PHPStan Level 8: How to Get There Without Stopping Feature Work

#PHPStan level 8
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Why Most Teams Never Reach PHPStan Level 8

Ask a PHP team whether they run static analysis and most will say yes. Ask what level, and the honest answer is usually 0 or 1, set once during CI setup and never touched again. The reason is predictable: running PHPStan level 8 against a mature codebase for the first time typically surfaces several thousand errors, and nobody can justify a multi-week cleanup sprint while the roadmap is on fire.

The good news is that nobody has to. The migration strategy below comes from real client engagements on Symfony codebases between 40K and 700K lines. It gets teams from level 1 to level 8 over a few months while feature work continues at full speed, and it prevents the single most demoralizing failure mode: gating a pull request on errors in code the PR never touched.

What Each Level Actually Buys You

PHPStan's levels are cumulative, and they are not equally valuable. Knowing where the signal lives helps you decide how fast to push:

LevelWhat it addsNoise vs. value
0-2Unknown classes, methods, undefined variables, PHPDoc sanityAlmost pure value, fix immediately
3-4Return types, property types, dead code detectionHigh value, moderate effort
5-6Argument type checks, missing type hints (typed everything)The big jump: level 6 usually accounts for half of all errors
7Partially wrong union typesReal bugs hide here, especially around nullable returns
8Calling methods on nullable typesThe level that catches the classic "null pointer" production incident

Level 8 is the target worth naming in an engineering OKR because it eliminates the most common runtime failure in PHP applications: calling a method on a value that can be null. Everything above it (level 9's mixed strictness, level 10 in recent releases) is worthwhile later, but level 8 is where production incidents measurably drop.

Step 1: Establish the Baseline

The feature that makes this migration possible without stopping delivery is the baseline file. Run PHPStan at your target level and dump every current error into an ignore file:

vendor/bin/phpstan analyse --level 8 --generate-baseline

This produces phpstan-baseline.neon, a catalogue of every existing error with its file and count. Include it in your config and the effect is immediate: CI is green today at level 8, existing debt is frozen and documented, and any new code that introduces a new error fails the build.

This flips the economics. Instead of "we cannot enable level 8 until we fix 4,200 errors," the statement becomes "no new level 8 errors enter the codebase from today, and the 4,200 old ones are a tracked backlog." The baseline count becomes a burn-down metric you can put on a dashboard, and in our experience it is one of the few code quality metrics that teams actually enjoy watching fall. If you want to connect it to a broader measurement approach, see our post on code quality metrics that matter.

One rule keeps the baseline honest: it only ever shrinks. Regenerating the baseline to hide new errors defeats the entire mechanism, so treat a baseline regeneration in a PR diff as a review blocker unless it is clearly a deletion.

Step 2: Migrate One Directory at a Time

With the baseline protecting new code, schedule the cleanup as background work rather than a sprint-stopping project. The approach that works in practice is directory-by-directory ownership:

  1. Pick a bounded module, for example src/Invoice/.
  2. Fix all baseline entries for that directory in one focused PR.
  3. Remove the corresponding lines from the baseline.
  4. Move to the next directory.

A single directory is usually a day or less of work, small enough to slot between feature tickets. It also keeps review manageable: a PR that adds return types across one module is easy to approve, while a 4,000-file type-fixing PR is impossible to review and will sit until it rots.

Prioritize directories by incident history, not alphabetically. The module that pages someone every month is the module where nullable-type errors are most likely to be real bugs rather than cosmetic annotations.

Step 3: Let Rector Do the Mechanical Work

A large share of PHPStan findings are mechanical: missing return type declarations, missing property types, PHPDoc annotations that can become native types. Fixing those by hand is a waste of senior engineering time. Rector automates most of it:

// rector.php
use Rector\Config\RectorConfig;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictNativeCallRector;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPreparedSets(typeDeclarations: true)
    ->withTypeCoverageLevel(8);

Run Rector's type declaration set against the directory you are about to clean up, review the diff, then run PHPStan again. In client projects this routinely resolves 60 to 80 percent of the baseline entries in a module before a human touches anything. The remaining errors are the interesting ones: genuine logic questions about whether a value can actually be null, and those deserve human attention.

The dry-run workflow matters for trust. Always run vendor/bin/rector --dry-run first, review the proposed diff in the PR like any other change, and keep Rector commits separate from manual fixes so reviewers can skim the automated part and concentrate on the judgment calls.

The Symfony-Specific Configuration That Cuts the Noise

Vanilla PHPStan does not understand Symfony's container, Doctrine's repositories, or form types, and that gap generates a wall of false positives that makes teams give up. The extension packages close it. This is the starting configuration we ship on Symfony 7 projects:

# phpstan.neon.dist
includes:
    - phpstan-baseline.neon

parameters:
    level: 8
    paths:
        - src
    symfony:
        containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
    doctrine:
        objectManagerLoader: tests/object-manager.php

With phpstan/phpstan-symfony and phpstan/phpstan-doctrine installed via the extension installer, PHPStan resolves container services, understands that find() returns your entity or null, and validates DQL strings. The Doctrine extension is the one that pays for itself fastest: it catches type mismatches between entity properties and column definitions, and it flags repository methods whose PHPDoc claims to return Invoice[] while the query can return null. Those two categories account for a disproportionate share of production bugs we find during code audits.

Two custom additions are worth the effort on most projects: a rule that requires @return annotations on custom repository methods (Doctrine's magic methods make untyped repositories a nullability minefield), and treatPhpDocTypesAsCertain: false if your PHPDoc history is unreliable, which is true of nearly every codebase older than five years.

Keeping PRs Unblocked: Process Rules That Make It Stick

Tooling alone does not get you to level 8. The teams that arrive there follow a few process rules:

  • CI checks changed files strictly, the baseline covers the rest. A developer should never be forced to fix a stranger's ten-year-old type error to ship a one-line change.
  • The baseline burn-down is visible. Post the count weekly in the team channel. Falling numbers create momentum; invisible numbers create apathy.
  • Boy-scout fixes are welcome but bounded. Touching a file means fixing its baseline entries only if it stays within the PR's reviewable scope. Otherwise file it for the directory pass.
  • Level bumps are events. When the baseline for the current level hits zero, raise the level, regenerate the baseline once, and start the next burn-down. Each bump is a legitimate thing to celebrate.

On a legacy codebase this pairs naturally with a broader modernization effort. Static analysis is most valuable exactly when you are changing old code, because it tells you what the code actually does rather than what its comments claim. If you are staring at a Symfony 2-era system, our step-by-step legacy PHP refactoring guide shows where PHPStan fits into the larger migration sequence, and our legacy code optimization service covers doing it with outside help.

What to Expect: A Realistic Timeline

For a 100K-line Symfony application with a team of five shipping features full time, the pattern we see is consistent: week one sets up PHPStan with extensions, the level 8 baseline, and CI enforcement. Months one through three burn down the baseline directory by directory, with Rector clearing the mechanical majority. Somewhere in month three or four the baseline hits zero and the team is at a clean level 8, having never frozen the roadmap.

The payoff shows up before the migration even finishes. Teams typically report the first prevented production incident within weeks, usually a nullable return that would previously have become a 500 error for a customer.

Get an Outside Assessment First

If your baseline generation returns a five-digit error count and you are unsure whether the codebase is worth the investment, that question deserves a structured answer before you commit months of background work. A focused code quality audit gives you the error taxonomy, the modules where type errors correlate with incidents, and a prioritized migration plan.

Write to hello@wolf-tech.io or find more on wolf-tech.io, and we will tell you honestly whether level 8 is a three-month background task or a symptom of a deeper modernization need.