Events, listeners and notifications

Domain events decoupling a workflow, model observers, queued listeners, and notifications across mail, database and broadcast channels.

Events and listeners

class OrderPlaced
{
    use Dispatchable, SerializesModels;
    public function __construct(public Order $order) {}
}

class ReserveStock implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        $event->order->items->each(fn ($item) => $item->reserve());
    }
}

// dispatch after the transaction commits
DB::transaction(function () use ($order) {
    $order->save();
    OrderPlaced::dispatch($order)->afterCommit();
});
  • Queued listeners run outside the request, so dispatch afterCommit() - otherwise a listener can act on a row that a later rollback erases.
  • Auto-discovery scans app/Listeners; register explicit mappings in AppServiceProvider when the wiring is not obvious.
  • An event with several listeners is a fan-out, not a workflow. If the steps must be ordered, use a chain of jobs instead.
💡
Model observers fire on every save, including bulk operations through Eloquent. They are convenient for audit fields, and dangerous for anything with side effects that a mass update would multiply by a thousand rows.

Observers

class BookObserver
{
    public function creating(Book $book): void
    {
        $book->slug ??= Str::slug($book->title);
        $book->created_by ??= auth()->id();
    }

    public function updated(Book $book): void
    {
        if ($book->wasChanged('status')) {
            Cache::forget("book:{$book->id}");
        }
    }

    public function deleted(Book $book): void
    {
        $book->cover?->delete();
    }
}

#[ObservedBy(BookObserver::class)]
class Book extends Model {}

// bulk updates bypass observers entirely
Book::where('status', 'draft')->update(['status' => 'archived']);
HookFiresTypical use
creating / createdBefore/after the insertDefaults, slugs, external ids
updating / updatedAround an updateCache invalidation, audit trail
deleting / deletedAround a deleteCleaning up files and children
restoredAfter a soft-delete restoreRe-indexing, notifications

Notifications and broadcasts

class OrderShipped extends Notification implements ShouldQueue
{
    public function __construct(public Order $order) {}

    public function via(object $notifiable): array
    {
        return $notifiable->wantsSms()
            ? ['mail', 'database', 'vonage']
            : ['mail', 'database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Your order shipped')
            ->line("Tracking number: {$this->order->tracking}")
            ->action('Track it', url("/orders/{$this->order->id}"));
    }

    public function toDatabase(object $notifiable): array
    {
        return ['order_id' => $this->order->id, 'status' => 'shipped'];
    }
}

$user->notify(new OrderShipped($order));
Notification::send($users, new OrderShipped($order));
  • The database channel needs the notifications table: php artisan make:notifications-table then migrate.
  • Broadcast channels need driver configuration plus authentication on your private channels; publish to PrivateChannel and authorize it in routes/channels.php.
  • Queue notifications (ShouldQueue) or the mail driver becomes part of the request latency.

FAQ

Events or direct method calls?
Direct calls when the caller genuinely owns the next step. Events when several unrelated concerns react to a fact - and be honest that the indirection costs discoverability. One level of events with a small number of listeners is usually the right amount.
Why did my listener not run?
Check that it is queued and a worker is running, that it fired inside a transaction with an afterCommit dispatch, and that the class is discovered in app/Listeners. Then run php artisan event:list to see what the application actually registered.

Queues, jobs and scheduled tasks The service container, providers and facades

Last refreshed 2026-09-18.