PHPStan 2 Tips and Tricks: Leveling Up Legacy PHP Codebases
Practical PHPStan 2 tips for PHP developers. Learn baselines, generics, custom rules, and strategies to reach level max on real-world projects.
If you have been writing PHP for more than a few years, you know the quiet dread of opening a 10-year-old controller to fix a “simple” bug. You also know what PHPStan feels like the first time you point it at that controller at level 9. It is less static analysis and more a full psychological assessment of your codebase. PHPStan 2.0 shipped in late 2024, and the 2.x line has continued to iterate, making it arguably the single most valuable tool in a modern PHP developer’s belt. Here is how to actually use it well on real projects, not just greenfield ones.
Why PHPStan 2 Matters
PHPStan 2.0 bumped the minimum PHP version to 7.4 for analyzed code and 8.1 for running the analyzer itself. It also overhauled the rule set: what used to be “level 9” strictness is now closer to the default expectation, and some rules that were off-by-default became on-by-default. The upgrade guide on phpstan.org is honest about the fact that existing projects will see new errors immediately — and that is the point. The 2.x line is built for teams that are willing to take static analysis seriously as a long-term investment.
The most useful changes in practice are improved generics inference, stricter handling of mixed, better narrowing inside instanceof chains, and first-class understanding of readonly classes and property hooks from PHP 8.4. If you are on Laravel or Symfony, the ecosystem-specific extensions (Larastan and phpstan/phpstan-symfony) have all been updated in lockstep.
Start With a Baseline, Not a Bonfire
The single biggest mistake teams make is running phpstan analyse --level=max on a legacy codebase, seeing 4,000 errors, and giving up. Do not do this. PHPStan has a baseline feature for exactly this situation.
vendor/bin/phpstan analyse --level=8 --generate-baseline
This produces a phpstan-baseline.neon file that lists every current error. Include it in your main config:
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
- tests
reportUnmatchedIgnoredErrors: false
Now your CI will pass on the current state, but any new error introduced in a PR will fail the build. This turns PHPStan from a one-time purge into a ratchet. Every time someone touches an old file, they can fix a few baseline entries and regenerate. The baseline shrinks over time without blocking feature work.
Pro tip: commit the baseline file, but review diffs to it in code review. A shrinking baseline is a good sign. A growing baseline means someone added an @phpstan-ignore without telling anyone.
Leverage Generics and Array Shapes
PHPStan’s generics are the feature that sold me on going past level 5. PHP itself has no generics syntax, but PHPStan reads PHPDoc annotations and infers types across collection-style classes as if they did.
/**
* @template T of object
*/
class Repository
{
/** @var class-string<T> */
private string $model;
/**
* @param class-string<T> $model
*/
public function __construct(string $model)
{
$this->model = $model;
}
/**
* @return T|null
*/
public function find(int $id): ?object
{
// ...
}
}
Now (new Repository(User::class))->find(1) is understood to return User|null, and calling a User method on it type-checks correctly. No runtime change, just better IDE completion and real compile-time safety.
Array shapes are equally valuable. If you have that one function that returns ['user' => User, 'roles' => string[], 'expires_at' => int], annotate it:
/**
* @return array{user: User, roles: list<string>, expires_at: int}
*/
public function buildSession(User $user): array
PHPStan will now complain if you try to read $session['expries_at'] (typo) or pass an int where a User is expected. This is the cheapest possible refactor-safety you can add to a legacy codebase.
Use Extensions That Already Know Your Framework
Static analysis is only as good as its understanding of the dynamic parts of your framework. Plain PHPStan has no idea what User::query()->where('active', true)->first() returns in Laravel, because that is all runtime magic.
Larastan solves this. Install it with composer require --dev larastan/larastan, add ./vendor/larastan/larastan/extension.neon to your includes, and suddenly PHPStan understands Eloquent, facades, service container bindings, and route closures. The same story applies to Symfony with phpstan/phpstan-symfony, and to Doctrine with phpstan/phpstan-doctrine. These extensions are not optional on real projects; running PHPStan without them means ignoring the parts of your app most likely to have bugs.
Write a Custom Rule for Your Own Rough Edges
Every codebase has patterns the team has agreed to avoid. “Don’t call DB::raw() directly.” “Controllers must not inject the Request object into service methods.” “No env() calls outside config files.” You can encode these as custom PHPStan rules, and the payoff is enormous because the rule runs on every PR forever.
A minimal custom rule looks like this:
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/** @implements Rule<FuncCall> */
class NoEnvOutsideConfigRule implements Rule
{
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (! $node->name instanceof Node\Name) {
return [];
}
if ($node->name->toString() !== 'env') {
return [];
}
$file = $scope->getFile();
if (str_contains($file, '/config/')) {
return [];
}
return [
RuleErrorBuilder::message(
'Do not call env() outside config/. Use config() instead.'
)->identifier('app.envOutsideConfig')->build(),
];
}}
Register it in phpstan.neon under services and it will flag every violation going forward. This is team culture as code.
Pair It With Pest or PHPUnit, Don’t Replace Them
PHPStan is not a replacement for tests. It catches entire categories of bugs that tests cannot — type errors that only occur on rare branches, null dereferences in error paths, invalid array key access — but it also misses things tests catch easily, like “is the final number correct?” Run both in CI. A healthy PHP project in 2026 has PHPStan at level 8 or higher, a test suite that actually exercises the business logic, and PHP-CS-Fixer or Laravel Pint to keep style out of code review.
Resources
PHPStan is one of those tools that feels painful for a week and then feels essential forever. Adopt it with a baseline, crank the level up over time, and let it catch the bugs your future self would have had to debug at 2am.