Skip to main content
David Dew Mallick

David Dew Mallick

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

Laravel dispatchSync vs dispatch: Sync vs Queued Events and Listeners

How Laravel dispatchSync, dispatch_sync, and dispatch() differ, how sync vs queued event listeners actually deliver, and when afterCommit prevents lost work.

Sep 27, 2026 · 6 min read · Updated Oct 21, 2026

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 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, how dispatchSync and dispatch differ, 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;

class SendOrderReceipt implements ShouldQueue
{
    public $afterCommit = true;

    public $tries = 3;
    public $backoff = 10;

    public function handle(OrderPlaced $event): void
    {
        SendOrderConfirmation::dispatch($event->order)->onQueue('mail');
    }

    public function failed(OrderPlaced $event, \Throwable $e): void
    {
        report($e);
    }
}

Setting $afterCommit to true is the property 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.

Using dispatchSync, dispatch_sync, and dispatch() in Laravel

Events are not the only place where sync versus queued matters. When you dispatch a single job, the same choice appears as dispatch() versus dispatchSync(). The names are similar, so the difference is worth pinning down before you reach for one.

CallRunsReturnsUse when
dispatch($job)Queued; picked up by a workerA pending dispatch objectWork can happen after the response
$job->dispatchSync()Immediately, in the current processThe job's handle resultYou need the result or the work must finish before the response
dispatch_sync($job)Same as dispatchSync(), for a job class name or instanceThe job's handle resultExisting code that uses the helper form
<?php

use App\Jobs\SendOrderConfirmation;

// Queued: the worker handles it after the request
SendOrderConfirmation::dispatch($order);

// Immediate: runs now, in this process, on this connection
dispatch_sync(new SendOrderConfirmation($order));

// Also immediate:
(new SendOrderConfirmation($order))->dispatchSync();

dispatchSync runs the job inside the caller's process, on the same database connection, which is exactly the pre-commit read problem from this article's intro. If the job reads a row written by an uncommitted transaction, it will see it if it is on the same connection and miss it if not. For background work after a commit, prefer dispatch() with ->afterCommit(); for work whose result the caller needs, dispatchSync is correct.

The trade-off is the same one covered in the next section for event listeners: sync execution keeps the result close but adds the full duration to the response and any failure lands on the caller.

Sync or queued: the trade-off

A synchronous listener adds its full duration to the HTTP response. Each listener that does real work adds its own latency, and one of them doing an HTTP call to a mail provider can push a fast 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 and a queue push

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 to see every registered event and its listeners, then check each listener's class definition for ShouldQueue. If a slow endpoint fires an event, that list is the first place to check.

Registering listeners without losing track of them

Laravel can discover listeners in app/Listeners through EventServiceProvider, depending on the version and whether discovery is enabled. 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 the $afterCommit property on the listener 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 a naive retry charges again. Laravel gives you $tries and $backoff, but idempotency is your job: pass a deterministic key (order ID plus event ID) 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 large import creates one job, one serialization, and one queue round trip per row. 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 the listener you expect is registered.
  2. Start php artisan queue:work in a separate terminal, 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 the afterCommit property work outside a transaction?

Yes. When no transaction is active, the listener is pushed immediately, so the property 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.

When should I use dispatchSync instead of dispatch?

Use dispatchSync() when the caller needs the result of the work before the response, or when the work must run on the same connection as the caller. Use dispatch() for anything that can finish after the response, and add ->afterCommit() when the job reads data written by the request transaction.

DD

David Dew Mallick

Intermediate Software Engineer

GitHubLinkedInEmail

More posts

  • Oct 28, 2026 · 8 min read

    Database Sharding: When One Postgres Stops Working

    A decision guide for when database sharding pays off, the failure modes that push you to it, and how to shard without breaking SQL joins and transactions.

  • Oct 28, 2026 · 7 min read

    Database Best Practices: Primary Keys That Scale

    Pick integer, UUID, or UUIDv7 primary keys using index size and write-rate thresholds, plus a reproducible test that shows how random UUIDs bloat B-trees.

On this page

  • How Laravel events and listeners dispatch
  • Using dispatchSync, dispatch_sync, and dispatch() in Laravel
  • Sync or queued: the trade-off
  • Registering listeners without losing track of them
  • Failure modes worth designing around
  • The listener that reads before the commit
  • Duplicate side effects on retry
  • Ordering across listeners
  • Events fired inside loops
  • How to verify delivery in a real app
  • FAQ
  • Should every listener implement ShouldQueue?
  • Does the afterCommit property work outside a transaction?
  • Can a listener dispatch another event?
  • When should I use dispatchSync instead of dispatch?
Back to top

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

  • Privacy
  • Terms
  • Cookies