roave/infection-static-analysis-plugin
Runs Psalm static analysis on escaped mutants from Infection test runs, killing mutations that violate your program's type signatures. Improves mutation scores by removing false escapes. Acts as a drop-in replacement for vendor/bin/infection with an extra --psalm-config flag. Note: hacky and slower...
This package is a drop-in replacement binary for infection/infection, not an add-on you wire into an existing Infection config. If your team already runs mutation testing, adoption is one command swap; if you don't run Infection yet, you have two things to set up.
1. Prerequisites
composer require --dev roave/infection-static-analysis-plugin
The package pulls in its own pinned infection/infection and vimeo/psalm versions. Do not also require infection/infection at a different version — Composer will either refuse to resolve or you'll silently get the pinned one.
You also need a coverage driver, exactly like Infection:
php -d xdebug.mode=coverage vendor/bin/roave-infection-static-analysis-plugin
# or use pcov, which is much faster in CI
2. First run
Run it once, unmodified, against your existing infection.json.dist:
vendor/bin/roave-infection-static-analysis-plugin
It accepts every Infection flag (--threads, --min-msi, --only-covered, --logger-html, --filter, …) plus one extra:
vendor/bin/roave-infection-static-analysis-plugin --psalm-config config/psalm.xml
If you don't pass --psalm-config, Psalm is invoked with its default config discovery — which in a Laravel app usually works because psalm.xml is at the project root, but is fragile in monorepos.
3. Where to look first
Stability section — it's the single most important paragraph, because it explains the pinned-version behaviour and the "hacky bootstrap" that replaces Infection.infection.json.dist (source.directories, mutators, logs).psalm-static-analysis-for-mutants.xml config rather than your main psalm.xml (see Gotchas — baselines will defeat the whole point).4. First real use case
The canonical win is the array_values() / generic-return example from the README: mutation testing flags "you didn't assert the return type precisely", but the mutant doesn't type-check, so writing a test for it is wasted effort. Point this plugin at a directory of small, well-typed value objects / actions (app/Actions, app/Support, app/Data) and you'll see MSI jump without adding a single test. Start there rather than on the whole app/ tree — Eloquent models and controllers mutate slowly and produce noisy escapes that static analysis can't help with.
Composer script as the single entry point. Never let engineers call the binary directly with different flags:
{
"scripts": {
"test:mutation": [
"@php -d xdebug.mode=coverage vendor/bin/roave-infection-static-analysis-plugin --only-covered --min-msi=70 --min-covered-msi=80"
],
"test:mutation:subset": [
"@php -d xdebug.mode=coverage vendor/bin/roave-infection-static-analysis-plugin --only-covered --filter=app/Actions"
]
}
}
The :subset script matters day-to-day: full mutation runs are minutes-to-hours, so the normal developer loop is --filter against the directory you're touching.
Nightly full run + PR-scoped gate. The practical CI split:
--filter against changed source paths, --threads=max, --min-msi set to current score (ratchet, don't chase 100).--logger-html=build/mutation.html and archive the report.- name: Mutation testing
run: |
php -d xdebug.mode=coverage vendor/bin/roave-infection-static-analysis-plugin \
--threads=max \
--only-covered \
--min-msi=${{ vars.MIN_MSI }} \
--logger-html=build/mutation.html
Pin the MSI floor in a variable, not in the command. Because this plugin raises your MSI relative to vanilla Infection, you can ratchet the threshold upward after the first run, but keep the plain-Infection number documented so you know how much of the gain is from static analysis vs. real tests.
Layer it after, not instead of, static analysis. Run your normal Psalm/PHPStan job first; run this second. They answer different questions — the normal job type-checks the real code, this one type-checks mutants. A green Psalm run tells you mutants will be cheap to kill; it does not tell you they already have been.
Dedicated Psalm config for mutants. Keep psalm.xml for your normal analysis and add psalm-mutants.xml that:
app/ (not tests/, not vendor/, not database/),<psalm><ignoreFiles> entries covering app code,--set-baseline output would suppress exactly the type errors you need),findUnusedCode="false" and lowers errorLevel to the minimum you're willing to fail on — you only care about type errors here, not style.Then:
vendor/bin/roave-infection-static-analysis-plugin --psalm-config=psalm-mutants.xml
Pair with --only-covered. Without coverage filtering, Infection mutates code with no tests at all; those mutants are escapes that static analysis sometimes kills and sometimes doesn't, producing a long tail of confusing "why is this still escaping?" investigations. --only-covered --only-covered narrows the problem to mutants your tests actually exercise.
Exclude Laravel's non-typed surface. infection.json.dist:
{
"source": {
"directories": ["app/Actions", "app/Support", "app/Data"]
},
"mutators": {
"@default": true,
"MethodCallRemoval": false
}
}
Skipping controllers, models, jobs and middleware keeps runs short and keeps the plugin's benefit focused where type signatures are strict enough for Psalm to say anything meaningful.
Debugging an unexplained escape with a subset run + --show-mutations. When a mutant is still reported as escaped and you believe it shouldn't be:
vendor/bin/roave-infection-static-analysis-plugin --filter=app/Actions/Foo --show-mutations --debug
Then reproduce Psalm manually against the mutant diff that --show-mutations prints. Nine times out of ten the answer is "Psalm didn't load this file" or "there's a baseline entry for it."
The pinned Infection version is the biggest ongoing cost. The README is explicit: this tool "relies on internal details of the underlying runner" and always pins a specific infection/infection. Concretely, that means:
infection.json.dist must stay compatible with the pinned version's schema, not the latest. Renamed keys or removed options will fail with confusing errors.infection upgrades — treat those as potentially breaking and read the release notes. The repo's changelog is dominated by "Update dependency infection/infection to vX" for a reason.Version support tracks PHP narrowly. Recent releases dropped PHP 8.2 and 8.3 (1.41.0 dropped 8.2, 1.44.0 dropped 8.3 and added PHPUnit 13). In a Laravel app that stays on an LTS-ish PHP version, check the release notes against your composer.json require.php before upgrading — otherwise you'll be stuck between "don't upgrade the plugin" and "don't upgrade PHP".
Psalm only, not PHPStan. The README says Psalm is the initial 1.0.x scope; PHPStan support is future work. If your Laravel app standardised on Larastan/PHPStan, this plugin gives you nothing until either you add a Psalm config or the package ships PHPStan support. Don't assume --psalm-config is generic.
Psalm baselines silently defeat the plugin. This is the single most common "it's not working" report. If app/Actions/Foo.php has a baseline entry suppressing a InvalidReturnType, the mutant will also be suppressed, so the escaped mutant stays escaped. Symptom: you add a real baseline-suppressed issue, mutation score drops, and no amount of --threads or cache clearing helps. Fix: a separate Psalm config with no baseline for mutant analysis.
Psalm cache and --no-cache. Psalm caches results by file path + content; mutants are written to temp paths, so cache hits are unlikely but partial cache pollution is possible across runs. If you see results that contradict a clean local reproduction, re-run with --no-cache on the Psalm config (or delete .psalm/cache), then re-enable.
It's much slower than plain Infection. The README says so directly. The per-mutant Psalm invocation dominates. Mitigations, in impact order:
--only-covered (fewer mutants).--threads=max and PCOV instead of Xdebug in CI.source.directories — 20 files is 30 seconds, 400 files is an hour.Exit codes are the gate, not the report. --min-msi / --min-covered-msi cause a non-zero exit; if you only parse --logger-html output in CI without those flags, the job goes green regardless. Set both flags and let the exit code fail the build.
Don't invoke both binaries in one pipeline step. They are alternatives, not a pair — running vendor/bin/infection and this binary in the same job doubles the work and gives you two conflicting reports. Pick one per workflow.
No extension points. There's no plugin interface, no service provider, no event hook — the binary replaces Infection's bootstrap entirely. All customisation happens through (a) infection.json.dist, and (b) the Psalm config. Plan accordingly: if you need custom mutant behaviour, you'll be patching this package, not extending it.
Laravel-specific traps. Psalm by default won't understand Laravel facades, Model::query() magic, container bindings, or Blade-adjacent code. If any mutated file touches those, Psalm will emit MixedAssignment/UndefinedMethod, which is technically a "type error" — meaning the plugin will kill mutants for the wrong reason, inflating your score. Scope source.directories to plain, strictly-typed classes and verify the score gain is real by spot-checking --show-mutations output on a small subset before trusting the number.
Local reproduction checklist when a mutant "should" be killed but isn't:
--show-mutations diff into the file).vendor/bin/psalm --config=psalm-mutants.xml --no-cache app/Actions/Foo.php.ignoreFiles).--debug output for the temp file path and confirm it matches.How can I help you explore Laravel packages today?