事件
简介
假设你在做一个电商系统。用户下单付款后,你需要:发确认邮件、扣减库存、通知仓库拣货、给用户加积分、记录日志……如果这些逻辑全堆在控制器里,代码会变成这样:
// 不好的做法:所有后续逻辑耦合在控制器里
public function store(Request $request): RedirectResponse
{
$order = Order::findOrFail($request->order_id);
// 发邮件
Mail::to($order->user)->send(new OrderConfirmed($order));
// 扣库存
foreach ($order->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
// 通知仓库
Warehouse::dispatchPickRequest($order);
// 加积分
$order->user->addPoints($order->subtotal);
// ...以后每加一个功能,就得改这段代码
return redirect('/orders');
}
这有什么问题?控制器承担了太多职责,而且每加一个新功能(比如"发货后发 Slack 通知")就得改下单逻辑。事件系统就是来解决这个问题的。
用事件重构后,控制器只管派发一个「订单已创建」事件,后续的邮件、库存、积分各自有独立的监听器去处理,互不干扰:
// 好的做法:控制器只管核心流程 + 派发事件
public function store(Request $request): RedirectResponse
{
$order = Order::findOrFail($request->order_id);
OrderCreated::dispatch($order); // 一行代码,触发所有后续处理
return redirect('/orders');
}
这就是观察者模式——一件事发生了,通知所有关心这件事的人,至于每个人要做什么,发的人不需要管。
Laravel 的事件系统完整实现了这个模式。三个角色分工明确:
- 事件类(
app/Events):纯数据容器,为监听器提供所需数据,自身不含逻辑 - 监听器(
app/Listeners):真正干活的人,在handle()方法里写处理逻辑 - 派发:在业务代码(比如控制器)中触发事件,把数据交给监听器
整个流程就四步:
- 定义事件:创建事件类,把需要传递的数据塞进构造函数
- 定义监听器:创建监听器类,在
handle()方法里写真正要执行的处理逻辑 - 注册监听器:告诉 Laravel「哪个监听器监听哪个事件」——默认自动发现,通常不用管
- 派发事件:在控制器或服务中调用
OrderShipped::dispatch($order),事件带着数据飞向所有监听器
下面以「订单发货」为完整场景,一步步走完整个流程。
生成事件和监听器
用 Artisan 命令生成事件类和监听器类:
php artisan make:event OrderShipped
php artisan make:listener SendShipmentNotification --event=OrderShipped
make:event 生成 app/Events/OrderShipped.php,make:listener --event=OrderShipped 生成 app/Listeners/SendShipmentNotification.php,并自动导入 OrderShipped 事件类、在 handle() 方法中类型提示该事件。
你也可以不带参数调用,Laravel 会交互式提示你输入类名和要监听的事件:
php artisan make:event
php artisan make:listener
注册事件和监听器
事件发现
好消息是:大多数情况下你不需要手动注册。 Laravel 会自动扫描 app/Listeners 目录,当它发现监听器类中有 handle 或 __invoke 方法时,会根据方法签名的类型提示自动注册为对应事件的监听器。
比如你用 make:listener --event=OrderShipped 生成的监听器:
use App\Events\OrderShipped;
class SendShipmentNotification
{
/**
* Handle the event.
*/
public function handle(OrderShipped $event): void
{
// Laravel 看到 handle(OrderShipped $event) 就知道:
// 这个监听器要监听 OrderShipped 事件
Slack::to('#warehouse')->send("订单 #{$event->order->id} 已发货!");
}
}
Laravel 看到 handle(OrderShipped $event) 的类型提示,就知道这个监听器要监听 OrderShipped 事件——不需要你在任何地方手动绑定。
信息
监听器用 handle() 还是 __invoke() 都行,Laravel 都能自动发现。handle() 是 Laravel 约定(make:listener 默认生成),__invoke() 是 PHP 魔术方法(让类可像函数一样调用)。功能完全一样,选一个保持项目风格统一即可。
你也可以用 PHP 的联合类型让一个监听器同时处理多个事件:
/**
* Handle the event.
*/
public function handle(OrderShipped|OrderDelivered $event): void
{
// 订单发货或送达都会触发这个监听器
Log::info("订单 #{$event->order->id} 状态变更", [
'event' => $event::class,
]);
}
如果你把监听器放在 app/Listeners 以外的目录(比如按领域分目录),在 bootstrap/app.php 中指定扫描路径:
->withEvents(discover: [
__DIR__.'/../app/Domain/Orders/Listeners',
])
用 * 通配符扫描多个领域目录下的监听器:
->withEvents(discover: [
__DIR__.'/../app/Domain/*/Listeners',
])
用 event:list 命令确认所有监听器都正确注册了:
php artisan event:list
输出类似:
OrderShipped .... App\Listeners\SendShipmentNotification
OrderCreated .... App\Listeners\SendOrderConfirmationEmail
App\Listeners\DeductInventory
App\Listeners\RewardPoints
生产环境中的事件发现
自动扫描是有性能开销的。生产环境应该用 optimize 或 event:cache 命令缓存监听器清单,通常作为部署流程的一部分执行:
php artisan event:cache
框架会用缓存清单替代运行时扫描,加快启动速度。用 event:clear 清除缓存。
动态事件发现
有时候你想根据环境动态控制某个监听器是否被发现。比如有个调试用的监听器,只在本地环境生效。让监听器实现 ShouldBeDiscovered 接口并定义 shouldBeDiscovered() 方法:
use Illuminate\Contracts\Events\ShouldBeDiscovered;
class LogOrderEvents implements ShouldBeDiscovered
{
public function handle(OrderShipped $event): void
{
Log::debug('订单发货', ['order_id' => $event->order->id]);
}
/**
* 只在本地环境自动发现这个监听器。
*/
public static function shouldBeDiscovered(): bool
{
return app()->environment('local');
}
}
手动注册事件
自动发现能覆盖大部分场景,但有些情况它搞不定:监听器在第三方包或非约定目录中、你想用闭包监听器、或者需要通配符监听器——这些都需要手动注册。用 Event 门面在 AppServiceProvider 的 boot() 方法中手动注册:
use App\Domain\Orders\Events\OrderShipped;
use App\Domain\Orders\Listeners\SendShipmentNotification;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
// 告诉 Laravel:OrderShipped 事件由 SendShipmentNotification 处理
Event::listen(
OrderShipped::class,
SendShipmentNotification::class,
);
}
闭包监听器
如果你的监听逻辑很简单,不想单独建一个类,可以直接在 AppServiceProvider 的 boot() 方法中用闭包注册。比如订单发货后记一条日志:
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(function (OrderShipped $event) {
Log::info("订单 #{$event->order->id} 已发货,收件人:{$event->order->shipping_address}");
});
}
可排队的匿名监听器
如果闭包里的逻辑比较耗时(比如调外部 API),可以用 Illuminate\Events\queueable() 把闭包包装成队列任务异步执行。比如订单发货后调用物流 API 创建运单:
use App\Events\OrderShipped;
use function Illuminate\Events\queueable;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(queueable(function (OrderShipped $event) {
// 调物流 API 创建运单——可能要几秒,放队列里异步跑
$trackingNo = ShippingApi::createWaybill([
'order_id' => $event->order->id,
'address' => $event->order->shipping_address,
]);
$event->order->update(['tracking_no' => $trackingNo]);
}));
}
和队列任务一样,你可以用 onConnection()、onQueue() 和 delay() 自定义执行方式:
Event::listen(queueable(function (OrderShipped $event) {
ShippingApi::createWaybill(/* ... */);
})->onConnection('redis')->onQueue('shipments')->delay(now()->plus(seconds: 10)));
如果想处理失败情况,用 catch() 方法。这个闭包会收到事件实例和导致失败的 Throwable:
use Throwable;
Event::listen(queueable(function (OrderShipped $event) {
ShippingApi::createWaybill(/* ... */);
})->catch(function (OrderShipped $event, Throwable $e) {
// 物流 API 调用失败,通知运维
Log::error('运单创建失败', [
'order_id' => $event->order->id,
'error' => $e->getMessage(),
]);
}));
通配符事件监听器
如果你想监听所有事件(比如做全站事件日志),用 * 通配符注册。通配符监听器的第一个参数是事件名(字符串),第二个参数是事件数据数组:
Event::listen('event.*', function (string $eventName, array $data) {
// 所有通过 Event::dispatch 派发的事件都会经过这里
Log::debug("事件触发: {$eventName}", $data);
});
定义事件
事件类本质上就是一个数据容器——把事件发生时需要传递的信息装进去。比如订单发货事件,需要把 Order 模型传给监听器:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderShipped
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public Order $order,
) {}
}
如你所见,事件类没有任何业务逻辑——它就是装 Order 实例的容器。Dispatchable trait 提供了 dispatch() 静态方法(派发事件时用)。SerializesModels trait 会在事件被序列化时(比如使用队列监听器时)优雅地序列化 Eloquent 模型,避免序列化整个数据库连接。
定义监听器
监听器通过 handle() 方法接收事件实例。在 handle() 方法中执行你需要的任何操作。比如订单发货后给仓库团队发 Slack 通知:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Slack;
class SendShipmentNotification
{
/**
* Create the event listener.
*/
public function __construct() {}
/**
* Handle the event.
*/
public function handle(OrderShipped $event): void
{
$order = $event->order;
Slack::to('#warehouse')->send(
"📦 订单 #{$order->id} 已发货\n".
"收件人:{$order->shipping_name}\n".
"地址:{$order->shipping_address}\n".
"商品数:{$order->items->count()}"
);
}
}
信息
监听器的构造函数也可以类型提示所需的任何依赖。所有监听器都通过 Laravel 的服务容器解析,依赖会自动注入。比如你需要 Mailer、HttpClient 等,直接在构造函数里声明就行。
停止事件传播
默认情况下,一个事件的所有监听器都会依次执行。如果你想阻止事件继续传递给后续监听器(比如检测到库存不足,后续的积分发放就不需要了),在 handle() 方法中返回 false:
public function handle(OrderShipped $event): void
{
if ($event->order->hasStockIssue()) {
// 返回 false,后续监听器不会执行
return false;
}
// 正常处理...
}
注意
监听器的执行顺序取决于注册顺序。
- 自动发现:按文件系统扫描顺序(通常按文件名字母序,但不保证)
- 手动注册:按
Event::listen()的调用顺序
如果你依赖 return false 来阻止特定监听器执行,必须确保顺序正确。用 php artisan event:list 查看实际执行顺序;如果顺序很重要,改为手动注册以精确控制。
队列事件监听器
上面的 SendShipmentNotification 监听器发 Slack 通知,可能几百毫秒就完了。但如果你在监听器里发邮件、调外部 API、生成 PDF,这些操作可能要几秒甚至几十秒。如果同步执行,用户的请求就被卡住了。
解决方案是把监听器放到队列里异步执行。使用前确保已配置好队列并启动了队列 worker。
做法很简单——让监听器类实现 ShouldQueue 接口:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use App\Mail\ShipmentConfirmation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendShipmentNotification implements ShouldQueue
{
/**
* Handle the event.
*/
public function handle(OrderShipped $event): void
{
// 发货确认邮件——SMTP 通信可能要几秒,放队列里异步发
Mail::to($event->order->user)
->send(new ShipmentConfirmation($event->order));
}
}
就这样!当 OrderShipped 事件被派发时,事件分发器看到监听器实现了 ShouldQueue,会自动把监听器推入队列。队列 worker 异步执行监听器,用户请求不会被阻塞。如果执行过程中没有抛出异常,任务处理完毕后自动删除。
自定义队列连接、名称和延迟
默认情况下监听器用队列配置中的默认连接和队列。你可以用 Connection、Queue、Delay 属性覆盖:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use App\Mail\ShipmentConfirmation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\Delay;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Support\Facades\Mail;
// 用 sqs 连接、listeners 队列、延迟 60 秒执行
#[Connection('sqs')]
#[Queue('listeners')]
#[Delay(60)]
class SendShipmentNotification implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
Mail::to($event->order->user)
->send(new ShipmentConfirmation($event->order));
}
}
也可以在运行时动态决定——在监听器中实现 viaConnection()、viaQueue() 或 withDelay() 方法。比如根据订单优先级决定延迟时间:
/**
* 高优先级订单立即发送,普通订单延迟 60 秒。
*/
public function withDelay(OrderShipped $event): int
{
return $event->order->highPriority ? 0 : 60;
}
有条件地排队
有时候你想根据事件数据决定是否排队。比如订单金额超过 5000 元才赠送礼品卡,否则直接跳过。在监听器中添加 shouldQueue() 方法——返回 false 时监听器不会入队(也不会同步执行,直接跳过):
<?php
namespace App\Listeners;
use App\Events\OrderCreated;
use App\Models\GiftCard;
use Illuminate\Contracts\Queue\ShouldQueue;
class RewardGiftCard implements ShouldQueue
{
/**
* 订单金额满 5000 元才赠送礼品卡。
*/
public function handle(OrderCreated $event): void
{
GiftCard::create([
'user_id' => $event->order->user_id,
'amount' => $event->order->subtotal * 0.05, // 5% 返现
'expires_at' => now()->addMonths(3),
]);
}
/**
* 只有订单金额 >= 5000 元时才排队执行。
*/
public function shouldQueue(OrderCreated $event): bool
{
return $event->order->subtotal >= 5000;
}
}
手动操作队列
有时候你在监听器里遇到临时问题(比如外部 API 限流),想把任务放回队列稍后重试。用 Illuminate\Queue\InteractsWithQueue trait 提供的 release() 和 delete() 方法:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class CreateShippingLabel implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderShipped $event): void
{
try {
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
} catch (RateLimitException $e) {
// API 限流了,30 秒后重试
$this->release(30);
}
}
}
release(30) 把任务放回队列,30 秒后重新执行。delete() 则直接从队列中删除任务(不再重试)。
队列监听器与数据库事务
这是一个常见的坑:你在数据库事务里创建订单并派发事件,但事务还没提交,队列监听器就开始执行了——它去查订单,查不到,报错。
// 有问题的写法
DB::transaction(function () use ($order) {
$order->update(['status' => 'shipped']);
OrderShipped::dispatch($order); // 事件立即入队,但事务还没提交!
// 队列监听器此时查 $order 可能查不到最新状态
});
如果你的队列连接配置中 after_commit 设为 false(默认),但仍想让某个监听器在事务提交后才派发,让监听器实现 ShouldQueueAfterCommit 接口:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
// 实现这个接口后,即使全局 after_commit=false,
// 这个监听器也只在事务提交后才入队
class SendShipmentNotification implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public function handle(OrderShipped $event): void
{
Mail::to($event->order->user)
->send(new ShipmentConfirmation($event->order));
}
}
信息
更多关于这个问题的说明,请查阅队列任务与数据库事务文档。
队列监听器中间件
队列监听器也可以使用任务中间件,把通用逻辑(限流、重试、日志)抽出来复用。比如你有个限流中间件防止调 API 太频繁:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use App\Jobs\Middleware\RateLimited;
use Illuminate\Contracts\Queue\ShouldQueue;
class CreateShippingLabel implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
/**
* 每次执行前先过限流中间件。
*/
public function middleware(OrderShipped $event): array
{
return [new RateLimited];
}
}
加密队列监听器
如果监听器处理的数据包含敏感信息(比如用户隐私),可以通过加密保护。添加 ShouldBeEncrypted 接口后,Laravel 会在推入队列前自动加密监听器数据:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendShipmentNotification implements ShouldQueue, ShouldBeEncrypted
{
public function handle(OrderShipped $event): void
{
// $event->order 中的用户信息在队列中被加密存储
Mail::to($event->order->user)
->send(new ShipmentConfirmation($event->order));
}
}
唯一事件监听器
注意
唯一监听器需要支持锁的缓存驱动。目前 memcached、redis、dynamodb、database、file 和 array 缓存驱动支持原子锁。
场景:用户连续快速点击「保存」按钮,许可证被保存了多次,每次都触发 LicenseSaved 事件。你不想每次都重新获取产品密钥——同一个许可证只需获取一次。
让监听器实现 ShouldBeUnique 接口,队列中同一时刻就只会有一个实例:
<?php
namespace App\Listeners;
use App\Events\LicenseSaved;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
class AcquireProductKey implements ShouldQueue, ShouldBeUnique
{
public function __invoke(LicenseSaved $event): void
{
$key = KeyService::acquire($event->license->product_id);
$event->license->update(['product_key' => $key]);
}
}
上面的例子中,如果队列中已经有一个 AcquireProductKey 在处理,新派发的会被直接丢弃。
但这里有个问题:默认的唯一键是监听器类名,也就是说所有许可证共享同一把锁。许可证 A 正在获取密钥时,许可证 B 触发了同样的监听器——也会被丢弃,即使 A 和 B 毫无关系。
如果想让不同许可证并行处理、同一许可证不重复,用 uniqueId() 方法按许可证 ID 区分:
<?php
namespace App\Listeners;
use App\Events\LicenseSaved;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
class AcquireProductKey implements ShouldQueue, ShouldBeUnique
{
/**
* 唯一锁 1 小时后自动释放。
*/
public $uniqueFor = 3600;
public function __invoke(LicenseSaved $event): void
{
$key = KeyService::acquire($event->license->product_id);
$event->license->update(['product_key' => $key]);
}
/**
* 按许可证 ID 区分唯一性。
*/
public function uniqueId(LicenseSaved $event): string
{
return 'license:'.$event->license->id;
}
}
这样,许可证 A 和许可证 B 可以并行获取密钥,但许可证 A 不会被重复获取。如果一小时内没处理完,唯一锁自动释放,新的监听器可以入队。
简单判断:多个不同数据同时触发这个监听器时,是否应该并行处理? 应该就写 uniqueId(),不需要就不写——默认按类名锁定已经够用。
注意
如果你的应用从多个 Web 服务器或容器派发事件,请确保所有服务器都连接同一个中心缓存服务器,这样 Laravel 才能准确判断监听器是否唯一。
处理开始前保持唯一
默认情况下(ShouldBeUnique),锁从入队一直持有到处理完毕。如果处理要 5 分钟,这 5 分钟内所有新派发都被丢弃。
场景:你接收支付平台的 webhook,每次触发 SyncPaymentStatus 监听器同步支付状态。同步要 5 分钟,期间支付平台又发来新的 webhook(可能是真正的状态变更)。用 ShouldBeUnique,这些新 webhook 全被丢弃——你会漏掉真实的状态更新。
用 ShouldBeUniqueUntilProcessing 解决:监听器一开始处理就释放锁,新的 webhook 可以排队等下一轮同步,不会丢。一句话:防队列堆积用 UntilProcessing,防重复执行用默认 ShouldBeUnique。
你可能会问:那不实现任何唯一接口不就行了?不行——不实现接口意味着 50 个重复 webhook 会全部进入队列,造成 50 次重复同步。三种策略的区别:
- 不实现接口:完全不防重复,全部入队
ShouldBeUnique:排队 + 处理期间都防重复(最严格)ShouldBeUniqueUntilProcessing:只在排队期间防重复,处理一开始就放行
<?php
namespace App\Listeners;
use App\Events\LicenseSaved;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
class AcquireProductKey implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
public function __invoke(LicenseSaved $event): void
{
$key = KeyService::acquire($event->license->product_id);
$event->license->update(['product_key' => $key]);
}
}
唯一监听器锁
底层实现上,当 ShouldBeUnique 监听器被派发时,Laravel 会用 uniqueId 作为键去获取一个缓存锁。锁被占用就丢弃新任务,锁释放后才允许新的入队。
默认用默认缓存驱动获取锁。如果你的默认缓存是 file 或 database,但你想用 redis 来做唯一锁(性能更好),定义 uniqueVia() 方法:
信息
uniqueVia() 和 uniqueId() 是两回事:uniqueId 决定锁的 key(按什么区分唯一性),uniqueVia 决定用 哪个缓存驱动 存这把锁。如果你默认缓存已经是 Redis,uniqueVia() 就不需要写。
<?php
namespace App\Listeners;
use App\Events\LicenseSaved;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class AcquireProductKey implements ShouldQueue, ShouldBeUnique
{
public function __invoke(LicenseSaved $event): void
{
$key = KeyService::acquire($event->license->product_id);
$event->license->update(['product_key' => $key]);
}
/**
* 用 Redis 做唯一锁,而不是默认缓存驱动。
*/
public function uniqueVia(LicenseSaved $event): Repository
{
return Cache::driver('redis');
}
}
信息
你可能启动了多个队列 worker 并行处理任务(比如 php artisan queue:work --workers=5,5 个 worker 同时跑)。这时同一个监听器可能被两个 worker 同时拉取、同时执行。
比如用户快速点了两次「保存」,两个 AcquireProductKey 任务都进了队列,worker A 和 worker B 各拉一个同时执行——同一许可证获取了两次产品密钥。
ShouldBeUnique:第二个任务在派发时就被丢弃,根本不进队列WithoutOverlapping:两个任务都进队列,但 worker B 拉到后发现 worker A 正在处理同一个许可证,就等着 A 跑完再跑
如果你只是怕「同时执行出问题」而不是怕「重复排队」,用 WithoutOverlapping 中间件就够了。
处理失败任务
队列监听器可能因为各种原因失败——外部 API 宕机、数据库锁超时、网络超时等。当监听器超过队列 worker 配置的最大重试次数后,failed() 方法会被调用。你可以在里面做清理工作或通知运维:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Slack;
use Throwable;
class CreateShippingLabel implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
/**
* 所有重试都失败后调用。
*/
public function failed(OrderShipped $event, Throwable $exception): void
{
// 通知运维:运单创建彻底失败了
Slack::to('#ops')->send(
"⚠️ 订单 #{$event->order->id} 运单创建失败,需要人工处理\n".
"错误:{$exception->getMessage()}"
);
// 标记订单需要人工介入
$event->order->update(['label_status' => 'manual_required']);
}
}
指定最大重试次数
默认情况下队列 worker 会无限重试失败的任务。你肯定不想这样——物流 API 挂了,重试 100 次还是挂。用 Tries 属性指定最大重试次数:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\InteractsWithQueue;
#[Tries(5)] // 最多重试 5 次
class CreateShippingLabel implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
}
除了指定次数,你也可以指定一个截止时间——5 分钟内随便重试,超时就放弃。在监听器中添加 retryUntil() 方法:
use DateTimeInterface;
/**
* 5 分钟内可以任意重试,超时后标记失败。
*/
public function retryUntil(): DateTimeInterface
{
return now()->plus(minutes: 5);
}
如果同时定义了 retryUntil() 和 Tries,retryUntil() 优先。
指定退避时间
默认情况下,重试之间没有间隔。但如果你调的外部 API 限流了,立即重试只会再被拒一次。用 Backoff 属性让重试之间等几秒:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Backoff;
#[Backoff(3)] // 每次重试前等 3 秒
class CreateShippingLabel implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
}
如果需要根据事件数据决定退避时间,定义 backoff() 方法:
/**
* 高优先级订单快速重试,普通订单慢一点。
*/
public function backoff(OrderShipped $event): int
{
return $event->order->highPriority ? 1 : 3;
}
返回数组可以配置「指数退避」——第一次重试等 1 秒,第二次等 5 秒,第三次等 10 秒,之后每次都等 10 秒:
/**
* 指数退避:1s → 5s → 10s → 10s → ...
*
* @return list<int>
*/
public function backoff(OrderShipped $event): array
{
return [1, 5, 10];
}
指定最大异常次数
有时候你想允许很多次重试,但如果是因为未捕获异常(而非手动 release())触发的重试,达到一定次数就放弃。比如调物流 API,偶尔限流可以重试,但如果连续报错说明是真的挂了。用 Tries 和 MaxExceptions 组合:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\InteractsWithQueue;
#[Tries(25)] // 最多尝试 25 次(包括手动 release 的)
#[MaxExceptions(3)] // 但最多只允许 3 次未捕获异常
class CreateShippingLabel implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
}
上面的例子中,如果 API 限流,可以用 $this->release(30) 手动放回队列(不算异常)。但如果 API 返回 500 错误抛出异常,3 次后就标记失败。
指定超时时间
有些监听器可能卡死(比如等待一个永远不会返回的 HTTP 响应)。用 Timeout 属性限制最大执行时间,超时后 worker 进程会退出:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(120)] // 最多跑 120 秒
class CreateShippingLabel implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
}
默认超时后任务会被重新排队重试。如果你想超时后直接标记为失败,加上 FailOnTimeout 属性:
<?php
namespace App\Listeners;
use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\FailOnTimeout;
#[FailOnTimeout]
class CreateShippingLabel implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
$label = ShippingApi::createLabel($event->order);
$event->order->update(['label_url' => $label->url]);
}
}
派发事件
事件和监听器都准备好了,现在在业务代码中派发事件。调用事件类的静态 dispatch() 方法即可——这个方法由 Dispatchable trait 提供。传给 dispatch() 的参数会传给事件的构造函数:
<?php
namespace App\Http\Controllers;
use App\Events\OrderShipped;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class OrderShipmentController extends Controller
{
/**
* 处理订单发货。
*/
public function store(Request $request): RedirectResponse
{
$order = Order::findOrFail($request->order_id);
// 核心业务逻辑:更新订单状态为已发货
$order->update([
'status' => 'shipped',
'shipped_at' => now(),
]);
// 派发事件——后续的邮件通知、运单创建、积分发放都由监听器异步处理
OrderShipped::dispatch($order);
return redirect('/orders')->with('status', '订单已发货!');
}
}
如果你想有条件地派发事件,用 dispatchIf() 和 dispatchUnless():
// 只有国际订单才派发海关申报事件
CustomsDeclaration::dispatchIf($order->isInternational, $order);
// 除了测试订单外都派发
OrderShipped::dispatchUnless($order->isTest, $order);
信息
测试时,你可能想断言某些事件被派发了但不实际触发监听器。Laravel 的测试辅助方法让这变得很简单。
数据库事务后派发事件
前面在队列监听器与数据库事务中提到了监听器端的解决方案。如果你想在事件端就控制——确保事件在数据库事务提交后才派发——在事件类上实现 ShouldDispatchAfterCommit 接口:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderShipped implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(
public Order $order,
) {}
}
这样,即使你在事务中调用 OrderShipped::dispatch($order),事件也会等到事务提交后才真正派发。如果事务回滚了,事件会被丢弃——订单都没发货成功,通知就不该发出去。如果派发时没有进行中的事务,事件会立即派发。
延迟事件
有时候你需要在一段代码里创建多条关联记录,但不想每条记录创建后都立即触发事件。比如创建用户时同时创建关联的文章,你希望两个都创建好了再触发监听器。
用 Event::defer() 把事件派发延迟到闭包执行完毕后:
use App\Models\User;
use Illuminate\Support\Facades\Event;
Event::defer(function () {
// 创建用户——会触发 UserCreated 事件,但不会立即派发
$user = User::create(['name' => 'Victoria Otwell']);
// 创建关联文章——会触发 PostCreated 事件,也不会立即派发
$user->posts()->create(['title' => 'My first post!']);
// 闭包执行完毕后,UserCreated 和 PostCreated 才一起派发
});
这确保了监听器触发时,用户和文章都已经创建好了。如果闭包内抛出异常,延迟的事件不会被派发——不会出现用户创建了一半就触发监听器的情况。
如果只想延迟特定事件,传第二个参数指定:
Event::defer(function () {
$user = User::create(['name' => 'Victoria Otwell']);
$user->posts()->create(['title' => 'My first post!']);
}, ['eloquent.created: '.User::class]); // 只延迟 User 的 created 事件
事件订阅器
编写事件订阅器
一个用户在系统里会有多种事件:登录、注销、密码修改、邮箱验证……如果每种事件都单独建一个监听器类,文件会很多。事件订阅器让你把一个主题下的多个事件处理器集中到一个类里。
比如用户认证相关的事件,可以放在一个 UserEventSubscriber 里:
<?php
namespace App\Listeners;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
class UserEventSubscriber
{
/**
* 用户登录:记录登录日志。
*/
public function handleUserLogin(Login $event): void
{
Log::info('用户登录', [
'user_id' => $event->user->id,
'ip' => request()->ip(),
]);
}
/**
* 用户注销:清除会话数据。
*/
public function handleUserLogout(Logout $event): void
{
Log::info('用户注销', ['user_id' => $event->user->id]);
}
/**
* 注册订阅器监听的事件。
*/
public function subscribe(Dispatcher $events): void
{
$events->listen(
Login::class,
[UserEventSubscriber::class, 'handleUserLogin']
);
$events->listen(
Logout::class,
[UserEventSubscriber::class, 'handleUserLogout']
);
}
}
如果监听方法就定义在订阅器自身内部,subscribe() 方法也可以直接返回事件类到方法名的映射数组,Laravel 会自动推断订阅器类名:
/**
* 注册订阅器监听的事件。
*
* @return array<string, string>
*/
public function subscribe(Dispatcher $events): array
{
return [
Login::class => 'handleUserLogin',
Logout::class => 'handleUserLogout',
];
}
注册事件订阅器
编写好订阅器后,如果遵循 Laravel 的事件发现约定,订阅器中的处理方法会被自动注册。否则,用 Event 门面的 subscribe() 方法手动注册。通常在 AppServiceProvider 的 boot() 方法中执行:
<?php
namespace App\Providers;
use App\Listeners\UserEventSubscriber;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::subscribe(UserEventSubscriber::class);
}
}
测试
测试派发事件的代码时,你通常只想确认「事件被派发了」,而不想让监听器真正执行(发邮件、调 API 什么的)。监听器本身的逻辑可以单独测试——直接实例化监听器、调用 handle() 方法即可。
用 Event 门面的 fake() 方法可以阻止所有监听器执行,然后用 assertDispatched() 等方法断言事件是否被派发:
<?php
use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
test('订单发货时派发 OrderShipped 事件', function () {
Event::fake();
$order = Order::factory()->create();
// 执行发货操作
$order->update(['status' => 'shipped']);
OrderShipped::dispatch($order);
// 断言事件被派发了
Event::assertDispatched(OrderShipped::class);
// 断言事件被派发了一次
Event::assertDispatchedOnce(OrderShipped::class);
// 断言事件没有被派发
Event::assertNotDispatched(OrderFailedToShip::class);
// 断言没有任何事件被派发
Event::assertNothingDispatched();
});
<?php
namespace Tests\Feature;
use App\Events\OrderFailedToShip;
use App\Events\OrderShipped;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class OrderShipmentTest extends TestCase
{
public function test_订单发货时派发_order_shipped_事件(): void
{
Event::fake();
$order = Order::factory()->create();
$order->update(['status' => 'shipped']);
OrderShipped::dispatch($order);
Event::assertDispatched(OrderShipped::class);
Event::assertDispatchedOnce(OrderShipped::class);
Event::assertNotDispatched(OrderFailedToShip::class);
Event::assertNothingDispatched();
}
}
你可以给 assertDispatched() 传入闭包来断言被派发的事件携带了正确的数据:
Event::assertDispatched(function (OrderShipped $event) use ($order) {
// 确认派发的事件里装的是正确的订单
return $event->order->id === $order->id;
});
如果你只想断言某个监听器正确绑定了某个事件(不实际执行),用 assertListening():
Event::assertListening(
OrderShipped::class,
SendShipmentNotification::class
);
注意
调用 Event::fake() 后,所有事件监听器都不会执行——包括 Eloquent 模型事件。所以如果你的模型工厂依赖事件(比如在 creating 事件中生成 UUID),应该在使用工厂之后再调用 Event::fake():
模拟部分事件
Event::fake() 会模拟所有事件。如果你只想模拟特定事件,让其他事件照常触发(比如只断言 OrderCreated 被派发,但 OrderUpdated 仍正常执行监听器):
test('创建订单时派发 OrderCreated 事件', function () {
// 只模拟 OrderCreated,其他事件照常执行
Event::fake([OrderCreated::class]);
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
// 其他事件照常触发,监听器正常执行
$order->update(['status' => 'processing']);
});
public function test_创建订单时派发_order_created_事件(): void
{
Event::fake([OrderCreated::class]);
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
$order->update(['status' => 'processing']);
}
用 except() 反过来——模拟所有事件除了指定的几个:
Event::fake()->except([
OrderCreated::class, // OrderCreated 照常触发监听器
]);
作用域事件模拟
如果你的测试只有一部分需要模拟事件,其他部分需要真实事件,用 fakeFor() 创建一个作用域:
<?php
use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
test('创建订单后更新库存', function () {
// 只在这个闭包里模拟事件
$order = Event::fakeFor(function () {
$order = Order::factory()->create();
// 断言事件被派发
Event::assertDispatched(OrderCreated::class);
return $order;
});
// 闭包外面,事件照常触发,监听器正常执行
$order->update(['status' => 'processing']);
});
<?php
namespace Tests\Feature;
use App\Events\OrderCreated;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class OrderProcessingTest extends TestCase
{
public function test_创建订单后更新库存(): void
{
$order = Event::fakeFor(function () {
$order = Order::factory()->create();
Event::assertDispatched(OrderCreated::class);
return $order;
});
$order->update(['status' => 'processing']);
}
}