Climbing to PHPStan Level 10 on a Twelve-Year-Old Codebase

PHPStan has eleven rule levels, 0 through 10, and level 10 arrived with PHPStan 2.0. Running level 10 on a greenfield project is unremarkable. Running it on a codebase that started before PHP had scalar type hints is a different exercise, and the interesting part is where the return on effort falls off.

Start with a Baseline

The instinct is to run the tool, see thousands of errors, and start fixing. That stalls every time, because the work is unbounded and nothing improves until it is finished.

The baseline file inverts this. It records every existing error as accepted, so the reported count starts at zero and any error you add is yours:

vendor/bin/phpstan analyse --generate-baseline

From that point the tool is useful in CI immediately, on day one, at whatever level you eventually want to reach. New code is held to the standard and old code is a debt you pay down deliberately. The baseline shrinking over time is a much better signal than a number that started enormous and is still enormous.

The trap is letting the baseline get regenerated whenever it becomes inconvenient. Regenerating hides new errors along with old ones. Treat the file as append-only in practice, and make regenerating it a decision somebody has to justify in review.

What Each Level Actually Buys

The levels are not evenly spaced in either difficulty or value. On old code the distribution looked roughly like this for me.

Levels 0 through 5 are close to free and worth doing in one sitting. Unknown classes, unknown methods, wrong argument counts, obviously wrong argument types. These are real bugs in dead code paths, and they were the fastest defect-per-hour of the whole exercise.

Level 6 is the wall. It requires type hints everywhere, which on a legacy codebase means annotating thousands of parameters, properties, and returns that were never written down. It is also where the majority of the value is, because everything above it depends on the types being declared. Budget accordingly, and do it package by package rather than all at once.

Level 7 handles union types correctly, and level 8 reports calling methods on something that might be null. Level 8 found the largest number of genuine, user-visible bugs for me. Old PHP code is full of functions that return an object or false, and calling a method on the false path is a fatal error nobody had hit yet only because the input never went that way in production.

Level 9 treats explicit mixed strictly. Level 10 extends that to implicit mixed, meaning values that are untyped because nobody wrote a type rather than because someone chose mixed deliberately.

Where I Stopped, and Why

Nine was the point where the ratio turned. The errors at level 9 and above are overwhelmingly about proving to the analyser that something is what you already know it is, and the fix is usually an assertion or a narrowing check that exists for the tool rather than for the program.

That work is not worthless. Code paths handling data from outside the process, request parameters, decoded JSON, database rows, third-party API responses, are exactly where implicit mixed represents a real unverified assumption, and level 10 is right to flag them. Those are worth fixing properly.

Internal code where the types are obvious from three lines of context is a different story. There the analyser is asking you to restate something the reader can already see, and the resulting assertions add noise to every function. I run level 9 across the codebase and level 10 on the packages that parse external input, which PHPStan supports directly:

parameters:
    level: 9
    paths:
        - lib
        - bin
    ignoreErrors:
        - identifier: missingType.iterableValue
    tmpDir: build/phpstan

The missingType.iterableValue exclusion is the one I would flag as a genuine judgment call. Annotating every array with its value type via array<string, Thing> is real documentation and real safety, and on a large old codebase it is also an enormous amount of typing for arrays that get built and consumed twenty lines apart. Excluding it globally is a compromise. Excluding it only where the array does not cross a module boundary would be better and PHPStan cannot express that distinction.

The Part That Surprised Me

The bugs found were not the payoff. The payoff was that adding types forced decisions that had been deferred for years. A function returning Thing|false|null was not a typing problem, it was three different error conventions that had accumulated in one place, and writing the type down made that impossible to ignore.

Most of my level 6 work turned out to be design cleanup wearing a static analysis costume. That is a better argument for doing it than the defect count is.