Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Infection Static Analysis Plugin Laravel Package

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...

View on GitHub
Deep Wiki
Context7

Getting Started

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

  • The repo's Stability section — it's the single most important paragraph, because it explains the pinned-version behaviour and the "hacky bootstrap" that replaces Infection.
  • Your existing infection.json.dist (source.directories, mutators, logs).
  • A dedicated 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.

Implementation Patterns

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:

  • On PRs: --filter against changed source paths, --threads=max, --min-msi set to current score (ratchet, don't chase 100).
  • Nightly / main branch: full run with --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:

  • includes only app/ (not tests/, not vendor/, not database/),
  • has no <psalm><ignoreFiles> entries covering app code,
  • has no baseline (--set-baseline output would suppress exactly the type errors you need),
  • sets 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."

Gotchas and Tips

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:

  • You cannot upgrade Infection independently.
  • Your 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.
  • Dependabot/Renovate will bump this package on 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:

  1. --only-covered (fewer mutants).
  2. --threads=max and PCOV instead of Xdebug in CI.
  3. Narrow source.directories — 20 files is 30 seconds, 400 files is an hour.
  4. Run subset jobs on PRs, full job nightly.

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:

  1. Reproduce the mutant by hand (copy the --show-mutations diff into the file).
  2. Run vendor/bin/psalm --config=psalm-mutants.xml --no-cache app/Actions/Foo.php.
  3. If clean → the issue is in your config scope (file not included, baseline, ignoreFiles).
  4. If Psalm errors but the plugin reports escaped → the pinned Infection version is probably not passing the mutation path through as expected; check --debug output for the temp file path and confirm it matches.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo