上周线上订单支付成功但库存没扣减。DBA 查到事务卡在中间状态——一个被遗忘的 DB::transaction() 里,catch 块只写了 Log::error($e),却漏掉了 throw $eDB::rollBack()。PHPStan 默认规则根本不管这个。

线上翻车:事务回滚被谁吃了

出事的那笔订单金额不大,但影响很恶劣。用户那边付款成功、银行扣款通知都收到了,我们这边库存硬是没减。DBA 凌晨两点爬起来看锁状态,发现事务卡在中间态——catch 块里只有一行日志,既没 throw $e 断开连接,也没 DB::rollBack() 释放资源。

其实这种问题在 Laravel 项目里太常见了。try-catch 漏写、DB::transaction()catch 里只记日志不回滚、finally 块直接空着——随便哪个都能让数据对不上。人工 Code Review 能挡得住表面上的漏洞,但代码一嵌套到 17 层 if 分支里,谁还记得外面有个 DB::beginTransaction() 没合上?

PHPStan 正好能堵这个窟窿——作为 Laravel 社区里用得最广的静态分析工具,它能让你自己写规则去翻 AST 节点。我们拿它干的活很简单:把那些常年没人管的异常路径和没提交也没回滚的事务,一个一个揪出来。

PHPStan custom rule detecting missing try-catch in Laravel

从 AST 下手:三个检查器盯死三类坑

别被“自定义规则”四个字吓住。就是写个 PHP 类,告诉 PHPStan 你想看什么节点(getNodeType()),看到后怎么判断(processNode())。我们写了个 ,盯着 PhpParser\Node\Stmt\TryStmt——只要 catchfinally 都为空,直接报错。

光这一个不够。又补了两个检查器:

  • :扫描所有调用了 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 都没救它们。

PHPStan rule class implementation in PHP code editor

塞进 CI:别让开发者自己扛这事

规则写好只是第一步,关键是把它塞进自动化的流水线。每次 git push 都等于给代码底裤翻个面,而不是指望谁还记得手动跑一遍 PHPStan。

GitHub Actions 配置不复杂。在项目根目录塞个 .github/workflows/phpstan.yml,核心三步:拉代码、装依赖、跑分析。记得显式指定 --configuration=phpstan.neon,不然自定义规则不会被加载。另外别用 --no-dev——依赖装不全,规则注册直接失效。

有个容易踩的坑:存量代码。项目里可能有 200 个没写 catchtry 块,你不可能一夜之间全修完。这时候要用 --generate-baseline 生成基线文件。第一次跑带上这个参数,PHPStan 会把当前所有违规写进基线,后续分析自动忽略它们。新代码里再出现同样问题,才会报错。

我习惯再加一层轻量预检。在 PHPStan 深度分析之前,先跑 php -l 检查语法——500 个文件半秒扫完,能把 PHP Parse Error 这种低级问题提前拦截掉。然后才是 PHPStan 的深度扫描,形成「语法层→规则层」两道防线。

GitLab CI 配置思路一样,只不过换成对应镜像。踩过 php:8.2-cliext-pdo 的坑,导致 DB::transaction() 相关分析报假阳性。最后换了 才消停。

初期基线刚生成那会儿,团队里其实挺不适应的——突然冒出几百条未处理异常,谁看了都头大。我的做法是:CI 放行、基线违规的 PR 先不拦着合并,但违规记录留在那,谁提交谁心里有数。等大家慢慢接受了这套规则,再一步步收紧。千万别上来就一刀切,不然同事天天找你吵架,代码还没改完,人际关系先崩了。

GitHub Actions CI pipeline for PHPStan static analysis

进阶:让 PHPStan 学会读 Laravel 的心

静态分析真要落地,光靠「有没有 try」远远不够。得知道它配没配对的 catchcatch 里有没有 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 和没回滚的事务,自己就跳出来了。