Events and Listeners — Building a Decoupled Domain
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:
Dispatchableadds the staticdispatch()methodSerializesModelssafely 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 notifications | Clearing a cache key |
| Calling external APIs | Writing a log entry |
| Generating files or PDFs | Updating a counter in Redis |
| Processing images | Any operation < 10ms |
| Recording audit entries to a remote system | Data 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.
ShouldQueueon 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 SendOrderConfirmationEmail, NotifyAdminOfNewOrder, UpdateInventory, 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:
ShouldQueueon 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.
OrderPlacedis a business event.OrderUpdatedis a Eloquent event. The difference: multiple listeners can react toOrderPlacedindependently;OrderUpdatedis too broad to be useful to any one listener.
🔥 Expert Note: Use
->afterCommit()on the listener class (not justDB::afterCommit()in the event) for listeners that read data written in the same transaction. Laravel 8+ supportspublic $afterCommit = true;onShouldQueuelisteners.