业务代码膨胀到一定程度,一个控制器方法里塞了几百行逻辑,又是发邮件又是写日志又是更新统计,各种依赖互相纠缠,改一处动不动就牵出一串bug。ThinkPHP 8的事件系统就是为这种场景准备的:它可以把主流程和附加行为彻底拆开,让代码变得干净、可扩展。这篇就通过一个完整的用户注册案例,把事件、监听器、订阅者这些概念落地到可运行的代码上。
一、事件系统解决了什么问题
假设用户注册时要同步做三件事:发送激活邮件、记录注册日志、给新用户发放初始积分。如果全部写在注册方法里,大概长这样:
public function register(Request $request)
{
// 验证数据...
$user = User::create($request->post());
// 发送邮件
Mail::send($user->email, '激活邮件', '请点击链接激活...');
// 记录日志
Log::record("新用户注册:{$user->id}");
// 发放积分
$user->score = 100;
$user->save();
return json(['code' => 0, 'msg' => '注册成功']);
}
这段代码不算错,但当需求频繁变动时——比如某天运营说注册不再送积分,改成送优惠券,或者日志要写到独立数据库——你就得反复改这个核心方法。更麻烦的是,如果发邮件超时或者抛异常,后面的积分赠送也会跟着挂掉,而这些步骤之间本不该有这种强依赖。
事件系统的思路是:在关键节点“抛出”一个事件,然后把后续动作拆成独立的监听器,由框架自动调度执行。注册方法只需要关心“用户被创建了”这件事本身,其余副作用全部解耦出去。
二、快速上手:手动定义与触发事件
ThinkPHP 8的事件类通常放在应用的event目录下。我们创建一个UserRegistered事件:
<?php
namespace appevent;
use appmodelUser;
class UserRegistered
{
public function __construct(public User $user)
{
}
}
这个事件类就是一个普通的PHP类,用来携带上下文数据。现在在控制器里触发它:
<?php
namespace appcontroller;
use appeventUserRegistered;
use appmodelUser;
use thinkfacadeEvent;
class Auth
{
public function register()
{
// 验证通过后创建用户
$user = User::create([
'username' => input('username'),
'password' => password_hash(input('password'), PASSWORD_BCRYPT),
'email' => input('email'),
]);
// 触发事件
Event::trigger(new UserRegistered($user));
return json(['code' => 0, 'msg' => '注册成功']);
}
}
就这么简单,但此时还没有任何监听器来响应这个事件,所以触发后什么也不会发生。接下来我们编写两个监听器。
三、编写监听器并绑定
监听器类可以放在app/listener目录。先写一个发送激活邮件的监听器:
<?php
namespace applistener;
use appeventUserRegistered;
class SendActivationEmail
{
public function handle(UserRegistered $event): void
{
$user = $event->user;
// 假设有个邮件服务类
appserviceMailService::send($user->email, '请激活您的账户', "点击链接激活:http://xxx/activate?token=xxx");
}
}
再写一个记录注册日志的监听器:
<?php
namespace applistener;
use appeventUserRegistered;
class RecordRegisterLog
{
public function handle(UserRegistered $event): void
{
$user = $event->user;
thinkfacadeLog::info("用户注册:ID={$user->id}, 邮箱={$user->email}");
}
}
现在我们需要把事件和监听器关联起来。ThinkPHP 8推荐在应用目录下的event.php配置文件里绑定:
<?php
// app/event.php
return [
'bind' => [
appeventUserRegistered::class => [
applistenerSendActivationEmail::class,
applistenerRecordRegisterLog::class,
],
],
];
这样当Event::trigger(new UserRegistered($user))执行时,两个监听器的handle方法会依次被调用。发送邮件和写日志的代码完全从控制器里消失,主流程干净得像纸片。
四、提升:事件订阅者——一次性解决多个事件
当业务复杂起来,一个功能模块可能要监听好几个事件。比如用户模块除了注册事件,还有登录事件、密码重置事件。如果把每个监听器都单独写一个类,文件会多得让人头疼。事件订阅者可以把属于同一模块的多个监听逻辑归到一个类里。
创建一个订阅者类app/subscribe/UserEventSubscriber.php:
<?php
namespace appsubscribe;
use appeventUserRegistered;
use appeventUserLogin;
use appeventPasswordReset;
class UserEventSubscriber
{
/**
* 用户注册
*/
public function onUserRegistered(UserRegistered $event): void
{
// 发送邮件 + 记录日志,甚至可以直接调两个私有方法
$this->sendActivationEmail($event->user);
$this->logRegistration($event->user);
}
/**
* 用户登录
*/
public function onUserLogin(UserLogin $event): void
{
// 更新最后登录时间
$event->user->last_login_time = date('Y-m-d H:i:s');
$event->user->save();
}
// 私有辅助方法
private function sendActivationEmail($user): void { /* ... */ }
private function logRegistration($user): void { /* ... */ }
}
然后在event.php中注册订阅者:
return [
'bind' => [
// 单个监听绑定可以继续保留
],
'subscribe' => [
appsubscribeUserEventSubscriber::class,
],
];
订阅者类的方法命名遵循on + 事件名的约定,框架会自动识别。这样用户模块的所有事件响应代码都集中在一处,维护起来顺手多了。
五、结合模型事件:不用手动触发也能跑
ThinkPHP的模型本身就内置了一系列事件:before_insert、after_insert、after_update、before_delete等。在很多场景下,我们甚至不用手动调用Event::trigger,直接利用模型事件就能自动触发业务逻辑。
比如用户注册的核心动作就是插入一条用户记录,那我们完全可以让模型在after_insert时自动抛出UserRegistered事件。在User模型里添加:
<?php
namespace appmodel;
use thinkModel;
use appeventUserRegistered;
class User extends Model
{
protected $name = 'user';
public static function onAfterInsert(self $user)
{
// 模型插入后自动触发事件
event(new UserRegistered($user));
}
}
这样控制器里连Event::trigger那行都不用写,创建用户的瞬间事件就自动传播出去了。注意这里用到了助手函数event(),等同于Event::trigger()。模型事件配合事件系统,很多自动化处理变得悄无声息,开发体验很舒服。
六、处理失败与异步执行
事件监听器如果抛异常,默认会中断后续监听器的执行。大部分情况下发送邮件失败不应该阻止日志记录,这时可以在监听器内部用try-catch包裹敏感操作,或者将某些监听器设置为“延迟执行”。
ThinkPHP 8的事件系统本身是同步的,但我们可以轻松把它和队列结合。比如发送邮件可以推入队列异步处理:
use thinkfacadeQueue;
class SendActivationEmail
{
public function handle(UserRegistered $event): void
{
// 推入队列,不阻塞当前请求
Queue::push(function () use ($event) {
appserviceMailService::send($event->user->email, '激活邮件', '内容...');
});
}
}
队列任务在后台执行,哪怕邮件服务暂时不可用,也不会影响用户注册成功的响应速度。监听器本身仍然是同步触发,但里面的重活被转移到队列里,用户体验和系统稳定性都得到了保证。
七、一个便于调试的监听器执行顺序原则
当绑定多个监听器时,它们的执行顺序就是event.php中数组定义的顺序。建议把不依赖外部服务、速度快的监听器放在前面(比如写日志、更新缓存),把可能慢或易失败的操作往后放或推入队列。这样即使后面某个监听器出错,关键的轻量操作已经完成了。
另外可以利用事件系统的listen方法动态绑定,方便在服务提供者里根据环境条件注册不同的监听器,比如开发环境额外增加一个Debug监听器打印所有事件。
八、真实场景下的目录结构参考
app/
├── controller/
│ └── Auth.php
├── event/
│ ├── UserRegistered.php
│ ├── UserLogin.php
│ └── PasswordReset.php
├── listener/
│ ├── SendActivationEmail.php
│ └── RecordRegisterLog.php
├── subscribe/
│ └── UserEventSubscriber.php
├── model/
│ └── User.php
└── event.php
这样一个结构,每个文件的职责都非常明确。新人接手项目时,顺着事件绑定表就能理清整个业务流程的副作用有哪些,不需要翻看几百行的控制器代码。
九、总结
事件驱动不是什么新概念,但在PHP框架里落地得舒服并不容易。ThinkPHP 8的事件系统从手动触发到模型自动抛出,再到订阅者统一管理,层层递进,配合队列可以轻松应对各类解耦需求。用事件系统重构后的注册流程,主逻辑只有“创建用户”这一行核心代码,其他动作全部隐形,业务扩展时加一个监听器就完事,控制器和模型都不用动——这才是可维护代码该有的样子。

