上周线上告警:MySQL 连接数在凌晨三点突然飙到 298,DBA 抓包发现全是 idle in transaction 状态。查日志,没报错;看队列,消费正常;最后翻到 App\Models\Order::created 监听器里一行 Mail::to($user)->send(new OrderConfirmed($order)) —— 邮件驱动配置错了,但异常被 Laravel 事件分发器吞掉,PDO 连接没释放,事务也没回滚。
模型事件监听器:数据库泄漏的隐形推手
很多人以为「事务悬挂」只发生在手动 DB::transaction() 里。其实 static::creating()、static::saved() 这些模型事件监听器更危险:它们天然运行在主事务上下文中,一旦抛出未捕获异常,Laravel 不会自动 rollback,PDO 连接也不会 close —— 尤其当监听器里调用外部服务(邮件、HTTP、日志)失败时。
典型场景就三个:
• 邮件发送失败(SMTP 配置错 / 网络抖动)
• 日志写入异常(磁盘满 / Monolog handler 崩了)
• 第三方 SDK 抛出 RuntimeException(比如 Stripe Webhook 验证失败)

手动排查 vs 静态分析:为何选择PHPStan
靠日志翻三天?靠 Xdebug 单步进 17 个监听器?上回那个 Mail::send() 问题,我们花了 6 小时才定位到邮件驱动配置错——而异常根本没进日志,PDO 连接就卡在 状态里。
手动查漏,本质是用时间赌运气
你得确保每个监听器都加了 try/catch;得确认 DB::transaction() 外层没被 Laravel 事件分发器绕过;还得祈祷第三方 SDK 的 Http::post() 不被 Event::dispatch() 吞掉。现实是:90% 的监听器连 Log::error() 都没写。
PHPStan 的 AST 扫描,不依赖运行时
它直接解析 App\Models\User 和模型类里的 static::created()、static::updated() 调用点,识别所有未包裹在 try 块中的外部调用(Mail::send()、Http::post()、Log::info())。不是猜,是看代码树的结构。
自定义规则 App\PHPStan\Rules\ModelEventCallWithoutTryCatchRule 会报出:App\Models\User::saved() calls Mail::to() without surrounding try-catch — possible connection leak on exception
静态分析不会漏掉那个没写测试的 Order::updated(),也不在乎你是不是关了 APP_DEBUG。

编写PHPStan自定义规则:三步搞定
那问题理清楚了,总得动手修吧。写一条 PHPStan 自定义规则,说到底就三步:先拿到 AST,再遍历监听器节点,最后检查 try/catch 有没有漏掉。别一听「抽象语法树」就觉得高大上——你把代码拆成一棵结构树,它就是个带坐标的地图,我们只需找到地图上那几个关键地标,剩下的事就好办了。
先说环境:PHPStan 1.10+ 是必须的,Laravel 9 或 10 都行,11 也兼容(虽然我还没在生产上跑过 11)。自定义规则是一个实现 PHPStan\Rules\Rule 接口的类,核心方法就一个 getNodeType(): string 和 processNode(Node $node, Scope $scope): array。
第一步:从 AST 里捞出模型事件监听器
模型事件监听器长什么样?User::creating(function ($user) { ... }) 或者类里定义的 static::creating() 方法。我们关心的是后者——那些直接定义在 Model 类中的静态方法。它们在 AST 里对应 ClassMethod 节点,方法名匹配 creating、created、saving、saved、updating、updated、deleting、deleted 这八个之一。
class ModelEventCallWithoutTryCatchRule implements \PHPStan\Rules\Rule
{
public function getNodeType(): string
{
return \PhpParser\Node\Stmt\ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
// 只处理 Model 类里的静态方法
if (!$node->isStatic()) {
return [];
}
$methodName = $node->name->toString();
$modelEventMethods = [
'creating', 'created', 'saving', 'saved',
'updating', 'updated', 'deleting', 'deleted'
];
if (!in_array($methodName, $modelEventMethods, true)) {
return [];
}
// 确认当前类继承自 Model
$classReflection = $scope->getClassReflection();
if (!$classReflection->isSubclassOf(\Illuminate\Database\Eloquent\Model::class)) {
return [];
}
// 方法体拿到了,下一步检查异常处理
$statements = $node->getStmts();
// ... 后续逻辑
}
}
这段代码干了三件事:限制节点类型为类方法,只取静态方法,只取那八个事件名,结果确认类继承自 Model。少一步都会有误报。
第二步:检查异常处理是否完整
拿到方法体后,我们要找的是所有「外部调用」——那些可能抛出异常且没有被 try/catch 包裹的语句。什么是外部调用?Mail::to()、Http::post()、Log::info()、dispatch(new Job),甚至 Cache::put() 在 Redis 连不上时也会崩。
最简单粗暴的方式:遍历方法体内的所有 StaticCall 和 MethodCall 节点,检查它们是否被某个 TryCatch 包裹。PHPStan 的 Scope 对象提供了 isInTryCatch() 方法,但注意:它只能判断当前节点是否直接位于 try 块内。嵌套的情况,比如 try 块里又调了另一个方法,那个方法里的调用是看不到的——但那是另一个规则该管的事。
public function processNode(Node $node, Scope $scope): array
{
// ... 上一步的筛选逻辑 ...
$errors = [];
$statements = $node->getStmts();
if ($statements === null) {
return []; // 抽象方法或接口方法
}
$this->checkStatementsForUncaughtCalls($statements, $scope, $errors);
return $errors;
}
private function checkStatementsForUncaughtCalls(
array $statements,
Scope $scope,
array &$errors
): void {
foreach ($statements as $stmt) {
if ($stmt instanceof \PhpParser\Node\Stmt\TryCatch) {
// try 块内部是安全的,不检查
continue;
}
if ($stmt instanceof \PhpParser\Node\Expr\StaticCall
|| $stmt instanceof \PhpParser\Node\Expr\MethodCall) {
$calleeName = $this->resolveCalleeName($stmt);
if ($this->isExternalServiceCall($calleeName)) {
$errors[] = \PHPStan\Rules\RuleErrorBuilder::message(
sprintf(
'Model event listener %s() calls %s without try-catch — possible connection leak on exception',
$scope->getFunctionName(),
$calleeName
)
)->line($stmt->getLine())
->build();
}
}
// 递归检查子语句(比如 if/foreach 里的调用)
foreach ($stmt->getSubNodeNames() as $subNodeName) {
$subNode = $stmt->$subNodeName;
if (is_array($subNode)) {
$this->checkStatementsForUncaughtCalls($subNode, $scope, $errors);
}
}
}
}
这里有个细节:resolveCalleeName() 需要你自己实现,从 AST 节点里提取出完整的调用字符串。比如 Mail::to() 的 StaticCall 节点,class 部分是 Name 节点(值为 Mail),name 部分是 Identifier(值为 to)。拼起来就是 Mail::to。MethodCall 类似,对应 $this->httpClient->post() 这种。
第三步:标记风险并报告
第二步里已经通过 生成了错误。但光是报错不够,我们还想告诉开发者「这可能导致连接泄漏」。PHPStan 的错误级别可以调:RuleErrorBuilder::message() 之后链式调用 ->tip() 可以加建议。
$errors[] = \PHPStan\Rules\RuleErrorBuilder::message(
sprintf(
'Model event %s::%s() calls %s without try-catch — possible DB connection leak',
$scope->getClassReflection()->getName(),
$scope->getFunctionName(),
$calleeName
)
)->line($stmt->getLine())
->tip('Wrap the call in try-catch and close the connection on failure: try { %s } catch (\Throwable $e) { DB::disconnect(); Log::error($e); throw $e; }', $calleeName)
->build();
注意
的 rules 数组里注册你的规则类,否则写了等于没写。注册完了跑一遍:vendor/bin/phpstan analyse --level=max app/Models。如果之前哪个 static::created() 里直接调了 Http::post() 没包 try,立马会飘红。
这个规则不是银弹。它抓不到运行时动态注册的闭包监听器(比如 Event::listen('eloquent.created: App\Models\User', function ...)),也抓不到 $dispatchesEvents 属性里配的函数。但没关系——80% 的泄漏发生在显式定义的模型事件方法里,逮住这些就够把事故率砍掉大半。
实话实说,写这个规则花了大概三小时,调试 AST 节点类型占了两个小时。但上线当天就在一个用了六年的项目里抓出四个未捕获调用——三个发邮件,一个写日志。那四个监听器如果同时出错,记录库连接池能直接被打满。
实战:在Laravel项目中运行检测
规则写好了,总不能躺在单元测试的 fixture 目录里吃灰。真正要让它干活,得把它塞进 PHPStan 的加载链里,然后对着真实项目跑一遍。这一步比写规则本身更容易翻车——不是规则不生效,就是路径配错导致分析器压根没扫描到你的模型文件。
首先确认 PHPStan 版本。如果你还在用 0.12.x,建议先升到 1.10+。老版本的自定义规则接口是 PHPStan\Rules\Rule,但参数签名在 1.x 改过两次,直接复制网上的示例大概率报 TypeError。升级完了跑一下 phpstan --version,版本号对得上再往下走。
安装自定义规则包不需要额外 composer 包——规则类直接放在 app/PHPStan 或项目根目录下的 rules/ 都行。我习惯丢在 ,命名空间用 App\PHPStan\Rules,然后在 的 autoload 里加上 PSR-4 映射:
"autoload": {
"psr-4": {
"App\\": "app/",
"App\\PHPStan\\": "app/PHPStan/"
}
}
跑 刷新一下,不然 PHPStan 找不到你的类。
关键的配置在 。如果你项目里还没有这个文件,从 Laravel 官方提供的 一份改改。在 rules 数组里注册你的检测类:
rules:
- App\PHPStan\Rules\ModelEventCallWithoutTryCatchRule
然后把扫描路径指到模型目录:
这里有个坑:如果你用了 Laravel 的 IDE helper 生成的 _ide_helper.php,或者安装了 ,记得在 scanFiles 或 里把它排除掉,否则 PHPStan 会在那个文件里分析出一堆不存在的模型方法,干扰结果。
跑起来:
vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G
第一次跑的时候,输出可能让你崩溃。别慌,Laravel 的 Facade 和魔术方法会让 PHPStan 报一堆 ——这不代表你的代码有问题,只是 PHPStan 不认识 User::where() 这种动态调用。解决方案是在 里加载 Laravel 的扩展包:
Larastan 会提供 Facade 和 Eloquent 的桩文件,把误报压到合理范围。如果你不想额外引入 Larastan,也可以自己写 stub 文件,但工作量不亚于再写一条规则。
终端刷出来的检测结果其实是这样的——拿一个真实项目跑完,输出差不多是:
Line App\Models\Order.php
42 Model event App\Models\Order::created() calls Http::post() without try-catch
— possible DB connection leak. Wrap the call in try-catch and close
the connection on failure.
Line App\Models\Invoice.php
78 Model event App\Models\Invoice::updated() calls Log::channel('slack')->info()
without try-catch — possible DB connection leak.
第一个泄漏点在 Order::created() 里直接调了 Http::post() 通知第三方支付网关。那哥们当初写的时候觉得“发个请求而已,不会挂的”——结果第三方网关超时,PDO 连接没释放,队列进程占满连接池,整条支付链路僵死。第二个是往 Slack 发通知,Log facade 底层走 UDP 按理不会阻塞,但 Log::channel('slack') 实际上是同步 HTTP POST,一样有连接泄漏风险。
你可能会问:这些地方加个 try-catch 把 DB::disconnect() 塞进 catch 块不就行了?对,但问题是没人记得。而且很多项目里模型事件是后期加的,代码评审时根本没人盯着这个点。自动化规则的价值就在这儿——它替你记住那些你不想记的事。
只有一个场景这条规则无能为力:Event::listen('eloquent.created: App\Models\User', function ($user) { ... }) 这种动态注册的闭包监听器。闭包在运行时才加载,AST 分析看不到。我的做法是在项目里加一个脚本文档,要求所有闭包监听器必须委托到显式的 Listener 类,然后给那个类加上 @phpstan-require-try-catch 注解,再写一条补充规则去扫注解。但那是后话了,先把 80% 的显式泄漏按住,剩下的手动审计也不至于手忙脚乱。
上线这条规则那天下午,CI 流水线直接红了三个 PR。开发者骂了两句,然后默默给模型事件套上了 try-catch。没人再提连接池被打满的事——因为没再发生过。
修复建议:从源头杜绝泄漏
规则能扫出来,但改不改还是看人。我见过最离谱的修复方式是:在 catch 块里写一行 DB::disconnect(),然后继续抛异常——连接是释放了,事务还在半空中挂着。
try/finally 比 try/catch 更安全
模型事件监听器里如果开了事务,或者隐式持有了连接,推荐用 try/finally 而不是 try/catch。因为 finally 块无论是否抛出异常都会执行,能确保 DB::disconnect() 或 DB::rollBack() 一定被调用。写起来也不复杂:
// 别这样
try {
$order->update(['status' => 'paid']);
Http::post('https://gateway.example.com/notify', $payload);
} catch (\Throwable $e) {
DB::disconnect();
throw $e;
}
// 这样更稳妥
try {
DB::beginTransaction();
$order->update(['status' => 'paid']);
Http::post('https://gateway.example.com/notify', $payload);
DB::commit();
} finally {
DB::rollBack();
DB::disconnect();
}
注意 finally 里的 DB::rollBack() 在事务已提交时不会报错,Laravel 的 DB::transaction() 会忽略多余的 rollBack 调用。当然,如果你用了 DB::transaction() 闭包写法,这些细节已经被框架处理了——但模型事件里直接调用 DB::beginTransaction() 的场景依然常见。
事务内操作尽量委托给队列
那个支付网关超时的例子,本质问题不是没 try-catch,而是在事务里做了同步 HTTP 请求。模型事件的触发时机往往在框架的事务保护层内(比如 Model::save() 可能嵌套在外部事务里),你在监听器里调用外部 API,等于把整个事务的提交时间交给了第三方。
解决办法:监听器里只做数据整理,然后 dispatch(new NotifyGatewayJob($order))。队列任务失败会自动重试,不会把数据库连接拖死。PHPStan 规则可以再加一条:检测模型事件监听器里是否有 Http::、Mail::、Log::channel('slack') 等 IO 调用,强制要求走队列。
对于闭包监听器,用文档约定兜底
前文说了 AST 扫不到闭包。我自己的做法是在 App\Providers\EventServiceProvider 的注释里写一段 @see ClosureListenerPolicy 指引,然后在项目 README 里加一条醒目的注意事项。开发者看到就会知道:闭包监听器必须显式声明 @phpstan-require-try-catch,否则 CI 不会放过你。这条补充规则大概 30 行 PHPStan 代码就能搞定——用 PhpParser\Node\Stmt\ClassMethod 扫注解,命中 @phpstan-require-try-catch 的方法再检查内部是否有 try-catch 结构。
自动化规则能卡住八成显式泄漏,剩下的两成,靠文档规范和代码评审慢慢磨。从那以后,连接池被打满的告警再也没响过。





评论