上月接手一个电商项目,订单模块里有一个“订单状态变更”的操作。原开发在 Order 模型里写了不少模型事件,用来在状态改变后发短信、记录日志、更新会员等级。刚上线那会儿还好,可随着业务增加,模型事件里的代码越来越乱,各种 if 套 if,看着就头大。后来我下决心用事件监听器重构了这块,代码清爽了不止一倍。
这里不踩模型事件,只是提供一个更适合复杂业务的玩法。下面用一个小案例带你走一遍:订单支付成功后,需要通知用户、写一条操作日志、统计订单金额。你会看到模型事件怎么写,以及为什么我最后换成了事件监听器。
一、为什么一开始会选模型事件
ThinkPHP 8 的模型支持事件钩子,比如 afterInsert、afterUpdate 等。在模型类内部定义这些方法,就能在写入后自动触发。以订单为例,原来代码大概长这样:
<?php
namespace appmodel;
use thinkModel;
use thinkfacadeLog;
class Order extends Model
{
// 订单状态变更后触发的模型事件
public static function onAfterUpdate(Order $order)
{
// 只处理状态为已支付的情况
if ($order->status == 1) {
// 1. 发短信通知用户
SmsService::send($order->phone, '您的订单已支付成功');
// 2. 记录日志
Log::info('订单已支付: ' . $order->order_no);
// 3. 更新会员等级积分
User::where('id', $order->user_id)->inc('points', $order->amount)->update();
}
}
}
刚开始这么写很方便,因为不需要额外注册什么。可后来订单状态变更的地方越来越多,有管理后台手动改、用户取消、超时关闭,还有回调通知。每个入口都有可能更新订单,也就都会触发 onAfterUpdate。问题来了:
- 模型事件里混杂了“发送短信”“写日志”“积分更新”三类无关逻辑,违反了单一职责。
- 有时候第三方通知回调里更新了订单,我不想让它发短信(比如同步数据时会误触发)。模型事件无法很方便地屏蔽。
- 如果将来要增加“发送邮件”或者“推送小程序订阅消息”,还得改模型类,扩展性差。
二、事件监听器,把业务拆成独立模块
ThinkPHP 的事件系统本来就是为了解决这类问题。和模型事件不同,事件监听器可以让“谁关心这个事件”自己去监,而不是在模型里写死。核心思路是:订单模型只负责数据更新,更新完成后抛出一个“订单已支付”的事件,然后由监听器去处理不同的任务。
这样做的好处是:模型不需要知道自己更新后要做什么,每个监听器只做一件事,互不干扰。而且我可以按需监听,不想触发时直接不监听,或者用事件类自带的“传播”特性来控制。
三、一步步重构订单支付事件
3.1 定义事件类
在 app/event.php 中注册事件定义,或者用更灵活的方式:直接创建一个事件类,携带订单数据。这里我选择建一个 OrderPaid 事件类,专门代表“订单支付成功”这个动作。
<?php
namespace appevent;
class OrderPaid
{
public $order;
public function __construct($order)
{
$this->order = $order;
}
}
3.2 创建监听器
分别创建三个监听器类,各司其职。比如 NotifyUser 负责发送通知,WriteOrderLog 负责记录日志,UpdateUserLevel 负责更新会员等级。
<?php
namespace applistener;
class NotifyUser
{
public function handle(OrderPaid $event)
{
// 这里使用你项目的短信服务
SmsService::send($event->order->phone, '您的订单已支付成功');
}
}
<?php
namespace applistener;
use thinkfacadeLog;
class WriteOrderLog
{
public function handle(OrderPaid $event)
{
Log::info('订单已支付: ' . $event->order->order_no);
}
}
<?php
namespace applistener;
class UpdateUserLevel
{
public function handle(OrderPaid $event)
{
User::where('id', $event->order->user_id)
->inc('points', $event->order->amount)
->update();
}
}
3.3 注册监听器
在 app/event.php 文件中,将事件与监听器关联起来。
<?php
return [
'bind' => [
'OrderPaid' => 'appeventOrderPaid',
],
'listen' => [
'appeventOrderPaid' => [
'applistenerNotifyUser',
'applistenerWriteOrderLog',
'applistenerUpdateUserLevel',
],
],
];
如果你用的是模块化架构,也可以把配置文件放到模块目录下。这里全局注册最省事。
3.4 在订单模型更新后触发事件
接下来,在 Order 模型里不再直接写业务逻辑,而是调用事件触发。我选择用模型的 afterUpdate 钩子来触发,因为支付成功就是一个更新动作。
<?php
namespace appmodel;
use thinkModel;
use thinkfacadeEvent;
class Order extends Model
{
public static function onAfterUpdate(Order $order)
{
// 只有状态变为已支付时才触发业务事件
if ($order->status == 1) {
Event::trigger(new appeventOrderPaid($order));
}
}
}
同样的,控制器里仍然只需要更新订单状态。但后续的所有额外操作都自动跑在监听器里了。
3.5 在控制器里如何调用
<?php
namespace appcontroller;
use appmodelOrder;
use thinkRequest;
class OrderController
{
public function pay(Request $request)
{
$order = Order::find($request->param('order_id'));
// 模拟支付成功置状态为1
$order->status = 1;
$order->save();
return json(['code' => 0, 'msg' => '支付成功']);
}
}
你不需要在控制器里手动触发事件,模型钩子会自动完成。当然,如果你希望某些场景不触发,可以在模型里设置一个临时属性来跳过。例如:
// 临时忽略事件
$order->withEvent(false)->save(); // ThinkPHP8 已内置这个方法
这比在模型事件里塞各种判断简单多了。
四、比模型事件强在哪
重构后最大的感受是,每个文件篇幅小了很多,排查问题只看对应监听器就行。而且当我需要新增一个“发送企业微信通知”的时候,只需要新建一个监听器,然后在 event.php 里加一行,不动任何现有代码。
还有一点:监听器支持按需动态监听。比如临时只想停掉短信通知,直接在 event.php 里把 NotifyUser 注释掉就好,不用跑到模型里删代码。这要是放在以前,得去翻那份复杂的 if 代码。
五、绕不过的坑
5.1 事件监听器是同步执行的
ThinkPHP 的事件默认是同步运行。也就是说,监听器里的短信、日志、积分更新全部阻塞在订单 save() 之后的流程里。如果某个监听器执行慢,会影响接口响应。对于发短信这种外部调用,建议把监听器改成异步任务,比如放到队列里。
我的做法是:在监听器的 handle 方法里把真正耗时的操作投递到队列。这里不展开队列的具体写法,但你可以用 ThinkPHP 自带的 Queue。
5.2 事件类对象传递的坑
如果你的监听器要修改订单的数据,并且希望后续其他监听器能看到修改,那就需要传引用。比如在 NotifyUser 里改了 $event->order->remark,而 WriteOrderLog 依赖这个字段。事件类构造函数里最好写 &$order 来强制引用。
否则你拿到的就是一个副本,改了不影响后续监听器。我一开始没注意,导致日志里打印的备注一直是旧的,折腾了半天。
5.3 不要和模型事件混用相同逻辑
改完以后,最好把原来模型事件里的业务逻辑全部删掉,只保留触发事件这一句。不然会出现“发两次短信”的情况。我们在代码评审时重点关注了这个点。
六、进阶玩法:事件订阅
当监听器过多时,可以用事件订阅器把相关监听器捆在一起。一个订阅器类里可以监听多个事件,让代码归拢。
<?php
namespace applistener;
class OrderSubscriber
{
public function onOrderPaid(OrderPaid $event)
{
// ...
}
public function onOrderCancel(OrderCanceled $event)
{
// ...
}
public function subscribe($events)
{
$events->listen('OrderPaid', [$this, 'onOrderPaid']);
$events->listen('OrderCanceled', [$this, 'onOrderCancel']);
}
}
然后注册这个订阅器到 event.php 中。这种方式适合一个领域下的事件比较多时,便于维护。
七、最后
经过这次重构,我深刻体会到:模型事件适合做“模型内部自身状态的一致性维护”,而跨领域、多副作用的业务动作,用事件监听器才是正解。它让模型保持干净,让业务逻辑自治还灵活。
如果你的项目里也有类似的一堆模型钩子和越来越重的模型事件,强烈建议试试事件监听器。它不会让你多写太多代码,但后期维护时你会谢现在的自己。
以上代码都是基于 ThinkPHP 8.0 版本编写,目录结构和命名空间请根据你的项目微调。欢迎交流各自踩过的坑。

