PHPStan落地:从装到CI的三层配置

一、先说清楚静态分析能帮你抓到什么

很多人第一次听说 PHPStan,以为是「比 PHP 更严格的语法检查」。不是。PHP 语法检查(php -l)只能发现括号没闭合这类事,一行有问题的代码照样能过。静态分析做的是另一件事:它不执行代码,而是在读代码的过程中把类型、调用关系、可能为空的变量、写错的类名和方法名推导一遍,然后告诉你「这一行在什么情况下会炸」。

对个人站长来说,这件事的价值很具体。我们的代码大多是一个人写的,没有 Code Review,没有测试覆盖,改一处登录逻辑,可能三个月后才在凌晨被用户报上来。静态分析是唯一能在「提交之前」就拦住这类问题的手段。

PHPStan 能查出的典型问题,基本落在四类:

  • 类型错误:函数声明返回 int,实际返回了 null 或 string;数组访问一个可能不存在的键。
  • 调用错误:调用了不存在的类、方法参数个数不对、方法被改名了但调用处没改。
  • 死代码与遗留:未使用的变量、永远走不到的分支、被注释掉的 return 后面的代码。
  • 可疑写法:== 与 === 混用、字符串与数字隐式比较、switch 缺 break。

它查不出逻辑本身的错误——比如「这个优惠券可以被重复使用」。那需要测试或者人脑子。把预期放在正确的位置,才不会第一次跑完看到两千条报错就直接放弃。

二、装到项目里,而不是装到系统里

PHPStan 是一个 PHP 包,最省心的方式是项目内安装。不要 apt install,也不要全局 composer require,否则不同项目对版本的要求会打架。

cd /var/www/example.com
composer require --dev phpstan/phpstan
./vendor/bin/phpstan --version

装完看版本号,记下来。PHPStan 的大版本之间规则集变化很大,团队(哪怕团队只有你一个人)的 CI 和生产机上要看同一个版本,否则本地绿、线上红,非常浪费时间。

接下来写配置文件 phpstan.neon,放在项目根目录:

parameters:
    level: 5
    paths:
        - app
        - cron
    excludePaths:
        - app/thirdparty/*
        - app/cache/*
    treatPhpDocTypesAsCertain: false
    checkMissingIterableValueType: false

几个参数值得解释一下。level 是 0 到 9(旧版本到 8),数字越大越严格。paths 只写自己的代码目录,第三方库、缓存目录必须排除,否则报错会淹没一切。treatPhpDocTypesAsCertain: false 在接手别人代码时很有用——很多老项目的 PHPDoc 是过期的,把它当真理会产生大量假报错。

三、级别怎么选:从 5 开始,不要从 9 开始

这是最多人踩的坑。看了官方文档说「level 9 最严格」,直接把项目设成 9,跑出来三千条报错,然后觉得这工具不实用,卸了。

级别的大致含义是这样的:

级别主要新增检查
0~1类/函数/变量是否存在,明显的基础错误
2未知方法、参数个数、返回类型缺失
3~4返回类型、属性类型(无 PHPDoc 时只做能确定的部分)
5参数类型检查(这一级开始能抓到真正的 bug)
6~7缺失的类型提示、联合类型
8~9严格空值检查、mixed 的处理,接近「完美类型」

我的建议是:老项目从 5 开始,新项目从 8 开始。理由很简单——level 5 已经能抓到「传错参数」「调用了不存在的方法」这类真正会导致 500 的问题,而误报少得可以一条条看完。level 8 及以上要求代码里所有地方都有明确类型,对没有类型注释的老代码几乎是重写。

第一次跑起来的正确姿势是加 --error-format=table 且先只看数量:

./vendor/bin/phpstan analyse --no-progress --error-format=table > /tmp/stan.txt
wc -l /tmp/stan.txt
sort -t: -k1,1 /tmp/stan.txt | uniq -c -w 40 | sort -rn | head -20

按文件聚合,先修报错最多的那几个文件。通常两三个文件占了全部报错的一半,修完数量立刻掉下来,心理压力小很多。

四、baseline:承认历史债务,但只承认一次

如果老项目第一次跑出八百条报错,不要花两周去修。正确做法是用 baseline 把当前的报错「拍个快照」冻结起来,只拦新增的问题:

./vendor/bin/phpstan analyse --generate-baseline baseline.neon
# phpstan.neon 里加一行
includes:
    - baseline.neon

baseline 生成的是一份被忽略报错的清单,格式是「文件 + 报错消息 + 出现次数」。它有三个必须知道的特性:

  • 按消息文本匹配,不按行号。所以你改了行号不影响,但一旦你修改了那条代码本身,baseline 里的条目就匹配不上了,报错会「重新出现」。这是好事,等于提醒你「你正在改的这段历史代码有问题」。
  • 会随代码变化而腐烂。如果某条被忽略的报错被修好了,PHPStan 不会报错,但你可以跑 --generate-baseline 重新生成来收缩它。
  • 不要提交一个巨大的 baseline 然后不管。可行的做法是每次修 bug 顺手清掉对应的条目,半年下来 baseline 会明显变瘦。

还有一条硬规矩:baseline 只能生成,不能手改。手改出来的条目格式一旦偏移,等于把一段代码的检查整体关掉,而且没人会发现。

五、把 PHPStan 放进 CI 与提交前钩子

静态分析最大的问题是「跑一次就忘」。只有放进自动流程,它才会持续产生价值。

最轻量的版本是 Git pre-commit 钩子,只检查这次改动的文件。PHPStan 本身不支持「只查改动的文件」,但可以用一小段脚本拼参数:

#!/bin/sh
# .git/hooks/pre-commit
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$' | grep -v '^app/thirdparty/')
[ -z "$files" ] && exit 0
./vendor/bin/phpstan analyse --no-progress --level=5 $files
status=$?
[ $status -ne 0 ] && echo "静态分析未通过,已阻止提交" >&2
exit $status

注意 --level 显式传值会覆盖配置文件里的级别,所以要么这里跟配置保持一致,要么去掉这个参数让它读 neon。我倾向于去掉,只维护一处。

如果有 CI(哪怕是自建的小 runner),加一步全量分析,并且要求退出码为 0 才能合并。全量分析比增量慢,但能抓到「改 A 文件导致 B 文件类型不匹配」这种增量检查看不到的问题——这恰恰是最有价值的一类。

六、常见误报与压制写法

PHPStan 会误报,尤其是面对动态特性强的老代码:call_user_func、反射、魔术方法、框架的容器注入。压制误报要克制,每一条压制都意味着一个盲区。

// 单行压制:忽略下一行
// @phpstan-ignore-next-line
$user = $container->get('user');

// 带说明的压制(推荐)
/** @phpstan-ignore-next-line 容器返回类型无法静态推断,运行期已校验 */
$handler = $registry->make($name);

在 neon 里也可以按消息正则全局忽略,但我建议谨慎:

parameters:
    ignoreErrors:
        - '#Call to an undefined method .*::__call\(\)#'
        - message: '#Variable \$this might not be defined#'
          path: app/legacy/*

带 path 的忽略比全局忽略安全得多。全局忽略一个消息模式,等于永久放弃全站对那类问题的检查。

三个高频误报的处理经验:一是框架魔术方法,用 phpstan/phpstan-strict-rules 之外的官方扩展(例如 Laravel 有 larastan,Symfony 有 phpstan-symfony)解决,比自己写压制好;二是动态属性,PHP 8.2 已经废弃,正好借这个机会在类里声明;三是 array_key_exists 之后的取键,PHPStan 其实能理解,报错往往是变量来自更早的 extract 或 $$var,那是代码本身该改。

七、让它真正起作用的三条流程约定

工具装好只是开始,能不能长期用下去取决于约定。我这边跑了大半年,有三条是必须的:

第一,报错零容忍只在增量上成立。全量 800 条可以看到,但新提交必须 0 新增。实现方式就是 baseline + CI 全量对比,一旦有人新增问题,CI 直接红。

第二,升级 PHPStan 大版本时单独一个提交。不要和业务改动混在一起。升级后报错数可能从 200 涨到 900,混在一起就分不清哪些是新代码引入的、哪些是规则变严了。单独提交,专门处理一轮。

第三,运行时间要盯住。PHPStan 有结果缓存(默认在 /tmp/phpstan),第二次跑会快很多。但如果项目大到一次跑三分钟以上,就需要拆分:把 paths 拆成「核心业务」「定时任务」「后台」三份配置,本地只跑自己改的那份,CI 跑全部。缓存目录也别放在会被定时清理的位置,否则每次都冷启动。

最后一句实话:静态分析不会让你的代码变好,它只是让「你本来会犯的错」提前暴露。一个能跑通 CI、零告警的 PHPStan 配置,价值不在于它抓到了多少 bug,而在于它让你在下班前提交代码时,不必靠祈祷。

Last modification:October 31st, 2026 at 01:24 pm

Leave a Comment