PHPackages                             decocode/laravel-mcp - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. decocode/laravel-mcp

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

decocode/laravel-mcp
====================

Read-only MCP server for Laravel apps — safe production data diagnostics for Claude (claude.ai + Claude Code).

v0.3.6(3w ago)045↓37.5%MITPHPPHP ^8.2

Since Jul 4Pushed 3w agoCompare

[ Source](https://github.com/decocode-pl/laravel-mcp)[ Packagist](https://packagist.org/packages/decocode/laravel-mcp)[ RSS](/packages/decocode-laravel-mcp/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (40)Versions (10)Used By (0)

decocode/laravel-mcp
====================

[](#decocodelaravel-mcp)

Read-only **MCP server** for Laravel applications — safe production data diagnostics for Claude (claude.ai + Claude Code). Built on the official [`laravel/mcp`](https://laravel.com/docs/mcp).

Requirements
------------

[](#requirements)

- PHP `^8.2`
- Laravel `^11.45 | ^12 | ^13` (Laravel 10 must be upgraded first)
- Laravel Passport (OAuth guard for claude.ai custom connectors)

Infrastructure — three MySQL accounts (per project)
---------------------------------------------------

[](#infrastructure--three-mysql-accounts-per-project)

Read-only is enforced at the **database** level, not just in code.

AccountGrantsScopeWhen`mcp_ctl`SELECT, INSERT, UPDATE, DELETEonly `mcp_*`, `oauth_*`, `mcp_audit_log`now`mcp_ro`**SELECT**business tables minus blocklistnow`mcp_rw`SELECT, INSERT, UPDATE (no DELETE/DDL)business tables in scopewhen write is enabledGenerate the exact grant statements with `php artisan mcp:grants:print` (hand them to a DBA — the command only prints, never executes). Two read-grant modes:

- **`--ro-mode=per-table`** (default) — `mcp_ro` gets `SELECT` on business tables only; secret tables (`mcp_*`, `oauth_*`, `sessions`, tokens) are excluded at the **database** level. This is the pilot / high-PII posture (DoD §13.7). MySQL cannot subtract a table from a `db.*` grant, so this is per-table.
- **`--ro-mode=schema`** — a single `GRANT SELECT ON db.*`, relying only on the app-level blocklist. Simpler, but it grants DB-level SELECT on auth/token/session tables too — **not for high-PII / pilot.**

Per-table means new tables aren't readable until granted. After adding a business table, run `php artisan mcp:grants:diff` — it reads `mcp_ro`'s current grants and prints **only the missing**`GRANT` lines (one or two), so you don't re-run the whole script. To move from `schema` to per-table: `REVOKE SELECT ON db.* FROM mcp_ro` first, then apply `mcp:grants:print` (apply in one session — `mcp_ro`loses read in between).

### `.env`

[](#env)

```
MCP_DB_HOST=127.0.0.1
MCP_DB_PORT=3306
MCP_DB_DATABASE=your_database

MCP_DB_CTL_USERNAME=mcp_ctl
MCP_DB_CTL_PASSWORD=

MCP_DB_RO_USERNAME=mcp_ro
MCP_DB_RO_PASSWORD=

# Public exposure (channel B, for claude.ai) — off by default
MCP_HTTP_ENABLED=false
MCP_HTTP_DOMAIN=mcp.example.com
MCP_ROUTE_PREFIX=mcp

# Channel B OAuth — service account (token `sub`) + operator hook
MCP_OAUTH_ACCOUNT=diag                 # provisioned via mcp:account:create
MCP_OAUTH_OPERATOR_GUARD=web           # guard holding the human operator
MCP_OAUTH_OPERATOR_GATE=mcp-operator   # Gate ability; or set mcp.oauth.operator_check in config
MCP_OAUTH_OPERATOR_LOGIN_ROUTE=login   # where to bounce an unauthenticated operator
# MCP_OAUTH_REDIRECT_ALLOWLIST defaults to the claude.ai/claude.com callbacks

# IP allowlist — on by default, local only
MCP_IP_ALLOWLIST_ENABLED=true
MCP_IP_ALLOWLIST=127.0.0.1,::1
```

Install
-------

[](#install)

```
composer require decocode/laravel-mcp
php artisan mcp:install
```

`mcp:install` publishes config + migrations, runs the migrations, and prints the manual steps (the `mcp` auth guard for `config/auth.php` and the `routes/ai.php` entries) — these are printed rather than auto-applied, because those files differ per project and version.

### Channel A (Claude Code, Bearer) — the primary path

[](#channel-a-claude-code-bearer--the-primary-path)

Beyond `composer require` + `mcp:install`, six steps get channel A live. `mcp:install` prints the first two verbatim:

1. **`config/auth.php`** — add the `mcp` guard (Passport driver) + `mcp_service` provider (`McpServiceAccount`). Existing guards stay untouched.
2. **`routes/ai.php`** — `Mcp::local('diagnostics', DiagnosticsServer::class)`.
3. **Three MySQL accounts + grants** — `php artisan mcp:grants:print` emits the exact `GRANT`statements; **a DBA runs them** (the package never provisions DB users). This is where read-only is actually enforced (`mcp_ro` is SELECT-only at the DB level).
4. **`php artisan passport:install`** — keys + personal access client (required for Bearer tokens).
5. **Account → grant → token:** `mcp:account:create ` → `mcp:account:grant  read` → `mcp:token:issue ` (the token is shown once).
6. **Review masking against the project schema** — patterns match by column name and are best-effort: a bare `name`/`city` or an unlabelled person column (`applicant`) is **not** auto-masked. Extend `masking.patterns` / `masking.allowlist` per database before exposing data. Re-do this for every new project — a different schema means different PII.
    - **Run `php artisan mcp:masking:audit`** — a full-schema scan that lists PII-suspect columns that are **not** masked, table by table. Reviewing masking against a diff or a hand-built list misses gaps a full scan catches (legacy/foreign column names, a bare `ip`, `old`/`new` audit values); make this part of the deployment's Definition of Done. Add `--strict` to fail CI on any gap or any table it could not scan. The heuristic is **deliberately broad** — expect false positives to dismiss, and treat an empty result as "no suspect column slipped the current config", **not**proof of no PII. It fails (never green) on zero readable tables or an un-introspectable table.
    - For a column that is PII in one table but a harmless label in another (`customers.name` vs `tracks.name`), use **`masking.table_patterns`** (mask `table.column`) instead of a global pattern; `masking.table_allowlist` un-masks a column in one table only. Keep `table_allowlist`keys specific — a glob key (or a matching **view** name) lifts the mask across every match.

### Channel B (claude.ai, OAuth 2.1 + PKCE)

[](#channel-b-claudeai-oauth-21--pkce)

> **PREREQUISITE — Passport must be dedicated to MCP in this app.** Channel B configures Passport **globally**, so enabling it in an app that also uses Passport for its own OAuth will disrupt that OAuth. Concretely, with `http.enabled` + `manage_passport` (default on) the package sets: **(1)**the consent view — bound as a callback so only MCP connectors (redirect on the allowlist) get the MCP screen and other clients keep the default `passport::authorize`; **(2)** global token lifetimes (1d / 30d / 90d) for **all** Passport tokens; and you must set **(3)** `passport.guard = mcp_web`globally, which changes the OAuth resource owner for the whole app. If Passport is shared, either separate the concerns first or set `mcp.oauth.manage_passport=false` and wire Passport yourself.

`php artisan mcp:install --with-oauth` scaffolds channel B: it publishes the consent view and prints the wiring. The package ships the whole operator-authorization layer — an operator middleware, the consent screen, the redirect allowlist and the Passport bootstrap — so you only wire it up and fill **one** project-specific hook.

1. **`config/auth.php`** — add a `mcp_web` **session** guard (over `mcp_service`) so the minted token's `sub` is the service account, not the human operator.
2. **`config/passport.php`** — `'guard' => 'mcp_web'` **only if Passport is used solely for MCP** in this app (it's a global setting; an app using Passport for other things must keep them separate).
3. **`bootstrap/app.php`** — append the operator gate to the `web` group: `$middleware->web(append: [\Decocode\LaravelMcp\Http\Middleware\EnsureMcpOperator::class])`.
4. **Answer "who may authorize a connector"** — the one project-specific edit:
    - **Gate ability** (simple): `Gate::define('mcp-operator', fn ($u) => $u->hasRole('Super Admin'))`, then `mcp.oauth.operator_gate = 'mcp-operator'` (+ `operator_guard`, `operator_login_route`).
    - **`McpOperatorCheck` class** (advanced): set `mcp.oauth.operator_check` to a class implementing `authorize(Request): bool` + `serviceAccountId(): ?int` — wins over the gate; use it for logic a Gate can't express or a dynamically chosen service account.
5. **`MCP_HTTP_ENABLED=true`** + a public HTTPS endpoint (a dedicated domain or a tunnel), and point `mcp.oauth.account` at the provisioned service account.

The consent screen is **always shown — never auto-approved.** Client registration is public + dynamic, so a silent approve would let one phished operator click authorize an attacker's client; the redirect allowlist pins the delivery channel (claude.ai), the consent click pins the recipient. The package applies this Passport bootstrap automatically when channel B is on (opt out with `mcp.oauth.manage_passport=false`). Make your own RODO/GDPR decisions on what to mask.

> Channel A is repeatable and mostly config. Channel B adds the wiring above — five steps, one of them the operator hook.

Security model (summary)
------------------------

[](#security-model-summary)

- **Read-only** enforced by the `mcp_ro` SELECT-only user (DB level) + capability checks (app level).
- **Deletion** is impossible: no data-plane user ever holds DELETE, plus a global kill-switch.
- **PII** masked deny-by-default by column-name pattern — the shipped patterns cover credentials, financial/national ids *and* direct PII (e-mail, phone/mobile, person-name columns incl. no-underscore variants, street/address incl. `ip_address`, postal/zip code, date of birth, user-agent) — plus recursive scrubbing of PII nested in JSON / PHP-serialized columns (toggle `MCP_MASK_SCRUB_JSON`, default on); whole tables hidden by a blocklist — with one documented gap: `SHOW TABLES` / `SHOW TABLE STATUS` still reveal that a blocklisted table EXISTS and its metadata (row estimate, size, update time), because the blocklist scans the statement for the table's NAME and `SHOW` does not contain it. No row content is exposed. Matching is best-effort by column name: broad categories (`*address*`, `*ssn*`, `*zip*`, `*dob*`, `*birth*`) can't slip a variant, but person names can't go broad (a `*name*` would swallow entity names) — an unlabelled person column (`applicant`) or a bare `name`/`city` is NOT auto-masked, so review your schema and extend `masking.patterns`. Broad `*address*` also masks FK columns (`address_id`) — allowlist the ones you need for joins. Verification timestamps (`email_verified_at`) are un-masked by default. Un-mask a field via `masking.allowlist`, or soften it via `masking.partial` (keyed by exact column name, e.g. a column named `email` → domain). **Table-qualified** rules (`masking.table_patterns` / `masking.table_allowlist`) close the bare-`name` gap at the source — mask a column only in the table(s) where it is PII — and apply wherever the tool knows the source table (`schema_describe`, `count_rows`, `order_inspect`, and `read_query` SELECTs — which are single-table by design, since `read_query` **rejects JOINs and comma-joins** (a multi-table result has no single source table and would fall back to name-based masking, letting a per-table-masked column leak). Audit coverage with `php artisan mcp:masking:audit`.
- **Filterability is a separate decision from visibility.** Masking a column also removes it from every predicate (filtering on a secret turns a row count into an existence oracle) — but diagnosis often needs to *ask* about a value it already holds, without ever *seeing* it: a voucher code quoted in a customer's ticket, an order reference from an e-mail. **`masking.filterable`** (table-pattern =&gt; column-patterns) marks a column that stays fully masked in output yet may be referenced in a `count_rows` **or `read_query` `WHERE`** predicate — and only as `col = 'value'`, `col IN ('a','b')`or `col IS [NOT] NULL`, with the value **quoted** (a bare number would compare numerically and match on a prefix). Ranges, `LIKE`/`REGEXP`, arithmetic and bitwise operators, casts, functions wrapping the column and comparisons to another column are refused: those make a masked value *searchable* instead of confirmable. The term must also stand on its own, joined only by `AND`/`OR`/`XOR`/`NOT` — otherwise an operator could bind to a vetted equality and change what is compared (`'379' = code >> 4` is a bisection, not an equality). Deny-by-default (empty map ships) and table-qualified; a wildcard table key is dropped rather than honoured, since filterability is a judgement about one column in one table. **Enable it only where the value space is too large to walk (≳2^40 — a 10+ character code, a UUID, a hash).** The 50-value cap is per call, not per caller: at the shipped throttle of 60 req/min it still allows ~4.3M guesses a day, so an 8-digit reference falls in ~23 days and a 6-digit code in ~4 hours. Order/invoice numbers and external references are usually sequential — not candidates. Note also that the compared value reaches MySQL as a literal, so it can land in `general_log` / `slow_query_log` / APM traces; check those before enabling. Never for low-entropy personal data (`email` is a one-call membership oracle, `date_of_birth` has ~29k values): there the count alone answers "is this person in your database". Columns used this way are recorded in the audit trail (`masked_predicate`), `schema_describe` reports a `filterable` flag per column, and `mcp:masking:audit` lists them for review.
- **Filterability applies to `WHERE` only — never to `ORDER BY` / `GROUP BY` / `HAVING`.** Those keep the blanket refusal: the argument for filtering is about *equality against a value you already hold*, while sorting by a hidden value discloses its order across every row returned and grouping discloses its cardinality — neither is something a per-column opt-in can make safe. `read_query` therefore splits its tail in two and relaxes only the first half. Note what `read_query` adds over `count_rows` here: it returns the ROW, so the caller learns the record a code belongs to, not just how many rows carry it. That is usually the point (the order behind a voucher code from a customer's ticket) and it is also the extra disclosure — weigh it when opting a column in.
- **Proving a duplicate without revealing the value:** `masking.partial` accepts `fingerprint`, which emits a keyed HMAC of the value (`[fp:9a3c1f2b7d04]`) instead of the placeholder. Equal values read as equal fingerprints, so "did this card issue the same code twice" is answerable from ordinary masked output with no predicate relaxation at all. Keyed off `APP_KEY` (nobody can confirm a candidate value offline; nothing correlates across deployments) and fail-closed without one. It does reveal the column's equivalence classes — right for an identifier, a linkage leak for personal data, so it is opt-in per exact column name. (`last4` is digits-only — it mangles a hex code; use `fingerprint`.)
- **Capabilities** (`read` / `write` / `command:run`) are stored in `mcp_*` tables and managed via artisan commands — never in the repo.

Tools (read-only)
-----------------

[](#tools-read-only)

Each tool self-filters by the caller's capabilities — a `read`-only identity only ever sees the read tools, and execution is re-checked server-side. Every call is recorded to a fail-closed audit trail (if the call cannot be logged, no data is returned).

ToolCapabilityWhat it does`read_query``read`Ad-hoc `SELECT` against `mcp_ro`. SELECT-only, single statement, forced LIMIT, blocked tables refused, sensitive columns + nested JSON masked. To keep masking from being evaded, the projection allows only `*` / `t.*` / bare columns / numeric literals — **function calls, expressions, aliasing, `UNION`/`INTERSECT`/`EXCEPT`, CTEs, `FROM`-subqueries, JOINs/comma-joins and JSON extraction are rejected** (each can surface a column past name-based or table-qualified masking). A masked column may appear in `WHERE` only if the project marks it `masking.filterable`, and never in `ORDER BY` / `GROUP BY` / `HAVING`. `EXPLAIN ` is held to the same rules as the SELECT it wraps; after an `EXPLAIN`/`DESCRIBE` only a `SELECT` or a plain `DESCRIBE ` is accepted (`ANALYZE`, `FORMAT=JSON`/`TREE`, `TABLE …` and `FOR CONNECTION` are refused). A double quote outside a string literal is refused too — its meaning depends on `sql_mode`, so use `'` for values and back-ticks for identifiers — as are control bytes (C0 except tab/newline/CR, plus DEL), which can steer how MySQL parses a comment, a backslash before a quote inside a literal (write `''` instead of `\'`), and a back-quoted identifier containing anything outside `[A-Za-z0-9_$]`. Single-table only — aggregates live in `count_rows`.`count_rows``read`Row count for a table, optional `WHERE`. The projection is fixed to `COUNT(*)` (never a column value), the table must be a plain non-blocked identifier, and the assembled query is guarded. Masked columns may not be referenced — **except** those the project marks `masking.filterable`, which accept an equality test against a value the caller already holds (never a range, `LIKE` or function). Fills the aggregation gap `read_query` leaves.`schema_describe``read`Lists readable tables, or a table's columns. Blocked tables are hidden; each column notes whether its values are `masked` and whether it is `filterable` — freely for an un-masked column, **equality-only, and only in a `WHERE`,** for a masked one. Returns **no row data**.`order_inspect``read`Example domain tool — fetches one record + related rows by id. **Config-driven**; only registers once `mcp.tools.order_inspect` is set, so the package ships no project-specific schema.The server exposing these tools is registered in the app (not auto-edited by `mcp:install`). Add to `routes/ai.php`:

```
use Decocode\LaravelMcp\Servers\DiagnosticsServer;
use Laravel\Mcp\Facades\Mcp;

// Local/stdio (Claude Code, MCP Inspector via `php artisan mcp:inspector`):
Mcp::local('diagnostics', DiagnosticsServer::class);

// Public HTTPS (claude.ai), behind the mcp guard + IP allowlist + throttle:
Mcp::oauthRoutes();
Mcp::web('/mcp/diagnostics', DiagnosticsServer::class)
    ->middleware(['auth:mcp', 'mcp.ip-allowlist', 'throttle:'.config('mcp.http.throttle')]);
```

Reading the audit trail
-----------------------

[](#reading-the-audit-trail)

Every call lands in `mcp_audit_log` on the control-plane connection — the ones that ran, the ones the database rejected, and (since 0.3.6) the ones a **guard refused before they reached the database**. `mcp:audit:report` is how you read it. This is not a convenience: the trail is the **compensating control** the `masking.filterable` relaxation is argued on. The cap on how many values one call may test is defended by "a sweep would show up as a burst in the audit" — which is only true if somebody looks, and only useful if the refused attempts are in there too. They are the ones that matter most: where a column is *not* marked filterable, every probe against it is refused, so before 0.3.6 the most suspicious traffic was the only traffic that left no trace.

```
php artisan mcp:audit:report                          # last 7 days
php artisan mcp:audit:report --since="-2 hours"       # around an incident
php artisan mcp:audit:report --masked-only            # only calls that filtered on a masked column
php artisan mcp:audit:report --tool=read_query --outcome=rejected
php artisan mcp:audit:report --since=2026-07-01 --json | jq .masked_column_activity
```

The report ends with per-column counts for masked-column predicates, split by account and by outcome — **executed** (the project marked the column `masking.filterable`, so the call was answered) versus **refused** (it did not, and a guard stopped the call). The shape to look for is **one column, one account, many calls in a short window**: that is a value sweep, not a diagnosis. Refusals in that shape mean someone kept hitting a wall; executions in that shape mean they were being answered. `--outcome`takes `ok`, `rejected` (a guard refused the call), `denied` (the identity lacks the capability), `invalid` (the arguments never got as far as a guard), `failed` (the database refused) or `unknown`(the row cannot be read as expected — never hidden by a filter, whatever you pass). `denied` is the one to reach for after a grant is revoked: it is how an identity sweeping tools it may not use looks.

Counts cover the **whole window**, not the printed page: `--limit` caps the rows shown, never what was counted, so a sweep that falls off page one still appears in the per-column totals.

It reads only, and it is an **operator-side** command: the artisan-over-MCP channel is not implemented, so no command can be invoked over MCP at all. (`config('mcp.commands.denylist')` names `mcp:*` for the day it is — that entry is a stated intention, not yet an enforced control.) **Its output carries production data:** a statement that ran is stored with the literals compared against masked columns cut out, and everything else is verbatim SQL; one a guard refused is stored as a skeleton (see below). The `table` argument is an exception on both paths — it is recorded close to what the caller wrote (bounded, control bytes removed, runs of three or more digits blanked, so `orders_2024` reads as `orders_#`), because a trail that cannot say which table was asked about answers nothing.

Straight SQL, if you would rather not go through artisan:

```
-- What ran in the last hour, newest first.
SELECT id, created_at, account_id, name, row_count, parameters
FROM mcp_audit_log
WHERE created_at >= NOW() - INTERVAL 1 HOUR
ORDER BY id DESC;

-- Masked-column predicates per account — the burst check (MySQL 5.7+/8).
-- Reads the FIRST masked column of each call; a predicate touching two of them is
-- counted under one. `mcp:audit:report --masked-only` counts every column separately.
SELECT account_id,
       JSON_UNQUOTE(JSON_EXTRACT(parameters, '$.masked_predicate[0]')) AS masked_column,
       COUNT(*) AS calls,
       MIN(created_at) AS first_seen,
       MAX(created_at) AS last_seen
FROM mcp_audit_log
WHERE JSON_EXTRACT(parameters, '$.masked_predicate') IS NOT NULL
  AND created_at >= NOW() - INTERVAL 7 DAY
GROUP BY account_id, masked_column
ORDER BY calls DESC;
```

Two things to know when reading rows by hand:

- A call that returned normally records `"outcome":"ok"` — but rows written **before 0.3.6** recorded nothing at all on success, so on an older trail the absence of `outcome` means the same thing.
- A **refused** row carries `refusal` (a reason code plus the config rules involved) and its statement under `sql` as a **skeleton**: `SELECT id FROM orders WHERE code = 'a1b2c3d4e5'` is stored as `SELECT ? FROM ? WHERE ? = ?`. Everything that is not a known SQL keyword becomes `?` — identifiers, literals, numbers, aliases alike. Nothing is known about a refused call at the time it is logged, so the row keeps the shape of the attempt and nothing else. Expect to lose names you *wanted* to see; that is the trade, and it is what makes "no value can be in this field" a statement rather than a hope.
- A refusal about a masked column also records `masked_predicate`, so `--masked-only` covers refused probes and not only the calls that ran. Since 0.3.6 that field holds the **config rule** that made the column masked (`orders.gift_card`, `*voucher_code*`) rather than the token from the query — a token that matched `*voucher_code*` may itself BE a voucher code. Rows written earlier hold column names; both read fine.
- A refusal about a **blocked table** records `blocked_pattern` instead — the blocklist entry (`mcp_*`), for the same reason and never the name the caller typed. It is a separate field on purpose: `--masked-only` answers "who went after a masked column", and a blocked-table refusal is not that. Find those with `--outcome=rejected`; the pattern is shown in the arguments column.

The trail is append-only and the package never prunes it. Retention is the deployment's call (it holds production SQL); `created_at` is indexed, so a date-bounded `DELETE` is cheap.

Authorization &amp; exposure
----------------------------

[](#authorization--exposure)

Tools resolve the calling identity through a dedicated **`mcp` auth guard** (Passport driver over `McpServiceAccount`) — the application's own guards (`api`, `sanctum`, …) are untouched. Capability gating denies by default, so an identity only sees and runs the tools its grants allow.

- **claude.ai (public HTTPS):** full OAuth 2.1 — `Mcp::oauthRoutes()` publishes the discovery (`.well-known/*`) + PKCE endpoints. Channel B is **off by default** (`MCP_HTTP_ENABLED`); enable it with an explicit domain, IP allowlist and throttle.
- **Claude Code (SSH/tunnel):** a Passport **personal access token** (scope `mcp:use`) as `Authorization: Bearer `. Issue with `php artisan mcp:token:issue ` (shown once), revoke with `php artisan mcp:token:revoke `.

```
MCP_HTTP_ENABLED=false            # channel B (public HTTPS) — opt in
MCP_HTTP_DOMAIN=mcp.example.com
MCP_HTTP_THROTTLE=60              # requests/minute on the MCP route
MCP_IP_ALLOWLIST_ENABLED=true     # default: local only
MCP_IP_ALLOWLIST=127.0.0.1,::1
```

> End-to-end OAuth against a live claude.ai connector and Claude Code is verified against a running application with Passport installed, so it is out of scope for this package's own test suite.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance95

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

9

Last Release

21d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e2276e6082c07c002d8b66093671feaf3f1306bd8aa6ced5c0769a46a7bb9ff6?d=identicon)[patryk-sawicki](/maintainers/patryk-sawicki)

---

Top Contributors

[![patryk-sawicki](https://avatars.githubusercontent.com/u/30780444?v=4)](https://github.com/patryk-sawicki "patryk-sawicki (9 commits)")

---

Tags

laravelmcpRead onlydiagnosticsModel Context Protocol

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/decocode-laravel-mcp/health.svg)

```
[![Health](https://phpackages.com/badges/decocode-laravel-mcp/health.svg)](https://phpackages.com/packages/decocode-laravel-mcp)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/cashier

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.

2.6k31.8M163](/packages/laravel-cashier)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M238](/packages/laravel-mcp)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M331](/packages/laravel-ai)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

265.2k](/packages/aedart-athenaeum)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
