$ cat phpstan-23-leap-in-performance-generics-improvements-detecting-unused-variables-and-more.md
PHPStan 2.3: Leap in Performance, Generics Improvements, Detecting Unused Variables, and More!
PHPStan 2.3.0 release notes covering big speed gains from the new Turbo extension, faster result cache revalidation after changes, and generics changes that stop generalizing scalar types like 1 to int. The article explains what each change means for everyday analysis runs.
PHPStan06 Oct 2026 · 8 min read · summary
Read on phpstan.org ↗It’s been over four months and 16 patch releases since the last minor PHPStan release. Which means it’s about time for the next one! PHPStan 2.3.0 is here.
Performance improvements #
Since the beginning of this year we’ve been hard at work improving PHPStan’s performance and we’ve been very successful at it. Nothing shows it better than the following chart. On my computer (a 5-year-old MacBook Pro with an M1 Pro CPU), in December 2025 it used to take 60 seconds to analyse WordPress source code. And today it only takes 8 seconds:
{
"x": "Version",
"y": {"suffix": " s", "decimals": 1},
"tooltip": {"title": "PHPStan {Version}", "subtitle": "{Released}", "description": "{Note}"},
"series": [
{"id": "off", "column": "Turbo off (s)", "label": "Turbo off", "color": "text-blue-600", "labels": ["first", "last"]},
{"id": "on", "column": "Turbo on (s)", "label": "Turbo on", "color": "text-amber-500"}
],
"comparisons": [{"from": "off", "to": "on", "text": "Turbo is {percent}% faster"}],
"description": "Time to analyse WordPress core across PHPStan releases, with and without Turbo."
}
Version
Released
Turbo off (s)
Turbo on (s)
Note
2.1.33
December 5th 2025
60.51
Last release before this year’s performance improvements
2.1.34
January 19th 2026
46.51
Caching reflection objects and PHPDocs
2.1.38
January 30th 2026
44.55
Minor performance improvements
2.1.49
April 16th 2026
34.49
More performance improvements
2.2.6
July 26th 2026
32.02
24.68
First release with Turbo
2.2.10
August 30th 2026
29.94
22.20
Forking workers instead of spawning
2.2.13
September 3rd 2026
23.06
14.12
Always running with OPcache, stripping runtime checks
2.3.0
October 1st 2026
19.47
7.93
A lot more classes shadowed by Turbo
The first data point is PHPStan 2.1.33 which is the last release before all the contemporary performance work took place. Since then we have been chopping away at work that PHPStan doesn’t have to do at all, doesn’t have to do repeatedly, or that can be done faster. You can see it in the blue line getting steadily lower.
But PHPStan 2.2.6 at the end of July brought something new into the mix: PHPStan Turbo, an optional extension that makes parts of PHPStan faster by reimplementing them in C++. It also enables other optimization techniques: forking child processes instead of spawning them, which normally isn’t possible when running from a PHAR file, and registering an OPcache optimizer pass that strips runtime type checks from PHPStan’s calls to itself. It deserves a separate article which I will publish later this week.
Optional means PHPStan still runs fine without the Turbo extension. The twist is that the majority of PHPStan users have it enabled automatically and thus are already running PHPStan at the highest speed, without having to manually manage the extension on their system. The extension’s .so/.dll files are bundled inside the phpstan/phpstan Composer package, PHPStan selects the correct one for the current PHP runtime, and restarts itself with the extension enabled through php -d command line options.
You can see the effect in the orange line: the initial release made PHPStan 23% faster against the blue baseline. But today Turbo makes PHPStan almost 60% faster.
To summarize: PHPStan today is up to 7.5× faster than PHPStan as of ten months ago!
Result cache improvements #
But that’s not even the whole story. That’s only about the “cold” runs when the result cache does not exist or is too old. PHPStan doesn’t always run analysis on all files in a project. Since March 2020 [1] it remembers what errors it reported in which files during previous runs. It checks which files have changed since then, and only reanalyses those, plus files dependent on the changed files.
The day-to-day experience of people running PHPStan isn’t waiting for the full run to finish every time. It depends on how many files were changed since the last run. The following chart shows runs with a warm result cache on Drupal, a much larger project (a full run takes 22 seconds, compared to 8 seconds for WordPress):
{
"x": "Files",
"xDetail": "Detail",
"y": {"suffix": " s", "decimals": 1},
"tooltip": {"title": "{Files} files reanalysed", "subtitle": "{Share} of 11,194 files", "description": "{Note}"},
"series": [
{"column": "Time (s)", "label": "Time", "color": "text-amber-500", "labels": ["first", "last"]}
],
"description": "Time to analyse Drupal core with a warm result cache, depending on how many files have to be reanalysed.",
"height": {"ratio": 0.42, "min": 280, "max": 300}
}
Files
Detail
Share
Time (s)
Note
0
no change
0%
1.58
Nothing changed since the last run
10
0.1%
0.1%
2.84
1,124
10%
10.0%
6.21
2,771
25%
24.8%
9.89
5,574
50%
49.8%
16.10
7,204
64%
64.4%
19.03
8,956
80%
80.0%
21.14
11,194
cache cleared
100%
22.08
Result cache cleared
Re-running PHPStan without changing anything takes just 1.6 seconds to print the results. When PHPStan decides 10 files need to be reanalysed, it takes 2.8 seconds. When 10% of the project is reanalysed, it takes 6.2 seconds.
The recent result cache improvements are about making sure the whole result cache is invalidated and thrown away only when PHPStan is updated, or when the project’s PHPStan configuration changes. Previously, the project was fully reanalysed when any Composer dependency was updated, or when the Symfony DI container changed in any way. We’ve started tracking the dependencies between analysed files and installed packages so only the precise minimum is reanalysed.
We’re also introducing new extension interfaces and APIs that custom extensions can take advantage of to describe which files need to be invalidated and when, instead of throwing the entire result cache away when any minor detail changes.
The result cache now also behaves properly in monorepo setups that take advantage of "type": "path" repositories in composer.json, or scanDirectories & scanFiles of often-changed files.
Result cache is made to be saved and restored in CI for vastly faster pipelines. Make sure to configure that!
Generics: no more scalar generalization,
multi-pass inference for new Foo() from usages #
PHPStan has had generics support for almost seven years. We had to make a bunch of tough choices during development, and a few times we had to guess “how this should work” purely on gut feeling. Spoiler alert: some of those choices were wrong!
For instance, take the following function that returns the same type it accepts in its parameter:
/**
* @template T
* @param T $a
* @return T
*/
function doFoo($a)
{
// ...
}
What return type should be inferred when calling doFoo(1)? Is it int? Is it 1? There’s no correct answer; each comes with different trade-offs.
We opted for generalizing passed scalars so 1 becomes int. In hindsight, it was the wrong choice, and users reported many, many issues over the years because they wanted to preserve the precise scalar type.
But we couldn’t fix it without breaking one important use case.
Here’s another question for you: What’s the type of $c = new Collection([1, 2, 3])? If it’s Collection<1|2|3>, you can’t call $c->add(4) because it wouldn’t be accepted. If it’s Collection<int>, you can’t pass it to a parameter expecting Collection<positive-int>, even though that should be valid.
If we take an empty collection new Collection([]), the resolved type of @template T is the never type (the empty bottom type). Strictly speaking, you shouldn’t be able to add anything to it, because it’s supposed to be empty! Correct, but not really useful in the real world.
Yeah, tough choices.
Fortunately, Arnaud Le Blanc, who helped me tremendously with over 50 pull requests for the initial generics implementation back in 2019, came to the rescue again on March 8th 2022. He described a solution to this problem in an issue titled “Bidirectional type narrowing” so that’s what we’ve been calling the idea ever since.
It took me over four years to gather up courage and funding to implement it correctly and efficiently.
The solution is to analyse the code around new Collection(...) multiple times, observe how it’s used, where it’s passed to, and clamp the final inferred @template T type based on that information.
You can play with this interactive widget to understand what PHPStan does with different code snippets:
[{"id": "first-usage", "label": "Passed to a function", "toggles": [10, 11], "defaults": [true, false], "probes": [{"line": 9, "variable": "ints", "showName": false}], "results": [{"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": [{"line": 11, "message": "Parameter #1 $strings of function takeStrings expects Collection, Collection given.", "identifier": "argument.type"}]}]}, {"id": "precise-scalars", "label": "Precise scalars", "toggles": [7, 8, 9], "defaults": [true, false, false], "probes": [{"line": 6, "variable": "ids", "showName": false}], "results": [{"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": [{"line": 8, "message": "Parameter #1 $item of method Collection::add() expects int, -1 given.", "identifier": "argument.type"}]}, {"types": ["Collection"], "errors": [{"line": 8, "message": "Parameter #1 $item of method Collection::add() expects int, -1 given.", "identifier": "argument.type"}]}]}, {"id": "method-calls", "label": "Method calls", "toggles": [7, 8, 9], "defaults": [true, true, false], "probes": [{"line": 6, "variable": "items", "showName": false}], "results": [{"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types": ["Collection"], "errors": []}, {"types":
source
This is a summary of an article first published by PHPStan. The full piece is on their site.