Keeping Events Understandable
Name events after facts, stop listeners dispatching more events, and trace a chain the call site never shows.
php artisan event:listEvents are easy to write and hard to live with for one reason: the chain is invisible at the call site. Nothing in OrderShipped::dispatch($order) tells you that four listeners run. Three habits keep that readable.
Name the fact, not the follow-up
Name an event after what happened, in the past tense: OrderShipped, InvoicePaid, SubscriptionCancelled. SendShipmentEmail is a listener name. As an event name it claims there is one consequence, and the day a second one appears the name is wrong. Keep the event a data container with no logic in it.
Do not dispatch events from listeners
A listener that dispatches another event starts a chain no single file records, and tracing it means opening every listener in turn. If two things must happen after one fact, give that one event two listeners. They are independent, and the order between them is not guaranteed.
Make the chain visible
php artisan event:list prints every registered event with its listeners. Because discovery is implicit, that command is the only reliable answer to what runs when something fires. Run php artisan event:cache on deploy, and php artisan event:clear when a new listener does not fire locally. A queued listener's failure surfaces nowhere near the dispatch site: it lands in the failed jobs table instead.
Example
When to use it
- You inherit an application where saving an order sends three emails and you need to find out which code sends which.
- A new listener works in production but not locally, and the cached event manifest is the reason.
- A reviewer asks why the controller dispatches an event instead of calling two services, and the answer has to hold for the next consequence too.
- A listener runs before the related rows it needs exist, because the dispatch happened mid-transaction.
More examples
The name decides how many consequences are allowed
Use the past-tense name even when there is exactly one listener today. Renaming an event later means touching every dispatch site, every listener type hint and, if it broadcasts, the JavaScript listening for it.
// Named after the follow-up: only one consequence can ever be true,
// and the dispatch site now reads like a command, not a fact.
class SendShipmentEmail
{
public function __construct(public Order $order) {}
}
// Named after the fact: any number of listeners may care, and the
// dispatch site stays honest when a second one is added.
class OrderShipped
{
public function __construct(public Order $order) {}
}
// Listeners carry the verbs.
// app/Listeners/SendShipmentNotification.php
// app/Listeners/NotifyWarehouse.phpKeep chains one level deep
The test is whether a reader can answer what runs on OrderPlaced from event:list alone. Each extra hop moves part of the answer into a file they have to guess at.
// Hard to trace: nothing records that OrderPlaced reaches SendReceipt.
class ChargeCard
{
public function handle(OrderPlaced $event): void
{
$this->gateway->charge($event->order);
CardCharged::dispatch($event->order); // starts a second hop
}
}
// Easier to trace: one fact, two independent listeners.
// app/Listeners/ChargeCard.php handle(OrderPlaced $event)
// app/Listeners/ReserveStock.php handle(OrderPlaced $event)
//
// A second event is justified when it is a genuinely new fact that
// code outside this flow also reacts to - then dispatch it from the
// service that performed the charge, not from the listener.Dispatch when the data is actually there
Reach for these when a queued listener fails with a missing model or reads stale values. The worker can pick the job up before your transaction commits, and both switches move the dispatch to a point where the data is visible.
use App\Models\User;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Support\Facades\Event;
// Hold events raised inside the closure until it finishes, so listeners
// see the related rows too. An exception inside discards them.
Event::defer(function () use ($data): void {
$user = User::create($data['user']);
$user->posts()->create($data['post']);
});
// Per event class: do not dispatch until the open transaction commits.
// If the transaction rolls back, the event is discarded.
class OrderShipped implements ShouldDispatchAfterCommit
{
// ...
}
Discussion