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.4.0
- 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
- 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, trace, detect-changes and affected-tests tools plus orientation resources 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
This package is auto-updated.
Last update: 2026-08-11 07:44:55 UTC
README
Richter
Measures the magnitude of impact of code changes in a Laravel codebase. Like the Richter scale, but for your PHP.
Run php artisan richter:detect-changes on a branch and Richter reports the HTTP/CLI entry points the diff can reach, flags the ones no test references, and attaches a coarse advisory risk level, so review starts from what the change reaches instead of a cold diff. What makes it worth installing:
- Member-level change impact. A one-method change seeds that method in the code graph, not the whole class. The graph covers routes, controllers, jobs, listeners, policies, resources, Blade views, and Eloquent relations, plus edges a route-anchored analysis misses: static calls, facades, container bindings, constant reads, polymorphic overrides.
- Honest degradation. A change the graph can't place reads UNRESOLVED, never a falsely reassuring "no impact". A coverage gap costs reach, but it never causes anything to be reported as unaffected.
- Test-coverage prompts. Every reached entry point is tagged
[test-referenced]or[⚠ no test references this], a heuristic prompt rather than a coverage verdict (tag details). An entry point whose behaviour you changed with nothing referencing it is a place to add a test. - Blast radius and traces on demand. Before a refactor,
richter:impactlists a symbol's callers (what breaks if you change it), its dependencies (what it reaches), and the entry surfaces behind those callers.richter:traceanswers "how does this even reach that?" with the shortest call chain between two symbols. - Affected-test selection.
richter:affected-teststurns the diff's reach into a test selection, with an exit-code contract that fails toward running the full suite whenever the selection can't be trusted. - Built for coding agents. Richter registers a local MCP server exposing every analysis read-only, so an agent can work with the graph mid-review without shelling out. The
--markdownreport is ready to post as a PR comment.
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 (see Gating in CI).
The analysis is static, built on Laravel Brain, and fast enough to run on every branch: it never executes your application's routes, jobs, or commands. 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.
Installation
composer require --dev sandermuller/richter
Requires PHP 8.4+ and Laravel 12 or 13.
laravel/mcp is optional (it lights up the MCP server), but when present it must
fall in the supported ^0.8||^0.9 range; Richter declares a conflict with anything outside it.
laravel/boost only pulls a compatible
laravel/mcp from v2, and Composer won't upgrade a package Richter doesn't depend on, so an
existing laravel/boost v1 install has to take that major in the same command, or the
install fails on the laravel/mcp conflict:
composer require --dev sandermuller/richter laravel/boost:* -W
Optionally publish the config:
php artisan vendor:publish --tag=richter-config
Set up Richter for your project
Richter is accurate only once it knows your app's shape: which subsystems are entry surfaces, which helpers dispatch jobs, your real base branch, your frontend stack. You can set that up two ways.
With an agent (recommended). Richter ships two invoke-only skills. /richter-setup (or ask your
agent to "set up Richter") inspects the project, proposes config/richter.php, and (only if you say
yes) scaffolds a CI comment workflow and registers the MCP server in .mcp.json. It shows you every
edit before writing it. /richter-review reviews the current branch graph-first: it runs the report,
triages the reached entry points (unexpected reach, missing test references, security and gate
annotations), walks the findings, and closes with an advisory verdict. It recommends, never gates. To
make the skills available: with boost-core, add sandermuller/richter to withAllowedVendors([...]) in your
boost.php, then vendor/bin/boost sync; with laravel/boost, they're discovered as a third-party AI
package (an existing install may need boost:update / package selection).
Or paste these prompts to any agent (two, so CI stays opt-in):
Configure:
Set up Richter for this Laravel project. Inspect the code and propose edits to
config/richter.php; show me each change and get my OK, write nothing unasked. Cover:default_base(my repo's real default branch),entry_point_roots(anyapp/subsystem reached via runtime/vendor dispatch, such as form-builder Forms or registry-dispatched calculators, thatrichter:detect-changesreportsUNRESOLVED; pick the narrowest dir),dispatch_helpers(custom job-dispatch wrapper functions), frontend roots if there's an Inertia/Wayfinder/Ziggy frontend, andeditor: nullif this is mainly for CI. Also flag any Laravel Brain config (security.auth_middleware/throttle_middleware, route/command/listener discovery) that would fix mis-classified routes at the source.
Add the CI advisory comment:
Add a GitHub Actions workflow that posts the Richter report as an advisory PR comment. First check whether Richter is already wired into an existing workflow and integrate there instead of adding a duplicate. Make the whole job non-blocking, least-privilege (
permissions: contents: read, pull-requests: write), triggered onpull_request(notpull_request_target), checkout withfetch-depth: 0, runphp artisan richter:detect-changes --base=<PR base sha> --markdownand post it as a sticky comment. Show me the file before creating it.
Usage
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. The note never reaches stdout, so --json/--markdown output stays exactly the report.
Resolves which class members the branch changed, walks the graph, and reports:
- the entry points the change can reach, each tagged
[test-referenced]or[⚠ no test references this]: 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 aroute::node); - 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". A file that resolved to no graph node also echoes the FQCN its path derived to (
app/Services/Inspector.php → App\Services\Inspector), which is what separates a coverage gap from a wrong root namespace. Before a file falls through to UNRESOLVED, one last lane lists the surfaces that file defines (a routes file, a legacyapp/Console/Kernel.php) as touched, without walking them or moving the risk level (why).
A member added to an existing class seeds nothing: nothing called it before, so it can break nothing. A brand-new file is different: the class itself is new, so it seeds on its class node and reports its reach, its own entry surface (a new command, job or listener), and a risk level accordingly, marked [new file] in the report. A diff that only adds files can therefore report medium/high and trip --fail-on.
Changed files:
app/Models/Post.php (4 graph nodes)
app/Services/CategoryImporter.php (0 graph nodes) (UNRESOLVED: coverage incomplete for this area)
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)
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
The report carries more advisory annotation, none of which feeds the risk level or a --fail-on gate; see the detect-changes reference for the full detail:
- Security exposure per reached route (
[public],[authed], …), inherited from Laravel Brain and cross-checked against Richter's own policy edges. Routes only; absence means not classified, never "public". - Pennant feature gates: a gated route renders its flags inline (
[gated: ai-coach]), and a changed member that itself checks a flag is noted under Findings. - Payload parity in three directions: a model field added but never mirrored into its resource, a resource
toArray()key removed while a frontend consumer still reads it, and a form-requestrules()field removed while a consumer still sends it. Deliberately no-guess; anything the checker can't statically enumerate is skipped rather than guessed at. - Middleware group membership: a changed middleware that routes reach through a group rather than an alias is noted with the group and how many routes it guards (
runs in middleware group 'api', which guards 142 routes). Expanding the group into edges would make every member report every route in the app as an entry point, so the note supplies the size those edges withhold.
Three output formats beyond the text report, documented in the detect-changes reference: --markdown renders a PR-postable report with a risk badge, entry-point checklist, and collapsed long lists; --html=<path> writes one self-contained HTML file with tabbed views of the blast radius (a rendering surface, not a contract); --json emits the full report as a single semver-governed document (key reference).
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.
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" --explain # chain from each reached entry surface 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, so you never have to grep for what a report names.
Between the callers and dependencies, the report names the entry surfaces the callers walk
reaches (routes, commands, schedules, and Livewire/Filament component classes), with the same
annotations detect-changes carries: defining location, [test-referenced] /
[⚠ no test references this] tags, security exposure and Pennant gates. --explain adds the shortest call
chain from each surface down to the symbol. The tags are orientation, not verdicts: impact
reports no risk figure at all, and the section reads (none) when the walk reaches no surface.
A symbol that matches nothing is a lead rather than a dead end: the report names the nearest graph nodes (ranked by shared identifiers, so a lookup under the wrong root namespace surfaces the real node), or, when nothing in the graph resembles it, how many nodes were scanned.
With --json, stdout is a single document ({target, callers, dependencies, entryPoints, entryPointPaths, entryPointLocations, entryPointSecurity, entryPointGates, entryPointAuthGates, entryPointTestReferences}; the entry-point keys share detect-changes' vocabulary and shapes,
so a consumer parses both reports identically, and each hop is {depth, node, via, file?, line?}),
or {"error": "…"} on failure.
Shortest path between two symbols
php artisan richter:trace "App\Http\Controllers\PostController" "App\Services\PostPublisher" php artisan richter:trace PostController PostPublisher # substrings work too php artisan richter:trace PostController PostPublisher --json # machine-readable php artisan richter:trace PostController PostPublisher --markdown # PR-pasteable chain
Answers "does FROM reach TO, and through which chain?", strictly in call direction; swap the arguments to query the reverse. A found path prints as one chain, each arrow labelled with the edge type connecting its two hops:
Path from "PostController" to "App\Services\PostPublisher" (call direction, 1 hop(s)):
↳ App\Http\Controllers\PostController::publish →(action-to-service) App\Services\PostPublisher
No path is a result, not an error (exit 0). The report then names the deepest caller reached
from the TO side within the depth limit, which tells you how far upstream connectivity extends
(it is not a pointer toward FROM), or says plainly that the target has no callers. An
unresolvable symbol is an error (an empty trace would read as "no path", a wrong answer rather
than an empty one), and the error carries the same nearest-graph-nodes lead impact renders.
With --json, stdout is {from, to, resolvedFrom, resolvedTo, found, path}, plus
furthestReached ({node, depth, file?, line?}) on a miss whose target has callers, or
{"error": "…"} on failure.
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
Selects the test files that reference any entry point the diff reaches, plus the tests that import
any changed or reached class (selection mechanics). Diffs the same
way detect-changes does, so staged and unstaged edits are included. Selection is reference-based
recall, not proof of coverage.
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, an unparseable app file, an unfollowable dispatch that a possible dispatch target in the change's reach could hide, an 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. |
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, run the full suite
Frontend changes (Wayfinder / Ziggy)
Opt-in: point frontend.roots at your frontend source in config/richter.php:
'frontend' => [ 'roots' => ['resources/js'], ],
Changed TS/JS/Vue files are then scanned for the backend endpoints they reference (Wayfinder
imports, Ziggy route('name') calls, endpoint string literals), and those routes are reported as
touched entry points, feeding richter:affected-tests, while risk and impacted stay
untouched: a frontend edit does not change backend behaviour. The bridge also runs in reverse: a
changed backend member that renders an Inertia page is noted under Findings with the resolved page
file. The frontend reference covers what is matched, what deliberately
isn't, and how the scan fails safe.
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 with four read-only tools: impact (blast radius plus reached entry surfaces of a symbol), trace (shortest call-direction path between two symbols), detect-changes (advisory impact of the current branch diff), and affected-tests (the test selection the diff warrants). For affected-tests, determinable: false means run the full suite: every non-determinable cause returns that shape with its reasons, never a tool error. Three read-only resources cover orientation without a tool call:
| Resource | URI | Content |
|---|---|---|
| Entry points | richter://graph/entry-points |
Every statically-known entry surface (routes, commands, schedules, Livewire/Filament components) with kind and file:line where known. |
| Graph stats | richter://graph/stats |
Node and edge counts by edge type, plus the honesty flags (hasUnparseableFiles, hasUnresolvedDispatches). |
| Config | richter://config |
The effective analysis configuration: base ref, root namespace, entry-point roots, dispatch helpers, feature-gate wrappers, payload-parity settings, the frontend bridge, cache and parallel switches. |
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. Every tool returns 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
Every key in config/richter.php (base ref, root namespace, editor links, dispatch helpers,
feature-gate wrappers, payload parity, second-hop analysis, entry-point roots, the frontend
bridge, cache and parallel switches, benchmark cases) is documented in the
configuration reference.
Documentation
- detect-changes reference: annotation lanes, payload parity, output formats, the JSON contract
- affected-tests reference: selection mechanics and fail-safe behaviour
- Frontend changes: the Wayfinder/Ziggy bridge in full
- Coverage beyond Laravel Brain: what Richter traces that a route-anchored analysis misses, and its known limits
- Configuration reference: every config key
- Benchmarking: scoring accuracy against replayable history
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.
Security
Found a vulnerability? Don't open an issue; see SECURITY for where to send it.
License
MIT. See LICENSE.
