Events and Listeners — Building a Decoupled Domain

Events and Listeners — Building a Decoupled Domain

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 20 of 35 Level: Intermediate Prerequisites: Action Classes


What You’ll Learn

  • Why Events decouple your domain from its side effects
  • The anatomy of a well-defined Event class
  • Queued listeners for async side effects
  • Registering events and listeners in Laravel 11+
  • Real event patterns from production codebases

Why Events Exist

When an order is placed, your application might need to:

  • Send an order confirmation email
  • Notify the warehouse
  • Update inventory
  • Record a commission for a partner
  • Create a ledger entry

Without events, all of this sits inside CreateOrderAction. The action grows. Changing the email template means opening the action. Adding a new integration means touching a class that has nothing to do with that integration.

Events invert this. The action fires OrderPlaced. The email system, warehouse system, inventory system, and commission system each have their own listener. Adding a new integration means creating one new file — not modifying existing ones.

This is the Open/Closed Principle applied at the application level: open for extension, closed for modification.


The Anatomy of an Event

// app/Events/OrderPlaced.php
<?php

declare(strict_types=1);

namespace App\Events;

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

class OrderPlaced
{
    use Dispatchable;
    use SerializesModels;

    public function __construct(
        public readonly Order $order
    ) {}
}

Three things:

  • Dispatchable adds the static dispatch() method
  • SerializesModels safely serializes Eloquent models for queued listeners
  • The constructor is the event’s data — only carry what listeners need

Firing Events Correctly

Always fire events after a DB transaction commits. If a listener reads the data immediately and the transaction hasn’t committed yet (e.g., on a read replica), it might see stale data:

// app/Actions/CreateOrderAction.php
final class CreateOrderAction
{
    public function execute(User $user, OrderCustomerData $data): Order
    {
        // All writes inside the transaction
        $order = DB::transaction(function () use ($user, $data): Order {
            $order = Order::create([...]);
            $this->attachLineItems($order, $data);
            return $order;
        });

        // Fire AFTER the transaction — data is committed
        event(new OrderPlaced($order));

        return $order;
    }
}

The Anatomy of a Listener

// app/Listeners/SendOrderConfirmation.php
<?php

declare(strict_types=1);

namespace App\Listeners;

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

class SendOrderConfirmation implements ShouldQueue
{
    // Which queue to process this listener on
    public string $queue = 'notifications';

    // How many attempts before marking failed
    public int $tries = 3;

    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->email)
            ->send(new OrderConfirmationMail($event->order));
    }

    /**
     * Called when all retries are exhausted.
     */
    public function failed(OrderPlaced $event, Throwable $exception): void
    {
        logger()->error('Failed to send order confirmation after retries', [
            'order_id' => $event->order->id,
            'error'    => $exception->getMessage(),
        ]);
    }
}

ShouldQueue is the most important decision. Without it, the listener blocks the HTTP response while the email sends. With it, the listener runs on the queue — the response returns immediately.


One Event, Multiple Listeners

// EventServiceProvider or bootstrap/app.php (Laravel 11)
protected $listen = [
    OrderPlaced::class => [
        SendOrderConfirmation::class,       // email → queue: notifications
        NotifyWarehouseOfNewOrder::class,   // warehouse → queue: integrations
        UpdateInventoryForOrder::class,     // inventory → queue: default
        RecordPartnerCommission::class,     // commission → queue: default
    ],

    ServiceRequestRejected::class => [
        SendRejectionEmailToCustomer::class,
        NotifyAssignedMerchandiser::class,
        ClearServiceRequestCache::class,    // this one is NOT queued — cache clears are instant
    ],

    EnrolmentPaid::class => [
        GenerateInvoicePdf::class,
        SendEnrolmentConfirmation::class,
        UpdatePartnerLedger::class,
        SendCertificateWhenComplete::class,
    ],
];

Notice ClearServiceRequestCache doesn’t implement ShouldQueue. Cache invalidation should happen synchronously — you don’t want to return a response with stale data while the cache clear is queued.


Registering Events and Listeners

Laravel 11+ — Auto-discovery

Laravel 11 can discover listeners automatically using the #[ListensTo] attribute:

use Illuminate\Events\Attributes\AsEventListener;

#[AsEventListener]
class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void { ... }
}

Or use explicit discovery in bootstrap/app.php:

->withEvents(discover: [
    __DIR__ . '/../app/Listeners',
])

Laravel 10 and below — EventServiceProvider

// app/Providers/EventServiceProvider.php
class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        OrderPlaced::class => [
            SendOrderConfirmation::class,
            UpdateInventoryForOrder::class,
        ],
    ];
}

Real Events from These Projects

ServiceRequestRejected

From dp-sampling — when a service request is rejected, several teams need to know:

// Fired in RejectServiceRequest action, after DB commit
event(new ServiceRequestRejected($serviceRequest));

// Listeners:
class SendRejectionNotifications implements ShouldQueue
{
    public function handle(ServiceRequestRejected $event): void
    {
        // Notify the customer who submitted it
        $event->serviceRequest->requester->notify(
            new ServiceRequestRejectedNotification($event->serviceRequest)
        );

        // Notify any assigned merchandiser
        if ($event->serviceRequest->assignedTo) {
            $event->serviceRequest->assignedTo->notify(
                new AssignedRequestRejectedNotification($event->serviceRequest)
            );
        }
    }
}

DispatchCreated

event(new DispatchCreated($dispatch));

class SendDispatchCreatedNotifications implements ShouldQueue
{
    public function handle(DispatchCreated $event): void
    {
        // Email all drivers on the dispatch
        foreach ($event->dispatch->drivers as $driver) {
            $driver->notify(new DispatchAssignedNotification($event->dispatch));
        }
    }
}

Synchronous vs. Queued Listeners — Decision Guide

Implement ShouldQueue when…Don’t queue when…
Sending emails or notificationsClearing a cache key
Calling external APIsWriting a log entry
Generating files or PDFsUpdating a counter in Redis
Processing imagesAny operation < 10ms
Recording audit entries to a remote systemData you need immediately in the response

The rule: if the HTTP response can be correct without waiting for the listener, queue it.


Key Takeaways

  • Events decouple the action that causes something from the code that reacts to it. Adding a new reaction = new listener file, no changes to existing code.
  • Fire events after transaction commits — not inside them.
  • ShouldQueue on listeners means the HTTP response doesn’t wait. Use it for any listener that’s slower than 10ms.
  • Multiple listeners per event is normal and expected — it’s the whole point.
  • Cache invalidation listeners should NOT be queued — you need the cache cleared before the next request comes in.


Frequently Asked Questions

Should Listeners be synchronous or queued? Queue by default for anything that hits an external service (email, SMS, Slack, webhooks) or takes more than 50ms. Keep synchronous only if the Listener’s result must be available in the same request — which is rare. The cost of accidentally running a slow synchronous Listener is a degraded user experience; the cost of unnecessarily queueing it is minimal.

What’s the difference between ShouldQueue on a Listener vs dispatching a Job from a Listener? Both run on a queue worker. Use ShouldQueue directly on the Listener when the Listener is simple (send one email, update one record). Dispatch a Job from a Listener when the work is complex enough that it deserves its own class with retry configuration, batching, or chaining.

Can one Event have multiple Listeners? Yes, that’s the whole point. OrderPlaced can have SendOrderConfirmationEmailNotifyAdminOfNewOrderUpdateInventory, and CreateInitialInvoice as independent Listeners. They all register in EventServiceProviderand fire in order (or concurrently if queued). Adding a new side effect never requires touching the Action that fired the event.


Tips and Gotchas

⚠️ Warning: ShouldQueue on a listener requires the queue to be running. In staging environments where queues aren’t running, queued listeners silently do nothing — emails aren’t sent, inventories aren’t updated. Always confirm the queue worker is running when testing listener behavior.

💡 Tip: Fire one event per business outcome, not one per model change. OrderPlaced is a business event. OrderUpdated is a Eloquent event. The difference: multiple listeners can react to OrderPlaced independently; OrderUpdated is too broad to be useful to any one listener.

🔥 Expert Note: Use ->afterCommit() on the listener class (not just DB::afterCommit() in the event) for listeners that read data written in the same transaction. Laravel 8+ supports public $afterCommit = true; on ShouldQueuelisteners.

Further Reading


← Structured Logging | Next: Status Page →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.