代码 PHPStan 2.3 升级笔记:WordPress 分析从 60.5s 降到 7.9s,以及新规则带来的一堆报错

2026-10-20 21:32:45

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.3844.55s—
2.1.4934.49s—
2.2.6(07-26)32.02s24.68s
2.2.1029.94s22.20s
2.2.1323.06s14.12s
2.3.019.47s7.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。

复制全文 生成海报 PHPStan PHP 静态分析 工具链

推荐文章

程序员茄子在线接单