You point PHPStan at a five-year-old Laravel codebase, run it for the first time, and get 2,400 errors. Nobody is going to fix 2,400 errors before the next sprint, so the report gets closed and static analysis never happens. The baseline exists precisely for this moment: it lets you draw a line under the existing debt and start enforcing the ruleset on everything written from today.
I've adopted a PHPStan baseline on several inherited apps now. The mechanics are simple — it's the discipline around the baseline that decides whether it pays off or quietly rots.
Run PHPStan and generate a baseline#
In a Laravel project you don't install PHPStan directly — you install Larastan, which bundles PHPStan and teaches it about Eloquent, facades, container bindings and the rest of the framework's magic. Larastan 3.x requires PHPStan 2.x, which is the line that introduced level 10.
composer require --dev larastan/larastan
Create a phpstan.neon in the project root. Pull in Larastan's extension, set a starting level, and tell it which paths to analyse.
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 5
paths:
- app
- database
- routes
Now run it once to see the damage, then generate the baseline:
# See the full error count first
vendor/bin/phpstan analyse
# Freeze the current errors into a baseline file
vendor/bin/phpstan analyse --generate-baseline
That second command writes a phpstan-baseline.neon containing every current error grouped by message and count per file. Include it in your config so those errors are skipped on subsequent runs:
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- app
- database
- routes
Run vendor/bin/phpstan analyse again and you'll get a clean exit. The 2,400 errors are still there in the ledger — they're just not failing the build. Commit both files. The baseline is part of your source, not a local artefact.
Keep the baseline from growing in CI#
Here's the part people miss. A baseline only delivers value if new code is held to the full ruleset. The mechanism is almost free: in CI you run phpstan analyse against the committed baseline and never regenerate it. Any error in new or modified code that isn't already in the baseline fails the run.
# .github/workflows/static-analysis.yml
name: Static Analysis
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- run: composer install --no-interaction --prefer-dist
# No --generate-baseline here. Uses the committed baseline as-is.
- run: vendor/bin/phpstan analyse --no-progress
The real failure mode is social, not technical: a developer hits an error, runs --generate-baseline locally to make it go away, and commits the bloated baseline. That silently swallows the new debt. Guard against it by failing CI when the baseline file grows:
#!/usr/bin/env bash
# scripts/check-baseline-not-grown.sh
# Fail if phpstan-baseline.neon has more ignored errors than on the base branch.
set -euo pipefail
git fetch origin "${GITHUB_BASE_REF:-main}" --depth=1
base_count=$(git show "origin/${GITHUB_BASE_REF:-main}:phpstan-baseline.neon" 2>/dev/null \
| grep -c 'message:' || echo 0)
head_count=$(grep -c 'message:' phpstan-baseline.neon || echo 0)
if [ "$head_count" -gt "$base_count" ]; then
echo "Baseline grew from $base_count to $head_count entries. Fix the new errors instead of baselining them."
exit 1
fi
echo "Baseline did not grow ($head_count entries)."
This pairs naturally with the other quality gates you're probably already running — if you've wired up Pint on a pre-commit hook, drop the static-analysis check into the same GitHub Actions matrix you use for tests so style, types and tests all gate the same PR.
Burn down the baseline deliberately#
A frozen baseline that never shrinks is just technical debt with extra steps. The point is to reduce it on purpose. PHPStan helps here because reportUnmatchedIgnoredErrors is on by default — once you fix an error, its baseline entry no longer matches anything and PHPStan reports the stale entry as a failure. That forces you to delete the entry, which means the baseline can only ever shrink as you fix things, never silently hide a regression in the same spot.
The practical workflow is to attack the baseline by category rather than file by file. Errors with the same identifier — say, missingType.iterableValue or argument.type — usually share a fix. Removing a cluster of related entries, fixing them, and re-running gives you visible, satisfying progress.
# Delete a category of entries from the baseline, fix the code, then verify
vendor/bin/phpstan analyse
If you want this structured, shipmonk/phpstan-baseline-per-identifier splits the baseline into one file per error identifier, so you can see exactly how much of each error type remains and burn down a whole identifier in one PR. And for mechanical fixes — adding return types, parameter types, nullable hints — reach for Rector to automate the Laravel-aware refactors instead of editing hundreds of files by hand. A Rector pass followed by a baseline regeneration often clears thousands of entries in an afternoon.
Once the production code is under control, it's worth extending the same discipline to your tests — running PHPStan over your Pest suite catches type errors in the code that's supposed to be protecting you.
Raise the level once the baseline shrinks#
The baseline lets you adopt static analysis at a comfortable level today and ratchet up later. PHPStan has levels 0 through 10, each adding checks on top of the last. Start where the first run is survivable — often level 5 on a legacy app — and only raise it once the current baseline is small enough to clear.
When you bump the level, you'll get a fresh wave of errors from the stricter rules. Regenerate the baseline at the new level, commit it, and start the burn-down again:
# After raising `level:` in phpstan.neon
vendor/bin/phpstan analyse --generate-baseline
The end state is level: 10 (or level: max) with an empty or near-empty baseline. The journey from level 5 to level 10 throws up a predictable set of Laravel-specific complaints — I've collected the most common PHPStan level 10 errors in Laravel and how to fix them so you're not solving each one cold.
Gotchas and Edge Cases#
The baseline matches on message text and count, not line numbers. Renaming a file, moving a class, or reordering methods can invalidate entries and surface errors you thought were frozen. This is mostly harmless — it just means a chunk of "old" errors suddenly need attention after a big move — but it can make a refactor PR look scarier than it is.
Don't baseline things you should exclude. Generated files, compiled views, or vendored legacy code don't belong in the baseline — use excludePaths in phpstan.neon instead. A baseline is for code you intend to fix eventually; excludePaths is for code you never will.
--generate-baseline is not a CI command. If it ever runs in CI, your build can't fail on new errors because they get absorbed instantly. Keep it strictly a local, deliberate action.
Larastan changed its package name. Older guides reference nunomaduro/larastan; the maintained package is larastan/larastan. If you're on an ancient version you'll also be pinned to PHPStan 1.x and can't reach level 10 — upgrade Larastan first.
Wrapping Up#
Generate the baseline, commit it, gate new code in CI, and treat the file as a shrinking ledger rather than a mute button. The level you start at doesn't matter — the direction does. Pick a category each sprint, delete its entries, fix, and watch the count fall.
From here, decide where static analysis sits among your other guardrails: my take on the wider Laravel developer toolchain for 2026 covers how PHPStan, Pint, Rector and Pest fit together so each tool catches a different class of bug before it ships.
FAQ#
What is a PHPStan baseline?
A PHPStan baseline is a file (usually phpstan-baseline.neon) that records every error PHPStan currently reports, grouped by message and count per file. When you include it in your config, those specific errors are ignored on future runs. It lets you adopt static analysis on an existing codebase immediately while only new errors fail the build.
How do I generate a PHPStan baseline in Laravel?
Install Larastan with composer require --dev larastan/larastan, create a phpstan.neon that includes vendor/larastan/larastan/extension.neon and sets a level, then run vendor/bin/phpstan analyse --generate-baseline. That writes phpstan-baseline.neon, which you then add to the includes section of your config so the existing errors are skipped.
Should I commit the PHPStan baseline file?
Yes. The baseline is part of how your project enforces static analysis, so it belongs in version control alongside phpstan.neon. Committing it means every developer and your CI pipeline analyse against the same frozen set of known errors, so any new error in a pull request fails consistently for everyone.
How do I stop the PHPStan baseline from growing in CI?
Never run --generate-baseline in CI — run plain vendor/bin/phpstan analyse so new errors fail the build. To stop developers from regenerating and committing a bloated baseline, add a CI check that compares the number of entries against the base branch and fails if the count increased. That keeps the baseline a shrinking ledger rather than a dumping ground.
What is the difference between Larastan and PHPStan?
PHPStan is the general-purpose static analysis engine for PHP. Larastan is a set of PHPStan extensions that teach it Laravel's dynamic patterns — Eloquent models, facades, container resolution, helper return types — so it produces far fewer false positives on a Laravel app. In a Laravel project you install Larastan, which pulls in PHPStan as a dependency, rather than installing PHPStan on its own.