Skip to content
Tech

Laravel Events, Listeners and Observers: Taming Side Effects in Controllers

Using events, listeners and model observers to move side effects like notifications and cache clearing out of controllers and into named classes.

Laravel Events, Listeners and Observers: Taming Side Effects in Controllers
Laravel Events, Listeners and Observers: Taming Side Effects in Controllers
In this article

    A controller that creates an order, sends a confirmation email, updates inventory, notifies a webhook, and clears three caches is doing five jobs at once. None of that extra work is really about handling the HTTP request — it's a reaction to something that happened. Laravel's event system exists to name that reaction and move it somewhere else.

    The usual path toward this mess is gradual. The first side effect goes straight into the controller because it's one line and adding a whole event class feels like overkill. The second and third follow the same reasoning, and a year later the controller is forty lines long, half of them unrelated to actually creating an order, and nobody wants to touch it without re-reading the whole method first.

    What Belongs in an Event

    An event should represent a fact: something happened, past tense. OrderPlaced, not PlaceOrder — the naming difference signals that the event doesn't decide anything, it just announces.

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

    Listeners Do One Thing Each

    Rather than one giant handler, each side effect gets its own listener. This makes each piece independently testable and lets you queue the slow ones without touching the fast ones.

    class SendOrderConfirmation implements ShouldQueue
    {
        public function handle(OrderPlaced $event): void
        {
            Mail::to($event->order->customer)->send(new OrderConfirmationMail($event->order));
        }
    }
    class DecrementInventory
    {
        public function handle(OrderPlaced $event): void
        {
            foreach ($event->order->items as $item) {
                $item->product->decrement('stock', $item->quantity);
            }
        }
    }

    Notice DecrementInventory doesn't implement ShouldQueue — stock needs to update immediately, while sending an email can safely happen a second later on a queue.

    Wiring It Together

    Event::listen(OrderPlaced::class, [
        SendOrderConfirmation::class,
        DecrementInventory::class,
        NotifyFulfillmentWebhook::class,
    ]);

    The controller itself shrinks to almost nothing:

    $order = $this->orders->create($validated);
    
    event(new OrderPlaced($order));
    
    return new OrderResource($order);
    A controller that only orchestrates — validate, persist, announce — is one you can read in ten seconds and trust without tracing five side effects.

    Observers for Model-Level Reactions

    Events are best for business-meaningful occurrences; observers are better suited to reactions tied directly to a model's lifecycle, regardless of which code path triggered it.

    class ProductObserver
    {
        public function updated(Product $product): void
        {
            if ($product->wasChanged('price')) {
                Cache::tags(['product:'.$product->id])->flush();
            }
        }
    }

    Register it once in a service provider's boot() method, and every save — whether from a controller, a console command, or a queued job — triggers the same cache clear automatically. That consistency is the entire point: nobody has to remember to call it manually from every place a product gets updated.

    Tip: Observers are easy to overuse for business logic that really belongs in a service class. If an observer method is doing more than a couple of lines of clearly infrastructural work — cache clearing, search index syncing — consider whether it should be an explicit event instead, so the behavior is visible from the code that triggers it.

    When Not to Reach for Events

    • If a side effect must happen synchronously and the caller needs to know if it failed, a direct method call with proper exception handling is more honest than an event nobody's watching for failures.
    • Don't use events to avoid passing a dependency cleanly — if a listener needs five things from the event payload, that's a sign the operation should be a single service method instead.
    • Avoid event chains where one event's listener fires another event — they're notoriously hard to trace when something goes wrong three events deep.

    Events, listeners and observers don't reduce the amount of code in an application — they redistribute it into pieces with names, each doing one job. That's a trade worth making the moment a single action in your app needs to cause more than one or two things to happen.

    Written by Rajasekar

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.