shipmonk/phpstan-baseline-per-identifier
Splits your PHPStan baseline into one file per error identifier, with a loader that includes them all. Keeps baselines readable and reduces merge conflicts. Works with PHPStan 2.x baseline generation plus a simple split command.
composer require --dev shipmonk/phpstan-baseline-per-identifier
Requirements: PHP 8.1+ and PHPStan 2.x (2.4.0 is the current line). If your Laravel app or CI image is still on PHP 8.0, pin to a pre-2.4.0 release or upgrade the runtime first — Composer will refuse to resolve otherwise.
Where to look first. If you already have a monolithic phpstan-baseline.neon, that's your whole migration target. Delete the include for it from phpstan.neon.dist, then let PHPStan write a fresh baseline to a path you control:
vendor/bin/phpstan --generate-baseline=baselines/_loader.neon \
&& vendor/bin/split-phpstan-baseline baselines/_loader.neon
That produces a directory like:
baselines/
├─ _loader.neon
├─ empty.notAllowed.neon
├─ foreach.nonIterable.neon
└─ identical.alwaysFalse.neon
Wire the loader in — this is the only config change the package needs:
# phpstan.neon.dist
includes:
- baselines/_loader.neon
First use case. The most common day-one win in a Laravel codebase is reviewing an existing 1,000+ entry baseline. Before: one giant file, every regeneration rewrites everything, merge conflicts on every branch. After: git diff baselines/ shows exactly which kinds of errors you added or fixed, and you can open baselines/property.notFound.neon and see only the models/properties PHPStan is complaining about.
Make it repeatable immediately with a Composer script so nobody has to remember the two-step dance:
{
"scripts": {
"analyse": "phpstan analyse",
"baseline": [
"phpstan --generate-baseline=baselines/_loader.neon",
"split-phpstan-baseline baselines/_loader.neon"
]
}
}
Regenerate-then-split, always as a pair. split-phpstan-baseline reads the loader file PHPStan just wrote and re-emits the per-identifier files. Never hand-edit _loader.neon — it's a machine artifact. Do edit the individual identifier files if you must (e.g. dropping one stale entry), but expect the next regeneration to reconcile it.
Burn-down by deleting a file. The natural per-identifier remediation workflow: pick an identifier you want to eliminate, delete baselines/foreach.nonIterable.neon (and its entry in the loader), run composer analyse, and fix the real errors. When you regenerate, the orphan-cleanup step (2.3.0+) removes identifier files that no longer have matching errors, so your baseline directory shrinks visibly as you pay down debt. This makes per-quarter "reduce empty.notAllowed to zero" goals trackable in git history.
CI freshness gate. The highest-value pattern is failing CI when the committed baseline is stale:
- run: composer baseline
- run: git diff --exit-code baselines/
Because 2.3.0 preserves the order of existing entries and inserts new ones at their sorted position, this diff is normally tiny and reviewable — which is exactly what makes the gate practical rather than annoying.
PHP baseline variant. If you'd rather commit .php baselines (they're return [...] arrays, so they play nicer with some tooling and with include-order tricks), generate to baselines/loader.php instead:
vendor/bin/phpstan --generate-baseline=baselines/loader.php
vendor/bin/split-phpstan-baseline baselines/loader.php
If the loader extension is .php, all generated files are .php. Pick one flavour per repo and stick to it — mixing neon and PHP loaders in the same directory confuses both humans and the orphan cleanup.
Laravel-specific include ordering. Put baselines/_loader.neon after your hand-written ignoreErrors block in phpstan.neon.dist if you want project rules to read as authoritative. ignoreErrors entries aggregate, but keeping generated-then-manual ordering keeps the config file skim-readable. Likewise keep paths/excludes_analyse (e.g. bootstrap/cache, storage, _ide_helper*.php, database/*.php migrations you don't analyse) in your own config, not in the baseline.
Per-area baselines. The package splits by identifier, not by directory, so you can't get baselines/app/ and baselines/tests/ out of it directly. The workaround is two PHPStan invocations with two configs and two baseline directories (each generated/split separately), which is worth it only when app/ and tests/ have genuinely different suppression budgets.
Local pre-commit nudge. A post-checkout/pre-push hook that runs composer baseline && git diff --quiet baselines/ catches the "I fixed errors but forgot to shrink the baseline" case before it hits review.
Flags aren't sticky. --tabs and --no-error-count are per-invocation. If you use them once and not next time, every baseline file churns. Bake them into the Composer script permanently or don't use them at all.
Duplicate suppressions. The classic migration bug is leaving the old phpstan-baseline.neon include in place while adding baselines/_loader.neon. PHPStan then applies each ignore twice, count mismatches, and reportUnmatchedIgnoredErrors starts firing. Grep your configs for baseline after migrating.
__DIR__ stripping in PHP baselines. Since 2.4.0 paths are written absolute, without the __DIR__ prefix. That's better for portability across machines, but verify it on your setup: if your local checkout path differs from the CI path and the paths end up embedded, you'll get environment-specific diffs. Generate once locally, once in the CI container, and diff before committing to the workflow.
Orphan cleanup is safe but scoped. 2.4.0 deletes only files it recognises as baseline files, preserving non-baseline files sitting next to the loader. Older versions were more aggressive. Still, keep the loader directory clean — don't park hand-written neon fragments in baselines/ and expect them to survive forever.
Exit code of --generate-baseline. PHPStan's baseline generation exits 0 even when it found errors — it's a "write the file" mode, not a "check" mode. Never let a CI pipeline's only PHPStan step be the baseline generator; keep a separate phpstan analyse job that actually fails the build.
rawMessage needs a recent PHPStan. Baselines using rawMessage (bleeding-edge PHPStan since 2.1.24) are supported from 2.2.0, but the loader approach regenerates via native PHPStan, so if the loader is fine and the split fails, check your PHPStan version before suspecting the package.
Stale counts after manual edits. Each entry carries count: N. Delete a few errors by hand without regenerating and reportUnmatchedIgnoredErrors will flag the mismatch. Treat any manual baseline edit as "run composer baseline right after".
Filenames come from identifiers verbatim, dots and all. On case-insensitive filesystems (default macOS/Windows checkouts) two identifiers differing only by case would collide — vanishingly rare in practice, but if a split run produces fewer files than expected, check filenames before assuming a parser bug.
Debugging a bad split. Run vendor/bin/split-phpstan-baseline baselines/_loader.neon by hand and read its output; then git status baselines/ to see exactly what it created and removed. If the loader looks malformed, regenerate it with plain phpstan --generate-baseline — the splitter assumes it's a standard, unedited PHPStan baseline.
Don't use the legacy mode. The --error-format baselinePerIdentifier + extension.neon + shipmonkBaselinePerIdentifier.directory setup still works but loses PHPStan's result cache, doesn't support rawMessage, and needs manual rm baselines/*.neon in your script. There's no reason to adopt it on a new integration — the CLI approach is strictly better.
Upgrade watch. When you bump PHPStan major versions, error message formats shift, so baseline regeneration produces a one-off large diff (and possibly new identifiers appearing as files). Do that upgrade in its own commit, regenerate, and review the baseline diff separately from the dependency bump.
How can I help you explore Laravel packages today?