上周线上订单支付成功但库存没扣减。DBA 查到事务卡在中间状态——一个被遗忘的 DB::transaction() 里,catch 块只写了 Log::error($e),却漏掉了 throw $e 或 DB::rollBack()。PHPStan 默认规则根本不管这个。
线上翻车:事务回滚被谁吃了
出事的那笔订单金额不大,但影响很恶劣。用户那边付款成功、银行扣款通知都收到了,我们这边库存硬是没减。DBA 凌晨两点爬起来看锁状态,发现事务卡在中间态——catch 块里只有一行日志,既没 throw $e 断开连接,也没 DB::rollBack() 释放资源。
其实这种问题在 Laravel 项目里太常见了。try-catch 漏写、DB::transaction() 的 catch 里只记日志不回滚、finally 块直接空着——随便哪个都能让数据对不上。人工 Code Review 能挡得住表面上的漏洞,但代码一嵌套到 17 层 if 分支里,谁还记得外面有个 DB::beginTransaction() 没合上?
PHPStan 正好能堵这个窟窿——作为 Laravel 社区里用得最广的静态分析工具,它能让你自己写规则去翻 AST 节点。我们拿它干的活很简单:把那些常年没人管的异常路径和没提交也没回滚的事务,一个一个揪出来。

从 AST 下手:三个检查器盯死三类坑
别被“自定义规则”四个字吓住。就是写个 PHP 类,告诉 PHPStan 你想看什么节点(getNodeType()),看到后怎么判断(processNode())。我们写了个 ,盯着 PhpParser\Node\Stmt\TryStmt——只要 catch 和 finally 都为空,直接报错。
光这一个不够。又补了两个检查器:
:扫描所有调用了DB::transaction()、Model::save()、DB::statement()的方法体。如果方法没有try包裹,标红。:当DB::beginTransaction()出现后,在同一作用域内找不到commit()或rollBack()(含finally分支),直接报悬空事务。
注册规则不用 里的 includes——它在 Laravel 11 + Composer 2.7 下失效过三次。直接改 :
services:
-
class: App\Rules\UncaughtTryRule
tags: [phpstan.rules.rule]
跑完第一轮,项目里冒出来 12 处「日志写了,回滚没了」的事务块。其中 3 个在测试辅助类里,连 PHPUnit 的 @before 都没救它们。

塞进 CI:别让开发者自己扛这事
规则写好只是第一步,关键是把它塞进自动化的流水线。每次 git push 都等于给代码底裤翻个面,而不是指望谁还记得手动跑一遍 PHPStan。
GitHub Actions 配置不复杂。在项目根目录塞个 .github/workflows/phpstan.yml,核心三步:拉代码、装依赖、跑分析。记得显式指定 --configuration=phpstan.neon,不然自定义规则不会被加载。另外别用 --no-dev——依赖装不全,规则注册直接失效。
有个容易踩的坑:存量代码。项目里可能有 200 个没写 catch 的 try 块,你不可能一夜之间全修完。这时候要用 --generate-baseline 生成基线文件。第一次跑带上这个参数,PHPStan 会把当前所有违规写进基线,后续分析自动忽略它们。新代码里再出现同样问题,才会报错。
我习惯再加一层轻量预检。在 PHPStan 深度分析之前,先跑 php -l 检查语法——500 个文件半秒扫完,能把 PHP Parse Error 这种低级问题提前拦截掉。然后才是 PHPStan 的深度扫描,形成「语法层→规则层」两道防线。
GitLab CI 配置思路一样,只不过换成对应镜像。踩过 php:8.2-cli 缺 ext-pdo 的坑,导致 DB::transaction() 相关分析报假阳性。最后换了 才消停。
初期基线刚生成那会儿,团队里其实挺不适应的——突然冒出几百条未处理异常,谁看了都头大。我的做法是:CI 放行、基线违规的 PR 先不拦着合并,但违规记录留在那,谁提交谁心里有数。等大家慢慢接受了这套规则,再一步步收紧。千万别上来就一刀切,不然同事天天找你吵架,代码还没改完,人际关系先崩了。

进阶:让 PHPStan 学会读 Laravel 的心
静态分析真要落地,光靠「有没有 try」远远不够。得知道它配没配对的 catch,catch 里有没有 DB::rollBack(),这个 rollback 是不是只在特定异常分支里才跑——比如只处理了 \Exception,却漏了 Throwable。这种“半截子回滚”,比不写还危险。
我们利用 MethodCall 分析每个 catch 块里的调用链。用 扩展拿到 App\Exceptions\Handler::render() 的完整签名,反向扫描所有被 throw 触发却未被 render() 显式处理的异常类。比如你写了 throw new QueryException($e),但 Handler 里只写了 if ($exception instanceof QueryException)——这条漏网之鱼,会被规则直接标红。
真正卡住我们的从来不是语法,而是框架语义。DB::transaction() 的闭包参数里抛出异常,Laravel 会自动 rollback;但如果你手动 DB::beginTransaction(),就得自己兜底。我们写的规则会识别这两种模式,只对后者报错。这不是加限制,是让 PHPStan 学会读 Laravel 的心。
说到底,静态分析不是你用来当裁判的棍子,而是给代码库配的听诊器。每天 PR 合入前听一听,那些被遗忘的 catch 和没回滚的事务,自己就跳出来了。





评论