sandermuller / richter
Measure the magnitude of impact of code changes in a Laravel codebase, built on Laravel Brain
Requires
- php: ^8.4
- illuminate/console: ^12.0||^13.0
- illuminate/contracts: ^12.0||^13.0
- illuminate/process: ^12.0||^13.0
- illuminate/routing: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
- laramint/laravel-brain: ^2.3.1
- nikic/php-parser: ^5.0
- spatie/laravel-package-tools: ^1.93
- symfony/finder: ^7.2||^8.0
Requires (Dev)
- driftingly/rector-laravel: ^2.5
- larastan/larastan: ^3.10
- laravel/boost: ^2.4
- laravel/mcp: ^0.8||^0.9
- laravel/pao: ^1.1
- laravel/pint: ^1.29
- nunomaduro/collision: ^8.9
- orchestra/testbench: ^11.1
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- rector/rector: ^2.5
- rector/type-perfect: ^2.1
- sandermuller/boost-skills: ^2.19
- sandermuller/package-boost-laravel: ^1.0
- spaze/phpstan-disallowed-calls: ^4.12
- symplify/phpstan-extensions: ^12.0
- symplify/phpstan-rules: ^14.12
- tomasvotruba/cognitive-complexity: ^1.2
- tomasvotruba/type-coverage: ^2.2
Suggests
- laravel/mcp: Exposes the impact and detect-changes tools over MCP — supported range ^0.8||^0.9; richter registers a local MCP server when installed.
Conflicts
- laravel/mcp: <0.8.0 || >=0.10.0
README
Richter
Measures the magnitude of impact of code changes in a Laravel codebase. Like the Richter scale, but for your PHP.
Built on Laravel Brain's static analysis, Richter constructs a directed code graph of your application (routes, controllers, jobs, listeners, policies, resources, Blade views, Eloquent relations). It reads two things off that graph:
- The blast radius of a symbol: its callers (what breaks if you change it) and its dependencies (what it reaches).
- What the current branch diff touches: the HTTP/CLI entry points and flows the changed files reach, plus a coarse risk level.
You can use those results three ways:
- CLI: a self-review aid before you push.
- MCP: Richter registers a local
richterserver exposing both analyses read-only, so a coding agent can check a symbol's blast radius or triage the branch diff mid-review without shelling out. - CI and PR review: run
richter:detect-changesagainst the pull request's base ref and post the report for the reviewer, human or agent.
Richter is advisory by default: richter:detect-changes exits 0, and a low or empty result is a signal, not a guarantee of no impact. Opt into a CI gate with --fail-on / --fail-on-unresolved when you want a non-zero exit (see Gating in CI).
What it's for
Richter shows what a change reaches, before you or your reviewer have to guess.
- Catch what you missed, before review. Run
richter:detect-changeson your branch and read the entry points and flows the diff reaches. Anything you didn't expect it to touch is worth a look before you open the PR. - Turn reach into a test-coverage prompt. Every reached entry point is tagged
[test-referenced]or[⚠ no test references this], and a referenced entry point whose referencing tests contain no behavioural assertion the scan recognises is tagged[test-referenced — no behavioural assertion found]— a heuristic prompt, not a coverage verdict. An entry point whose behaviour you changed with nothing referencing it is a place to add a test; the tag flags a missing reference, not proof the code is untested. - Hand the reviewer your blast radius. Drop the report into the pull request description, or let a coding agent read it over MCP, so review starts from what the change reaches instead of a cold diff.
- Size a refactor first. Before you rename or rework a symbol,
richter:impact "App\Models\User"lists its callers (what breaks if you change it) and its dependencies (what it reaches).
The analysis never executes your application's routes, jobs, or commands. It is static analysis over a code graph, fast enough to run on every branch. It does, however, autoload classes from the analyzed checkout (to resolve constants, relation names, and queue interfaces), and autoloading runs a file's top-level code. Treat a checkout you would not composer install on as one you should not analyze either.
Coverage beyond Brain
Richter adds two things over Laravel Brain alone: the tooling above (CLI, MCP, and CI/PR review) and wider graph coverage. On coverage, it traces the edges a route-anchored analysis misses:
- queue dispatches, including unresolvable ones;
$listen-registered event listeners;- container bindings and interface implementations;
- policy references (
$user->can(PostPolicy::UPDATE, …)and@can(...)in Blade); - API resource composition;
- custom validation rules;
- trait usage;
- eager-load relation strings;
- view-to-view includes;
- frontend endpoint references — Wayfinder imports, Ziggy calls, endpoint literals in changed TS/JS/Vue files and Blade inline scripts (opt-in, see Frontend changes).
Installation
composer require --dev sandermuller/richter
Requires PHP 8.4+ and Laravel 12 or 13. Optionally publish the config:
php artisan vendor:publish --tag=richter-config
Usage
Blast radius of a symbol
php artisan richter:impact "App\Services\PostPublisher" php artisan richter:impact PostPublisher # substrings work too php artisan richter:impact "App\Services\PostPublisher" --json # machine-readable, for scripting php artisan richter:impact "App\Services\PostPublisher" --markdown # PR-ready markdown
Prints the symbol's callers (what breaks if you change it) and its dependencies (what it reaches), breadth-first. Each hop shows its depth (d1, d2, …) and the edge it was reached through, so a caller chain reads back to the entry point one hop at a time:
Callers (what breaks if you change "App\Services\PostPublisher"):
d1 App\Http\Controllers\PostController::publish (via action-to-service) — app/Http/Controllers/PostController.php
d2 App\Http\Controllers\PostController (via controller-to-action) — app/Http/Controllers/PostController.php
d3 route::POST::/posts/{post}/publish (via route-to-controller) — routes/web.php:24
Dependencies (what "App\Services\PostPublisher" reaches):
d1 App\Events\PostPublished (via action-to-event) — app/Events/PostPublished.php
Every hop carries its defining file (and line, when known), project-relative — no grepping to find what a report names.
With --json, stdout is a single document ({target, callers, dependencies}, each hop {depth, node, via, file?, line?}), or {"error": "…"} on failure.
Advisory change impact of the current diff
php artisan richter:detect-changes # diffs against richter.default_base php artisan richter:detect-changes --base=origin/develop php artisan richter:detect-changes --explain # show how each entry point reaches the change php artisan richter:detect-changes --json # machine-readable, for scripting or CI php artisan richter:detect-changes --markdown # PR-ready markdown, for descriptions and comments php artisan richter:detect-changes --html=impact.html # self-contained visual report (add --open to launch it)
Against the default HEAD, the diff is the working tree compared to the merge-base with --base — staged and unstaged edits are included, not just what's committed, so running this before you commit still sees your changes. (Passing an explicit non-HEAD ref instead replays that ref's committed tree.) The one gap git diff can't close is a brand-new file that was never git add-ed: it shows in no diff form, so a stderr-only note flags any such untracked file under app/, resources/views/, or a configured frontend root — never on stdout, so --json/--markdown output stays exactly the report.
Resolves which class members the branch changed (member-level, not file-level: a one-method change seeds that method, not the whole class), walks the graph, and reports:
- the entry points the change can reach — routes, commands, jobs, listeners, middleware, and Livewire/Filament component classes (a Blade-mounted component or Filament resource/page/widget is a user-facing surface even without a
route::node) — each tagged[test-referenced]or[⚠ no test references this]; - findings in the changed source itself, such as an eager-load or relation string that names no relation on any model. A missing comma between two relation constants is the classic case:
Post::OWNER . User::PROFILEconcatenates toownerprofile, a name Eloquent silently never resolves; - a coarse risk level (
low/medium/high); - honest degradation: a change that cannot be placed in the graph reads UNRESOLVED, never as a falsely reassuring "no impact", and an unfollowable dispatch makes a queue job read "unknown", not "none".
Changed files:
app/Models/Post.php (4 graph nodes)
app/Services/CategoryImporter.php (0 graph nodes) (coverage incomplete for this area — UNRESOLVED, not "no impact")
Entry points reached: 2 (some changed files are in an area not yet graphed — see UNRESOLVED above)
- command::categories:sync (app/Console/Commands/SyncCategories.php) [test-referenced]
- route::PATCH::/api/posts/{post} (routes/api.php:41) [⚠ no test references this] [authed]
Related models (association reach — context, not risk): 1
- App\Models\Category
Findings (in the changed source itself):
! app/Models/Post.php: eager-load string 'ownerprofile': segment 'ownerprofile' is not a method on any model — check the relation name (a broken constant concatenation reads exactly like this)
Impacted nodes: 7
Risk: MEDIUM (advisory — not a gate)
With --explain, each reached entry point carries the shortest call chain down to the changed code. That is the difference between knowing a change reaches PATCH /api/posts/{post} and seeing exactly which controller and service carry it there:
Entry points reached: 1
- route::PATCH::/api/posts/{post} [⚠ no test references this]
↳ route::PATCH::/api/posts/{post} →(route-to-controller) App\Http\Controllers\PostController::update →(action-to-service) App\Services\PostPublisher::publish
A self-listed entry class (a changed job or listener that is the entry surface rather than being reached from the change) deliberately carries no chain.
Reached routes also inherit Laravel Brain's security surface as advisory annotation: the exposure level renders inline ([public], [guest], [authed], [admin]) and any statically detected issues render under the route:
- route::POST::/webhooks/payments (routes/api.php:12) [⚠ no test references this] [public]
⚠ PUBLIC_WRITE (high): POST route with no auth middleware
This is annotation only — it never feeds the risk level or a --fail-on gate, it exists for routes only (Brain classifies nothing else), and false positives are suppressed where Brain's own config says so (laravel-brain.security.trusted_route_names / trusted_route_uris). A Livewire, Filament, or queue entry point never carries one of these tags at all — that absence means not classified, never "public" or "unauthenticated"; its real exposure comes from mount-time authorize() calls, middleware, or route placement the graph doesn't model.
Pennant feature gating is annotated the same way. A route guarded by EnsureFeaturesAreActive
renders its flags inline ([gated: ai-coach], a 🚩 badge in markdown, entryPointGates in JSON),
and a changed member or Blade view that itself checks a flag (Feature::active(...), @feature)
notes it under Findings — a flag-gated change has a smaller live blast radius than the raw graph
suggests, and the reviewer should know. Route detection reads statically visible middleware
(a string alias like 'features:ai-coach' or an FQCN-string form); the runtime-built
EnsureFeaturesAreActive::using(...) expression is invisible to static route parsing. Only the
Feature facade, @feature, and any feature_gate_methods-configured wrapper method are
recognised — a project convention like FeatureToggle::BETA_DASHBOARD->isActive() needs an
allowlist entry (see Configuration) before it is noted.
With --markdown, the report renders as GitHub-flavoured markdown: a risk badge up front, changed files as a table, entry points as a review checklist with their file:line, test tags and exposure badges, and long lists collapsed into <details> instead of truncated. The result is ready to paste into (or post onto) a pull request. --markdown --explain composes.
With --html=<path>, the report is written as ONE self-contained HTML file — every style and script inline, nothing fetched — so it opens offline straight from file:// and travels as a CI artifact you can link from a pull request. It has five tabs: Overview (a Files / Impacted / Depth / Risk stat row, the reached entry points, and what to focus on), Graph (the blast radius as concentric rings, one per BFS depth), Paths (how each entry point reaches the change), Changes (the member-level diff, naming the member that drove a low-confidence verdict), and Advisory (findings, test references, and the gate). --open launches it in the default browser afterwards; a failing opener is a warning, never a failed run.
Every file:line in the report is a clickable editor link. richter.editor reads the same env chain debugbar and Ignition do (CODE_EDITOR, then DEBUGBAR_EDITOR, then IGNITION_EDITOR) and, like debugbar, defaults to phpstorm, so an existing setup needs no new variable. Supported: phpstorm, idea, vscode, vscode-insiders, vscode-remote, vscodium, sublime, textmate, emacs, macvim, atom, nova, netbeans, xdebug. Set it to null to keep the file references plain text — worth doing for a shared CI artifact, since a link embeds an absolute local path that only opens on the machine that generated the report.
--html cannot be combined with --json or --markdown. It replaces the text report on stdout but never touches the gate: --html --fail-on=medium still exits non-zero exactly when the gate trips. The diagram is capped at 300 nodes and says so in the report when it caps — the counts above it are never capped. Note that the HTML is a rendering surface, not a contract: its markup is free to change in any release. --json remains the semver-governed machine output.
With --json, stdout is a single JSON document (the full, uncapped report) with these top-level keys, or {"error": "…"} if the diff can't be resolved:
| Key | Type | Meaning |
|---|---|---|
base |
string | the ref the diff was taken against |
changed |
object | {file: graph-node count} per changed file |
coverage |
object | {file: "analyzed" | "unresolved"} per changed file |
entryPoints |
string[] | entry-point nodes the change reaches |
entryPointPaths |
object | per reached entry point, the shortest call chain down to the changed code as {node, via, file?, line?} hops; a self-listed entry class carries no chain |
entryPointLocations |
object | per entry point, its defining {file, line?} (project-relative), when known |
entryPointSecurity |
object | per reached route, Brain's security surface {exposure, riskLevel, issues[]} — advisory annotation, routes only, never an input to risk or the gate; a Livewire/Filament/queue entry point has no key here at all, meaning "not classified," never "public" |
entryPointGates |
object | per reached route, the Pennant feature flags gating it — advisory annotation, never an input to risk or the gate |
entryPointTestReferences |
object | per reached entry point, "referenced" / "referenced-no-behavioural-assertion" / "unreferenced"; an entry point whose reference state cannot be determined is omitted from the map — advisory annotation, never an input to risk, the gate, or affected-tests selection |
impacted |
int | count of risk-bearing nodes reached |
relatedModels |
string[] | models reached only via association edges (context, not risk) |
risk |
string | "low" / "medium" / "high" |
lowConfidence |
bool | a changed member couldn't be pinned, so part of the estimate is coarse |
coarseCapApplied |
bool | a low-confidence high was capped to medium |
findings |
string[] | source-level findings, as shown above |
unresolved |
bool | any changed file is UNRESOLVED |
gate |
object | present only under a --fail-on* flag (see Gating in CI) |
Risk levels
Risk is a coarse, advisory signal, deliberately simple so --fail-on stays predictable:
| Level | Condition |
|---|---|
high |
≥ 3 entry points reached, or ≥ 20 impacted nodes |
medium |
≥ 1 entry point reached, ≥ 5 impacted nodes, or the diff changes an entry-point class (job, listener, command, Livewire, observer, middleware) |
low |
everything else |
Association edges (model relationships, trait usage, declares) are reach and context, not risk. They never count toward the impacted-node total, so touching a hub model or trait can't saturate a change to high on breadth alone.
A separate guard covers low confidence. When a changed member can't be pinned to a graph node and only a coarse class-level seed is available, a resulting high is capped to medium (coarseCapApplied). A low-confidence estimate shouldn't drive the top level on its own.
Gating in CI
detect-changes is advisory by default (exit 0). Two opt-in flags turn it into a gate:
--fail-on=<low|medium|high>exits non-zero when the reported risk is at least that level (see Risk levels).--fail-on-unresolvedexits non-zero when any changed file is UNRESOLVED (changed code the graph can't place). It works independently of the risk threshold.
Either flag also fails an un-assessable diff (a broken or invalid base ref) rather than letting it pass as "no impact". Add --json and stdout carries a gate object alongside the report.
A pull-request check that surfaces the blast radius and fails on high-risk or unplaceable changes:
name: Impact on: pull_request jobs: richter: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # detect-changes diffs against the base ref, so it must be in history - uses: shivammathur/setup-php@v2 with: php-version: '8.4' - run: composer install --no-interaction --prefer-dist - run: cp .env.example .env && php artisan key:generate # detect-changes boots the app to build the graph - run: php artisan richter:detect-changes --base=${{ github.event.pull_request.base.sha }} --fail-on=high --fail-on-unresolved
No GitHub Action ships with the package. detect-changes is a plain Artisan command, so wire it into whatever pipeline you already run.
Note:
detect-changesrunsphp artisan, so it boots your Laravel application to build the graph. The job needs whatever booting the app normally requires: typically an.env(cp .env.example .env) and anAPP_KEY(php artisan key:generate), as above. Without them the command fails to boot before it can analyse anything.
The workflow analyzes the pull request's code, and analysis autoloads classes from that checkout (see above). For a public repository, keep the trigger on pull_request (never pull_request_target with a privileged token) so fork-submitted code runs without access to your secrets.
Affected-test selection
php artisan richter:affected-tests # human-readable selection php artisan richter:affected-tests --base=origin/develop php artisan richter:affected-tests --json # {base, determinable, reasons, tests, frontendTests, unreferencedEntryPoints} php artisan test $(php artisan richter:affected-tests --plain) # simple form — coarse but safe
Diffs the same way detect-changes does — against HEAD, staged and unstaged edits are included,
so the selection reflects what's actually on disk before a commit exists to diff against. An
untracked (never git add-ed) file under app/, resources/views/, or a frontend root is one
git diff cannot see (see above), so here it makes the selection undeterminable (exit 2) rather
than emit a narrowed set that silently omits it — the stderr note still fires, and git add-ing the
file includes it. The note is stderr-only, never on stdout, so --plain/--json stay clean.
The simple form only ever errs toward running more: both an undetermined selection and a
determined-but-empty one leave $(…) empty, and an argument-less runner executes the full suite.
To also skip the run when the selection is determined and empty, branch on the exit code:
tests=$(php artisan richter:affected-tests --plain); status=$? if [ "$status" -eq 0 ] && [ -z "$tests" ]; then echo "No affected tests." elif [ "$status" -eq 0 ]; then php artisan test $tests else php artisan test; fi # exit 2: not determinable — full suite
Inverts the test-reference index into a selection: the test files that reference any entry point
the diff reaches, plus the tests that import any changed or reached class (a unit test of an
intermediate caller never touches an entry point). A test naming a Livewire component by string
(Livewire::test('admin.dashboard'), the livewire() helper) counts as referencing
App\Livewire\Admin\Dashboard via the default naming convention. A schedule:: entry resolves
through the command it runs. Only conventionally-named *Test.php files are selected — helpers and fixtures
under tests/ never end up as runner arguments, and an entry point whose only references live in
a support trait blocks determination rather than silently dropping the tests using that trait.
Selection is reference-based recall, not proof of coverage — reached entry points nothing
references contribute nothing, and the report says how many those are.
It fails safe, and the exit code is the contract:
| Exit | Meaning |
|---|---|
0 |
Selection determined (possibly empty). |
2 |
Not determinable — run the full suite. Any UNRESOLVED file, low-confidence seed, unfollowable dispatch, uncheckable entry point, or an untracked relevant file git diff can't see trips this; the reasons are printed (text) or carried in reasons (JSON). |
1 |
Usage or unexpected error. |
In --plain mode an undeterminable run prints nothing, so the command-substitution form degrades
to the full suite by construction — as does a determined-but-empty selection, which is why the
exit-code branch above is the precise form.
Frontend changes (Wayfinder / Ziggy)
Opt-in — point frontend.roots at your frontend source in config/richter.php:
'frontend' => [ 'roots' => ['resources/js'], ],
Changed .ts/.tsx/.js/.jsx/.vue files are then scanned for the backend endpoints they
reference, and those routes are reported as touched entry points — with their location, exposure
and gate annotations, feeding richter:affected-tests — while risk and impacted stay
untouched: a frontend edit does not change backend behaviour, and the report says so explicitly.
Detected references:
- Wayfinder imports —
@/actions/App/Http/Controllers/PostControllerresolves through the router's action index (method-precise; aliased, default, invokable andimport typeforms included), and@/routes/postsroute imports plus Ziggyroute('name')calls resolve through the route names. Wayfinder's generated trees (actions/,routes/,wayfinder/under each root) and Ziggy's generated route map (ziggy.js) are excluded as regeneration churn, and.d.tsdeclaration files are never scanned — seefrontend.generated_pathsabove. - Endpoint strings, matched against the app's route templates: plain literals
(
axios.post('/posts')) and backtick templates whose interpolations wildcard one segment (fetch(`/posts/${id}`)matches/posts/{post}). A/-leading literal or template only counts as the first argument of an allowlisted HTTP/route callee —route,fetch,axios,useFetch,$http,$(jQuery),window,page/cy(Playwright/Cypress navigation) by default, plusfrontend.http_callees— matched on the callee's leading identifier before a.method(axios.get(...),$http.post(...),window.fetch(...),page.goto(...)). A verb-named call pins the HTTP method, whether the verb is the callee itself (post('/x')) or its.methodsegment (axios.post('/x')); anything unrecognisable stays method-agnostic and never narrows the match. Inline<script>blocks in changed Blade views get the same literal scan. Gating on the callee means a constants file, nav-link config, i18n helper (translate('/preferences')), or any other non-HTTP call is never mistaken for an endpoint call — and a project-custom HTTP wrapper needs registering viafrontend.http_calleesbefore its literals seed. A few idioms are a documented, deliberate recall loss: a URL assigned to a variable and used later (const URL = '/x'; fetch(URL)), an options object'surlproperty (axios({ url: '/x' })), and therequest(method, url)second-argument idiom (the URI's callee can no longer be identified once it isn't the call's first argument).
Frontend spec files (*.test.*, *.spec.*, *.cy.* under the roots, or frontend.test_paths)
referencing a touched route surface in richter:affected-tests as an advisory frontendTests
list for the JS runner — never in --plain (which feeds the PHP runner), and never a
determinability input.
The scan is regex-based and says so when it can't see: a dynamic route(…) argument or an
unmatched Wayfinder action import marks the file UNRESOLVED (and richter:affected-tests exits
2), while an unmatched route('name') string simply isn't a reference — routes/ modules and
route() helpers collide with frontend-router idioms, so unmatched names never guess. Before a
dynamic argument taints the file, it gets one resolution attempt against a same-module
const/enum string constant (route(ROUTES.player) resolves when ROUTES is a flat const
with exactly one player member); anything less certain — let, multiple declarations, imported
constants, nested bodies — keeps the fail-safe.
The bridge also runs in reverse, without any configuration: a changed backend member that
renders an Inertia page (Inertia::render('Posts/Show'), the inertia() helper) is noted
under Findings with the resolved page file under frontend.pages_path — or with an explicit
"no page file found" when the component doesn't resolve, which usually means a renamed or
deleted page.
Scoring accuracy against replayable history
php artisan richter:benchmark php artisan richter:benchmark --case=TICKET-123 php artisan richter:benchmark:add abc1234 php artisan richter:benchmark:add abc1234 --control
Replays historical fix commits (configured in richter.benchmark_cases) through the report: bug fixtures must resolve and reach an entry point; benign controls cap the risk a harmless change may report. Run it after changing the graph or tracers. A control flipping green→red is a regression in trustworthiness.
richter:benchmark:add scaffolds a case from a historical fix commit: it dry-runs the commit through the same replay, reports what it would score today, and prints a paste-ready benchmark_cases entry. It never edits the config file.
Each case in config/richter.php:
'benchmark_cases' => [ [ 'key' => 'TICKET-123', // label, and the --case selector 'fix_commit' => 'abc1234', // commit whose diff is replayed through the report 'bug_class' => 'background-job change (data not copied on duplication)', 'expect_signal' => true, // bug fixture: must resolve and reach an entry point 'max_risk' => 'high', // caps the risk a control (expect_signal: false) may report ], ],
Graph cache
Building the code graph is the dominant cost of every command. Richter caches the built graph on disk (default: storage/framework/cache/richter/graph.json), keyed by a content fingerprint of everything the build reads: app/, routes/, resources/views, the relevant config, and the package versions. Any input change rebuilds automatically, so a hit can only ever serve the graph the current code produces; there is no TTL to tune and no stale window.
- The cache is on by default; set
richter.cache.enabledtofalseto disable it. --no-cache(on every command) bypasses it for one run, the escape hatch for an input the fingerprint doesn't cover.- A corrupt or mismatched cache file reads as a miss and is rebuilt; it never fails a run.
--profile(onrichter:detect-changes) forces a fresh build and prints a phase-by-phase timing split to stderr, for judging where build time goes on a given codebase.
MCP server
When laravel/mcp is installed, Richter registers a local MCP server named richter exposing two read-only tools: impact (blast radius of a symbol) and detect-changes (advisory impact of the current branch diff). A coding agent can then triage changes without shelling out to Artisan. Because the MCP session holds the graph cache in memory, repeated tool calls in one review don't rebuild the graph. Both tools also return MCP structured content in the same shape as the CLI --json output, so an agent can branch on fields instead of parsing prose. The supported range is laravel/mcp ^0.8||^0.9; composer.json carries a matching conflict entry so an unvalidated release fails at resolution time rather than at boot.
Point Claude Code, Cursor, or any MCP client at the Artisan entry point, e.g. in .mcp.json:
{
"mcpServers": {
"richter": {
"command": "php",
"args": ["artisan", "mcp:start", "richter"]
}
}
}
Configuration
config/richter.php:
| Key | Default | Purpose |
|---|---|---|
default_base |
origin/main |
Git ref richter:detect-changes diffs against when --base is omitted. |
editor |
phpstorm (via CODE_EDITOR / DEBUGBAR_EDITOR / IGNITION_EDITOR) |
Editor for the clickable file:line links in the --html report — reuses debugbar's/Ignition's env chain. One of phpstorm, idea, vscode(+-insiders/-remote/ium), sublime, textmate, emacs, macvim, atom, nova, netbeans, xdebug, or null to keep the references plain text. |
dispatch_helpers |
[] |
Project-custom global job-dispatch helper functions (e.g. dispatch_with_retries) the dispatch tracer should follow. |
feature_gate_methods |
[] |
FQCN::method allowlist of project wrappers around Pennant (e.g. App\Enums\FeatureToggle::isActive) — an EnumCase->method() call then annotates the change as flag-gated, alongside the built-in Feature facade / @feature support. |
entry_point_roots |
Jobs, Listeners, Console/Commands, Filament, Helpers, Http/Middleware, Livewire, Observers |
Directories under app/ traced as entry points beyond Brain's route-anchored graph (graph tracing only; the analyzer's risk-floor namespace heuristics are fixed). |
frontend.roots |
[] (off) |
Frontend roots whose changed TS/JS/Vue files are scanned for Wayfinder/Ziggy endpoint references (see Frontend changes). |
frontend.generated_paths |
actions, routes, wayfinder, ziggy.js |
Wayfinder's generated trees and Ziggy's generated route map under each frontend root — excluded from scanning as regeneration churn. Each entry matches a directory, an exact file, or a *-glob (crosses /). .d.ts files are always excluded, regardless of this list. |
frontend.pages_path |
resources/js/Pages |
Where Inertia page components live — a changed member rendering a page is noted under Findings with the resolved file. |
frontend.test_paths |
[] (the frontend roots) |
Directories scanned for frontend spec files whose endpoint references feed richter:affected-tests' advisory frontendTests list. |
frontend.http_callees |
[] |
Extra JS/TS callees, beyond the built-in route/fetch/axios/useFetch/$http/$/window/page/cy, whose call-argument string literals count as backend endpoints. Matched on the callee's leading identifier, e.g. myHttpClient for myHttpClient.post(...). |
cache.enabled |
true |
On-disk graph cache, keyed by a content fingerprint of the build inputs (see Graph cache). |
cache.directory |
null |
Cache location; null means storage/framework/cache/richter. |
benchmark_cases |
[] |
Replayable accuracy fixtures for richter:benchmark. |
Filament coverage is class-level: resources, pages and widgets surface as entry points (and their computed HTTP routes come in through Laravel Brain when Filament is installed), but individual table/bulk actions are not modelled as separate entry points.
Richter assumes standard Laravel conventions: the App\ root namespace, app/Models, app/Policies, resources/views, and tests/.
Testing
composer test # test suite only composer qa-check # read-only pre-push gate: Rector + Pint dry-runs, PHPStan, tests — mirrors CI
composer qa is the auto-fixing variant — it rewrites the working tree (Rector, Pint), so use
qa-check when you only want to verify.
Changelog
See CHANGELOG for what changed per release.
License
MIT. See LICENSE.
