Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
laravel queued listenerslaravel event subscriberslaravel aftercommit eventslaravel event discovery

Laravel Events and Listeners: Sync vs Queued

How Laravel events and listeners actually dispatch, when queued listeners silently break, and how to choose between sync, queued, and afterCommit delivery.

Sep 27, 2026 · 6 min read

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.

The one rule that prevents most event bugs

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.

PropertySync listenerQueued listener
Runs inRequest processWorker process
Sees uncommitted writesYesNo, if deferred with afterCommit
Failure effectRolls back the requestRetries, then failed_jobs
Ordering guaranteeRegistration orderPer queue, per worker
Typical latency costSum of all listenersSerialization 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

  1. Run php artisan event:list and confirm each listener shows the queue it targets.
  2. Fire the event in php artisan tinker with a real payload and watch the worker log for the job class.
  3. Force a throw inside the listener, confirm the job retries the configured number of times, then confirm the row lands in failed_jobs.
  4. 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.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Sep 26, 2026 · 6 min read

    Eloquent Relationships: Eager Loading Deep Dive

    How Laravel eager loading actually works, when with() stops helping, and how to detect and fix relationship queries that quietly multiply per row.

  • Sep 22, 2026 · 8 min read

    Laravel Middleware: A Practical Guide to Request Flow

    Laravel middleware controls every request your app handles. Learn to write, register, and order middleware for auth, throttling, and request mutation.

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick