做电商后端的朋友应该都体会过:一个订单创建,接下来要扣库存、发优惠券、记积分、推送小程序模板消息、给运营发邮件通知。如果这些代码全都写在Controller或者Service里,不出三个月,你会发现自己连改个订单状态都手抖,生怕动了一处带着另一处崩。
ThinkPHP 8把完整的事件系统摆上了台面,算是一件正经事。事件的好处说白了就是“谁关心,谁自己去监听”。订单服务只管把订单状态改完,然后广播一声“订单已创建”。谁想响应,谁就去监听这个事件,互不打扰。这么说可能有点抽象,咱们直接上代码,看它是怎么帮我把一个烂摊子理顺的。
一、我遇到的真实情况
上个月领导让我在现有订单流程里加一个“订单完成后送抽奖次数”的功能。原本的代码是这样的:
public function paid($orderId)
{
$order = Order::find($orderId);
$order->status = 2;
$order->save();
// 通知用户
SmsService::send($order->user_id, '您的订单已付款...');
// 加积分
UserScore::add($order->user_id, $order->amount);
// 给运营发邮件
MailService::send('admin@example.com', '新订单...');
}
看着不复杂,可你要知道,这个paid方法被调用的地方至少有六七处,分散在支付回调、手动确认、后台补单等等。今天要加抽奖,我必须去把每一处都复制粘贴一遍。要是忘了其中一处,线上就会出现“这单付款了没给抽奖”的投诉。
用事件系统改完以后,paid方法里只留下改订单状态这一件事。其他乱七八糟的通知全部变成听众,什么时候想加新动作,就再写一个监听类,不用动原有代码。
二、事件系统的基础构件
TP8的事件系统主要有两个角色:事件类和监听器(也叫订阅者)。你可以把事件类理解为一个“消息信封”,里面装着订单ID等数据;监听器就是打开这个信封并干活的人。
先看看怎么定义一个订单支付成功的事件类。我在app/event/目录下建了一个OrderPaid.php:
namespace appevent;
class OrderPaid
{
public $order;
public $extra;
public function __construct($order, array $extra = [])
{
$this->order = $order;
$this->extra = $extra;
}
}
再看监听器。我想把“送积分”单独做成一个监听器,在app/listener/下建一个UpdateUserScore.php:
namespace applistener;
use appeventOrderPaid;
use thinkfacadeLog;
class UpdateUserScore
{
public function handle(OrderPaid $event)
{
$order = $event->order;
// 积分比例:1元=1分
UserScore::add($order->user_id, intval($order->amount));
Log::info("订单 {$order->id} 积分已加");
}
}
然后去注册监听关系。ThinkPHP 8在app/event.php文件里这样弄:
use appeventOrderPaid;
use applistenerUpdateUserScore;
use applistenerSendSmsNotification;
use applistenerAddLotteryChance;
return [
'bind' => [
// 如果你希望用字符串名字触发事件,可以在这里绑定
],
'listen' => [
OrderPaid::class => [
UpdateUserScore::class,
SendSmsNotification::class,
AddLotteryChance::class,
],
],
];
注意listen数组里,一个事件类对应一个数组,里面可以挂多个监听器。它们会按照你挂的顺序依次执行。目前我有三个:加积分、发短信、送抽奖次数。
三、订单控制器里怎么触发
触发事件有两种办法,你习惯用哪个就用哪个。第一个是事件门面(thinkfacadeEvent):
use thinkfacadeEvent;
public function paid($orderId)
{
$order = Order::find($orderId);
$order->status = 2;
$order->save();
// 广播事件,把订单对象传过去
Event::trigger(new appeventOrderPaid($order));
}
第二种是用助手函数event():
event(new appeventOrderPaid($order));
两种都一样。我现在更习惯用event(),因为它短。而且就算后期把构造参数改成多个,也不用改触发地方。
触发这一行代码,后面三个监听器就屁颠屁颠跑了。加积分的、发短信的、加抽奖的,全都自己做自己的事,互不相干。订单业务的主流程只改一次状态,干净利索。
四、带参数和返回值的事怎么处理
有时候监听器里需要执行完以后给个反馈,比如某个外部系统调用失败是不是要终止流程?TP8事件系统也支持返回值,但默认多个监听器之间不会互相影响。
你可以在监听器的handle方法里返回false,就能阻止后续监听器执行。举例:我写了一个风险控制监听器,如果发现订单有异常,就返回false,后面加积分、送抽奖的动作就不执行了。
namespace applistener;
class RiskCheck
{
public function handle($event)
{
if ($event->order->user_id == 10086) {
return false; // 拦截
}
return true;
}
}
然后在event.php里把它排在第一个:
OrderPaid::class => [
RiskCheck::class,
UpdateUserScore::class,
SendSmsNotification::class,
AddLotteryChance::class,
],
这样风险检查一旦返回false,后面所有监听器都不会执行。这相当于给订单支付流程加了一道闸门,非常实用。
五、监听器里能用依赖注入吗
这是我最喜欢的一点。TP8的监听器是支持依赖注入的。也就是说你可以在监听器的构造函数里提前把要用到的服务传进来,而不是在handle里写一堆new。
namespace applistener;
use appserviceSmsService;
class SendSmsNotification
{
protected $sms;
public function __construct(SmsService $sms)
{
$this->sms = $sms;
}
public function handle($event)
{
$this->sms->send($event->order->user_id, '您有一个新订单已付款');
}
}
以前写业务代码时,总是到处new_service,代码里全是耦合。现在通过构造函数注入,整个监听器就像一个小型API,清爽不少。
注意:监听器里做业务必须保证不抛异常影响主流程。所以我在每个监听器里都try/catch包住,异常记录到日志,但是不允许它中断事件循环。除非你确实想让某个监听器失败后阻断后面的监听器,那你就在监听器里不捕获异常,让TP8自己处理。我一般是捕获后写日志,然后正常继续。
六、队列事件:把短信和邮件扔进后台
事件默认是同步执行的。也就是说,监听器里跑了2秒,你的订单接口就得等2秒。虽然订单状态已经改了,但用户点完支付看到转圈转2秒,体验肯定差。
ThinkPHP 8里做了队列事件的集成。你不需要在监听器里自己写异步逻辑,直接定义一个“事件类”,让它实现一个接口就行。我用的是thinkcontractQueueShouldQueue。
namespace appevent;
class OrderPaid implements thinkcontractQueueShouldQueue
{
public $order;
public function __construct($order)
{
$this->order = $order;
}
}
然后在config/queue.php里配置好队列驱动(比如Redis或者数据库)。当触发OrderPaid事件时,如果你在event.php里监听器没有特别设置,TP8会自动把事件丢进队列里,异步执行监听器。同步的监听器还是同步执行,需要异步的监听器单独处理。
不过这里有个坑,监听器类里的依赖注入在队列模式下不会自动完成,因为那是另一个进程,容器是重新初始化的。所以我的做法是:把短信、邮件这类耗时的监听器在handle方法里使用thinkfacadeQueue或容器手动创建服务,而一些轻量的逻辑(比如写日志、改内存缓存)保留同步。
我实际项目中的配置是这样的:
OrderPaid::class => [
RiskCheck::class, // 同步,立即拦截
UpdateUserScore::class, // 同步,写本地数据
SendSmsNotification::class, // 异步,丢队列
AddLotteryChance::class, // 异步,丢队列
],
那么问题来了:一个事件,怎么让部分监听器同步,部分异步?当事件类实现了QueueShouldQueue时,所有监听器都会进队列。要让某个监听器同步,可以在监听器里加一个属性:
class SendSmsNotification
{
// 这个属性设为false,表示不异步,直接执行
public $async = false;
}
TP8的队列事件会去检查监听器对象上有没有$async属性,如果为false就同步执行。所以上面RiskCheck没有$async属性,默认被队列包裹;实际上我更想让它同步,所以我在代码里给RiskCheck类加了public $async = false;这样它就不进队列。
这个细节官方文档里没有写得很清楚,反正我是试出来的。如果你有什么更好的办法,欢迎告诉我。
七、从折腾到真香
后来我们又加了“订单金额满100元赠运费险”的需求,我只需要新建一个监听器,然后在event.php里加一行,完事。当时同事都有点惊讶,因为我只花了十分钟。
用事件系统最大的改变不是代码量少了多少,而是你不再需要去理解“一笔订单支付后到底要经历什么”这种全局逻辑。你只需要关心自己负责的那个监听器,接收数据,处理数据。真正做到了各自负责,互不踩踏。
如果你是刚开始接触ThinkPHP 8,我建议你从简单的日志监听开始。比如用户登录成功事件,写个监听器记一下登录时间和IP。等熟悉了这套模式,再把订单、支付这种核心流程慢慢拆开。你会发现,原来乱成一团的业务逻辑,也能被整理得服服帖帖。
代码写久了,越来越觉得优雅的架构不是靠设计出来的,而是靠一次次解耦拆出来。事件系统就是那把拆分的手术刀。

