Events & Listeners

Laravel Intermediate/Advanced
Course 2 ยท Chapter 4 ยท Events & Listeners

๐Ÿ“ก Events & Listeners

Same decoupling goal as Django's signals โ€” code reacts to something happening without the triggering code needing to know who's listening โ€” expressed differently. Laravel uses explicit Event and Listener classes instead of a @receiver-decorated function, and (in modern Laravel) auto-discovers listeners with no manual wiring step at all.

Creating an Event and a Listener

php artisan make:event OrderPlaced
php artisan make:listener SendOrderConfirmation --event=OrderPlaced
// app/Events/OrderPlaced.php
namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;

class OrderPlaced
{
    use Dispatchable;

    public function __construct(
        public Order $order  // a plain, typed data-carrying property
    ) {}
}

Compare this to Django's post_save receiver, which gets loose sender/instance/created keyword arguments โ€” Laravel's event is a genuine typed object, with exactly the data it declares and nothing implicit:

// app/Listeners/SendOrderConfirmation.php
namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->customer_email)->send(
            new \App\Mail\OrderConfirmation($event->order)
        );
    }
}

๐Ÿš€ Dispatching: Application-Triggered, Not Automatic

Here's a real difference from Django's post_save: a Laravel event fires only where you explicitly dispatch it โ€” not automatically on every model save:

// app/Http/Controllers/OrderController.php
use App\Events\OrderPlaced;

public function store(StoreOrderRequest $request)
{
    $order = Order::create($request->validated());

    OrderPlaced::dispatch($order);  // fires exactly here, and nowhere else

    return redirect()->route('orders.show', $order);
}

Django's Automatic Signal vs Laravel's Manual Dispatch

Django
@receiver(post_save, sender=Order)
def on_order_saved(sender, instance, created, **kwargs):
    if created:
        # fires automatically, EVERY time an Order is saved โ€”
        # including from the admin, a script, a migration...
Laravel
OrderPlaced::dispatch($order);
// fires ONLY at this exact line โ€” a script or Tinker
// session creating an Order directly never triggers it

Laravel does have model events (creating, created, saving, saved) that fire automatically on Eloquent lifecycle actions โ€” those are the closer parallel to post_save, and carry the exact same "fires on every save, no exceptions" trade-off Django's signals do (see this chapter's gotcha).

Registering Listeners: Auto-Discovery

Modern Laravel auto-discovers listeners in app/Listeners โ€” no manual registration step, unlike Django's apps.py ready() import requirement from the Django course:

Auto-Discovery (Laravel 11+)

A listener's handle() method type-hint tells Laravel which event it's for โ€” placing the class in app/Listeners is enough; nothing extra to wire up.

Manual Registration (Older Laravel)

Pre-11 Laravel required listing the event/listener pair in EventServiceProvider's $listen array โ€” the same "forgot to register it" trap as Django's apps.py gotcha.

โณ Queued Listeners

The cleanest built-in integration point in this course so far โ€” implementing one interface moves a listener onto the queue automatically:

Making a Listener Non-Blocking

namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->customer_email)->send(
            new \App\Mail\OrderConfirmation($event->order)
        );
    }
}
// implements ShouldQueue โ€” that's the ENTIRE change. No .delay() call,
// no separate task file โ€” the SAME class runs on the queue instead of
// synchronously, unlike the Django course's Celery chapter, which required
// the signal receiver to explicitly call a separate task's .delay() method.

๐Ÿ’ป Coding Challenges

Challenge 1: Create an Event and Listener Pair

Generate a UserRegistered event carrying a User, and a SendWelcomeEmail listener that sends a welcome email, then dispatch it from a registration controller.

Goal: Practice the full generate-event, generate-listener, dispatch workflow.

โ†’ Solution

Challenge 2: Make a Listener Queued

Take the SendWelcomeEmail listener from Challenge 1 and make it non-blocking by implementing ShouldQueue, and explain in one sentence what changes about when the email actually gets sent.

Goal: Practice the one-interface change that moves a listener onto the queue.

โ†’ Solution

Challenge 3: Spot the Model Event Trap

Given an Eloquent model with a static::created() model event hook that sends a real welcome email, explain what happens when a test suite or a database seeder creates instances of that model, and propose a fix.

Goal: Practice recognizing the gotcha around automatic Eloquent model events firing in unintended contexts.

โ†’ Solution

โš ๏ธ Gotcha: Eloquent Model Events Fire on Every Save โ€” Seeders Included

A model event hook (static::created(), or a creating/saving listener) fires on every save that model ever goes through โ€” a factory in a test, a php artisan db:seed run populating fake data, a Tinker session, all trigger it exactly the same as a real user action would. This is the same trade-off Django's post_save signal carries, and it's a real risk: a model event that sends an actual email or charges a payment can fire unexpectedly while seeding a development database. A manually-dispatched custom event (OrderPlaced::dispatch($order)) doesn't have this problem โ€” it only ever fires exactly where you call it โ€” which is one real argument for preferring explicit dispatch over an automatic model event for anything with a real-world side effect.

๐ŸŽฏ What's Next

Queued listeners got a preview of running work outside the request cycle โ€” the next chapter goes deeper: Queues, Laravel's queue system in full, compared to the Django course's Celery chapter.