A queued listener that fires before the transaction commits will read a row that does not exist yet, throw, retry three times, and land in failed_jobs. The user's order was saved, the confirmation email never sent, and nothing in the request logs points at the listener. This guide covers how Laravel events and listeners dispatch, where sync and queued listeners diverge, and how to pick between them without guessing.
If a listener reads or writes data that a surrounding database transaction controls, it must be queued with afterCommit or dispatched inside DB::afterCommit(). Synchronous listeners run inside the transaction and see uncommitted state.
How Laravel events and listeners dispatch
event(new OrderPlaced($order)) hands the object to the Dispatcher bound in the service container. The dispatcher resolves the event name, looks up every registered listener for that name, and calls them in registration order. There is no priority system and no built-in dependency ordering.
Each listener is a class with a handle method. If the listener implements ShouldQueue, the dispatcher serializes the listener and the event payload onto the queue connection instead of calling handle in the current process. Everything else runs inline, in the same request, on the same database connection.
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Jobs\SendOrderConfirmation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
class SendOrderReceipt implements ShouldQueue, ShouldQueueAfterCommit
{
public int $tries = 3;
public int $backoff = 10;
public function handle(OrderPlaced $event): void
{
SendOrderConfirmation::dispatch($event->order)->onQueue('mail');
}
public function failed(OrderPlaced $event, \Throwable $e): void
{
report($e);
}
}
ShouldQueueAfterCommit is the interface to reach for when a listener touches the same tables the request just wrote. It defers the push onto the queue until the active transaction commits, so the worker never races the writer. Laravel's documentation on events and listeners covers the registration mechanics; the delivery timing is what bites in production.
Sync or queued: the trade-off with numbers attached
A synchronous listener adds its full duration to the HTTP response. Ten listeners at 15 ms each is 150 ms of latency on every request that fires the event, and one of them doing an HTTP call to a mail provider can push a 90 ms endpoint past a second. Queued listeners move that cost to a worker, but you now own retries, ordering, and the failure path.
| Property | Sync listener | Queued listener |
|---|---|---|
| Runs in | Request process | Worker process |
| Sees uncommitted writes | Yes | No, if deferred with afterCommit |
| Failure effect | Rolls back the request | Retries, then failed_jobs |
| Ordering guarantee | Registration order | Per queue, per worker |
| Typical latency cost | Sum of all listeners | Serialization only, roughly 1 to 3 ms |
The recommendation: keep a listener synchronous only when it is pure computation on the event payload, or when the caller genuinely cannot proceed without the result. Anything that touches the network, sends mail, writes to a third-party API, or updates a denormalized table belongs on a queue.
You can verify the split without reading code. Run php artisan event:list and look at the listener column. Every entry without a queue marker runs inside your request. If a slow endpoint fires an event, that list is the first place to check.
Registering listeners without losing track of them
Laravel discovers listeners in app/Listeners by default through EventServiceProvider reflection. That laravel event discovery is convenient until two listeners handle the same event and nobody remembers the second one exists. For anything beyond a handful of events, explicit registration is easier to audit.
<?php
namespace App\Providers;
use App\Events\OrderPlaced;
use App\Listeners\SendOrderReceipt;
use App\Listeners\UpdateInventorySnapshot;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
OrderPlaced::class => [
SendOrderReceipt::class,
UpdateInventorySnapshot::class,
],
];
}
When a group of listeners always fires together for a domain concept, a laravel event subscriber packages them into one class with a subscribe method. That keeps the mapping in one file and makes it obvious in review that adding an order event also adds a listener.
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Events\OrderRefunded;
use Illuminate\Events\Dispatcher;
class OrderEventSubscriber
{
public function handlePlaced(OrderPlaced $event): void {}
public function handleRefunded(OrderRefunded $event): void {}
public function subscribe(Dispatcher $events): void
{
$events->listen(OrderPlaced::class, [self::class, 'handlePlaced']);
$events->listen(OrderRefunded::class, [self::class, 'handleRefunded']);
}
}
Subscribers are registered the same way as listeners in $subscribe on the provider. If you cache configuration in production with php artisan event:cache, remember to re-run it on deploy; a stale cache is a common reason a newly registered listener appears to do nothing.
Failure modes worth designing around
The listener that reads before the commit
This is the failure described at the top. The request wraps order creation in a transaction, dispatches OrderPlaced, and a queued listener picks the job up on a worker before the transaction commits. The worker's separate connection cannot see the row. Fix it with ShouldQueueAfterCommit or by moving the dispatch into DB::afterCommit(). Do not fix it by adding a sleep().
Duplicate side effects on retry
A queued listener that charges a card and then throws will retry and charge again. Laravel gives you $tries and $backoff, but idempotency is your job: pass a deterministic key to the external API, or check a processed_events table keyed by event ID before acting. The same reasoning applies to any listener that sends email, and it is the same class of problem covered in Laravel queue best practices.
Ordering across listeners
Two queued listeners for the same event can run in either order if they land on different queues with different worker counts. If listener B depends on listener A's write, chain them: have A dispatch a follow-up job, or merge them into one listener that calls two services in sequence. Relying on registration order only works for synchronous listeners.
Events fired inside loops
Dispatching an event per row inside a 5,000-row import creates 5,000 jobs, 5,000 serializations, and 5,000 queue round trips. Batch at the boundary: collect the affected IDs and dispatch one event carrying the array. This is also where a listener touching related models can quietly reintroduce the N+1 pattern, which the N+1 guide covers in detail.
How to verify delivery in a real app
- Run
php artisan event:listand confirm each listener shows the queue it targets. - Fire the event in
php artisan tinkerwith a real payload and watch the worker log for the job class. - Force a throw inside the listener, confirm the job retries the configured number of times, then confirm the row lands in
failed_jobs. - Check that the row the listener reads exists at execution time, not at dispatch time.
If step four fails, the listener is running too early. That single check catches the majority of event bugs in transactional Laravel code.
FAQ
Should every listener implement ShouldQueue?
No. Listeners that only transform the event payload or update in-memory state should stay synchronous, because queueing them adds serialization cost and a failure path with no benefit. Queue anything with I/O.
Does ShouldQueueAfterCommit work outside a transaction?
Yes. When no transaction is active, the listener is pushed immediately, so the interface is safe to apply unconditionally.
Can a listener dispatch another event?
It can, and Laravel will resolve it normally. Keep chains shallow. A listener that dispatches an event that dispatches an event is hard to trace in logs and easy to turn into a loop.
