Static analysis in a grown PHP project has a well-known first moment: you run PHPStan, get eight thousand errors and switch it off again. The impression that leaves is wrong, and it costs you an effective tool.
Those eight thousand messages are not a verdict on the code. They are the result of fifteen years of code being checked at once against rules that did not exist when it was written. There is a mechanism for exactly that, and it turns the number into zero without changing a single line.
This article describes how to introduce PHPStan into legacy code, what actually gets found at which level, and where the point lies beyond which further levels deliver nothing.
Why it works differently in legacy code
In a new project, static analysis is a quality tool: it prevents bugs before they appear. In legacy code it is something else first, namely a measuring device.
It answers three questions that are useful before any modernization, without anybody reading the code.
Where is the code unsafe rather than merely ugly? A call on a value that can be null is a possible outage. A long method is just a long method.
Which places are so dynamic that nobody can analyse them? Assembled class names, magic methods, arrays used as objects. Those are the places where a tool like Rector also has to be careful, and the list is a good approximation for "this gets expensive here".
How does it spread across the project? Two thousand messages in one module and thirty in the rest say more about the order of work than any estimate.
None of those three requires fixing a single message. That is what makes the start easy.
The baseline: the command that turns eight thousand into zero
PHPStan can freeze the state it finds. Everything present today counts as known; from tomorrow only new messages count.
composer require --dev phpstan/phpstan
# Measure first, without freezing anything
vendor/bin/phpstan analyse src --level=0
# Then freeze: produces phpstan-baseline.neon
vendor/bin/phpstan analyse --generate-baseline# phpstan.neon
includes:
- phpstan-baseline.neon
parameters:
level: 0
paths:
- src
# The existing state is frozen, new errors break the build.
# The file shrinks over time, it never grows.From here the tool is usable, on the same day. The backlog sits in a file, every new line of code gets checked, and nobody had to work through eight thousand messages first.
Two rules keep that healthy, and both are organizational rather than technical.
The baseline never grows. If somebody regenerates it because a build is red, the whole mechanism is worthless. That is checkable: the line count of the file may go down in a change set, never up.
Whoever touches a file clears its entries. Not the whole project, the file you are working in anyway. That way the baseline shrinks where the work happens, and that is where it matters.
Levels 0 to 2: the errors that are actual errors
The lower levels are the ones almost always worth it even in legacy code, because they produce hardly any false reports. What they find is, with few exceptions, a bug.
Level 0 finds calls to classes and methods that do not exist. That sounds harmless and is the most common finding with real damage behind it: a method called in a branch nobody has reached for years, missed when something was renamed four years ago.
Level 1 adds unknown variables. That finds typos in rarely used branches, and variables set inside a loop and used afterwards even though the loop can be empty.
Level 2 checks calls against types where they are known. This is where the first real candidates for outages appear: a method called on a value that can also be false, because the function producing it returns false on failure.
// Level 2 reports this, and rightly so:
// simplexml_load_string() returns false on failure.
$xml = simplexml_load_string($response);
foreach ($xml->item as $i) { // call on false when the XML is broken
...
}
// Fixed without changing the logic:
$xml = simplexml_load_string($response);
if ($xml === false) {
throw new IntegrationError('response is not valid XML');
}In most projects those three levels are clean within a few days if you work the baseline down for them. From level 3 the character of the work changes.
Levels 3 to 5: the types that are not there
From here it is no longer about bugs but about missing declarations. PHPStan reports that it cannot know something, and fixing it means writing it down.
The largest block is arrays. In old PHP an array is everything: list, dictionary, record, configuration. From level 4, PHPStan wants to be told what is in it.
/**
* The annotation is not decoration: it turns an "array" into a promise
* that gets checked at every call site.
*
* @param list<array{id: int, amount: int, currency: string}> $lines
* @return array<string, int> total per currency, in cents
*/
public function total(array $lines): array
{
...
}The return on that work is larger than it looks, and it does not show up in bugs found. It shows up when somebody reads the code six months later: the question "what is actually in this array" is answered without hunting for the caller.
The order worth following here is also not "everything". It is the places where data leaves the module: public methods, returns to callers, interfaces. Inside a method the annotation buys little.
Where it stops being worth it
The levels go up to 10, and there is no reason to arrive there. For legacy code the sensible end point in most projects is level 5 or 6.
The reason is the ratio of effort to findings. From level 6 the work consists mostly of writing declarations for code that is not going to change and explaining things PHPStan cannot know on principle. The benefit is small, the effort is not.
One exception is worth it and often gets overlooked: new code can be held to a higher level than old code. That can be expressed in the same configuration and is the best compromise I know.
parameters:
level: 5
paths:
- src
# Everything created since the modernization gets checked harder.
# The legacy code stays at 5 without holding the new work back.
strictRules:
allRules: false
# phpstan-new.neon: its own run for the new modules
includes:
- phpstan.neon
parameters:
level: 8
paths:
- src/Billing
- src/ShippingWhat you ignore, and why that gets written down
Every legacy project has messages that are correct and that you will not fix. That is fine, as long as it is a decision rather than a habit.
The difference between the two is a comment in the right place.
parameters:
ignoreErrors:
# The legacy container returns mixed. A rebuild onto typed
# resolution is planned (see docs/decisions/2026-10-30); until
# then this would be 400 messages with no insight.
- message: '#Cannot call method .* on mixed#'
path: src/Legacy/Container.php
# Deliberately dynamic: plugin names live in the database. This is
# the core of the extension mechanism and stays that way.
- message: '#Class .* not found#'
path: src/Plugin/Loader.phpBoth entries are legitimate, and both say why. An ignoreErrors with no reason is indistinguishable from convenience in two years, and then nobody dares remove it. That is the same pattern as with any suppressed warning.
What does not get ignored: anything in files currently being worked on. There a message is cheap to fix, and the occasion is already there.
The counterpart that does not find but changes is covered in Rector in a legacy project.
Into the pipeline without stopping it
The last step is what turns a tool into a habit. It is small, and it has a condition.
# The run is fast enough for every branch once the cache is warm.
vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=githubThe condition: the run must not take longer than a developer is willing to wait. On a large project that means keeping the result cache between runs. Without it the analysis takes minutes, and then it gets bypassed.
And the second condition, which matters more than the first: on the first red build it has to be obvious what to do. A message pointing at a line somebody has just written is helpful. One pointing at a file untouched since 2015 is a fault in the baseline, and it costs trust in the whole tool.
What a static analyser cannot see in principle is covered only by a test: Testing legacy code when there are no tests.
What PHPStan does not find
So that expectations are right, the boundary at the end. Static analysis checks whether the code is internally consistent, not whether it does the right thing.
Wrong logic stays invisible. A discount calculation using the wrong percentage is type-safe. That is what tests are for, and they are the other half of the net.
Anything that only appears at runtime. A class name from the database, configuration from a file, an array from an API. That is where many bugs sit in legacy systems, and that is exactly where the analysis stays silent.
The state of the data. That a column in the database contains NULL although the code does not expect it is invisible to any analyser. Only a run over real data finds that.
So the order in a modernization is usually: first a net of tests around the places being changed, then static analysis for everything else. The analysis is cheaper and broader, the tests are more precise and more expensive, and neither replaces the other.
How a PHP upgrade works is on its own page; an established analysis is one of the reasons the effort can be quantified in advance there.
This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.

