PHPStan 2.3 升级笔记:WordPress 分析从 60.5s 降到 7.9s,以及新规则带来的一堆报错
PHPStan 2.3.0 于 2026-10-01 发布,距上个 minor 四个多月,中间插了 16 个 patch。官方博客在 2026-10-06 发了说明:。项目地址:。
这次升级有两个值得动手的点:性能(Turbo + 结果缓存失效收窄)和一批默认开启的新规则。前者基本无痛,后者会让老项目一次性冒出大量错误。
性能:WordPress 源码,M1 Pro 上的数据
作者用五年前的 M1 Pro MacBook Pro 分析 WordPress 源码,2.1.33 是性能工作开始前的版本:
| 版本 | 默认 | Turbo |
|---|---|---|
| 2.1.33(2025-12-05) | 60.51s | — |
| 2.1.34(2026-01-19) | 46.51s | — |
| 2.1.38 | 44.55s | — |
| 2.1.49 | 34.49s | — |
| 2.2.6(07-26) | 32.02s | 24.68s |
| 2.2.10 | 29.94s | 22.20s |
| 2.2.13 | 23.06s | 14.12s |
| 2.3.0 | 19.47s | 7.93s |
几个阶梯分别对应:2.1.34 缓存了 reflection 对象与 PHPDoc;2.2.10 把 fork 子进程替代 spawn;2.2.13 始终开 OPcache 并去掉运行时检查;2.3.0 有更多类被 Turbo shadow。相对 2.1.33 最多快 7.5 倍。
Turbo 不用手动装
Turbo 是 2.2.6 引入的可选扩展,用 C++ 重写了部分逻辑。它还让 fork 子进程成为可能(PHAR 运行时不方便 fork),并注册了一个 OPcache optimizer pass,剥离 PHPStan 对自身调用的运行时类型检查。
「可选」的意思是不装也能跑,但大多数用户其实已经自动启用:.so/.dll 打包在 phpstan/phpstan 这个 Composer 包里,运行时自动选择,并用自己的 php -d 参数重启进程加载扩展。不需要改 CI 镜像,也不需要 pecl install。
单项收益大约是再快 60%。
结果缓存:失效范围收窄
结果缓存从 2020-03 就有:记录哪些文件报了什么错误,只重分析变更文件及其依赖。Drupal 全量分析 22 秒,暖缓存下的表现:
| 变更比例 | 时间 |
|---|---|
| 0 变更 | 1.58s |
| 10 个文件 | 2.84s |
| 10%(1124 文件) | 6.21s |
| 25% | 9.89s |
| 50% | 16.10s |
| 80% | 21.14s |
| 清缓存 | 22.08s |
2.3 的改进在失效判断:以前任何 Composer 依赖更新或 Symfony DI 容器变化都会导致全量失效;现在跟踪被分析文件与已安装包之间的依赖,只重分析最小集合。新增了扩展接口/API,让自定义扩展能描述自己的失效范围。monorepo 里 "type": "path" 的 repositories、scanDirectories/scanFiles 也能正常命中。
CI 里可以保存并恢复这个缓存,细节见 。
泛型不再把标量泛化
旧行为:doFoo(1) 推断 T 时把标量泛化成 int,模板参数丢失精度。用户抱怨了很久,但直接改会撞上集合的经典问题:
new Collection([1,2,3])若推断成Collection,$c->add(4)不合法;- 若推断成
Collection,又传不进Collection参数; - 空集合
new Collection([])会推断成never(bottom type)。
方案是 Arnaud Le Blanc 在 2022-03 提出的 bidirectional type narrowing:对 new Collection(...) 周围的代码做多遍分析,观察它被传到哪、怎么用,再夹逼出最终的 @template T。第一版原型慢 30%;优化后只重分析直接受影响的代码行,开销 1%–3%,被同版本性能提升抵消。
效果示例:
$ids = new Collection([1, 2, 3]);
$ids->add(4);
$ids->add(-1); // 第 8 行报错
推断为 Collection>,-1 不匹配。另一个例子:
$ints = new Collection([]);
takeInts($ints);
takeStrings($ints); // 第 11 行:Collection given
空集合被传入位置夹逼成 Collection。这块需要开 bleeding edge 才会生效。
未使用变量检测:五类主标识符
新能力默认开启,主场景:
variable.unused:变量赋值但从未读取assign.unused:赋的值在函数体结束前未被读取(变量可能在赋值前被读过)assign.overwritten:赋的值在被读取前被另一次赋值覆盖assign.redundant:变量被赋成它已有的值assign.unusedFlow:赋值看似被使用,但值最终流不到任何被使用的地方(受 Psalm 数据流图启发)
扩展范围还包括:数组 key 从未读取或立即被覆盖(array.unusedOffset、array.offsetOverwritten)、++/-- 的结果未被读取(preInc.unused、postInc.overwritten 等)、foreach 的 key/value(foreach.unusedValue、foreach.unusedKey 等)、catch 变量、闭包 use 变量(closure.unusedUse)、函数与私有方法的参数(function.unusedParameter)。
老项目第一次跑会一次冒出几百条。正确做法不是关掉规则,而是生成 baseline 或按 identifier 忽略:
composer update phpstan/phpstan
vendor/bin/phpstan analyse --generate-baseline
phpstan.neon 大致这样:
includes:
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
tmpDir: .phpstan-cache
ignoreErrors:
- identifier: variable.unused
- identifier: assign.unusedFlow
baseline 机制见 。按 identifier 忽略比按消息文本忽略稳,消息措辞变了也不会漏。
CI 缓存按 tmpDir 配:
- uses: actions/cache@v4
with:
path: .phpstan-cache
key: phpstan-${{ hashFiles('composer.lock', 'phpstan.neon') }}
restore-keys: phpstan-
key 带上 composer.lock 和配置文件的 hash,restore-keys 做前缀回落,这样依赖更新后仍能复用一部分缓存。
什么时候值得升
代码库越大、CI 越慢,收益越明显:20 秒的全量分析在暖缓存下能压到 1.5 秒,Turbo 再把冷启动砍掉一截。小项目本来只跑几秒,升级动力主要是泛型精度和新规则。
代价集中在两处:一是未使用变量检测带来的报错量,先用 baseline 吃掉存量,新代码再逐条修;二是泛型收窄需要 bleeding edge,行为变化可能让原本通过的分析出现新错误,升级前先在本地跑一遍完整分析再动 CI。