skip to content

In Laravel, an OrderPlaced event dispatched inside DB::transaction reaches a queued listener that cannot find the order; why, and how do ShouldDispatchAfterCommit and ShouldQueueAfterCommit fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. the worker races the commit
  2. uncommitted rows are invisible elsewhere
  3. rollback leaves the side effects behind
  4. the event interface defers every listener
  5. the listener interface defers one job

basics

~20 s

The listener's job is pushed while the transaction is still open, so a worker can run before commit and not see the row. ShouldDispatchAfterCommit on the event holds all listeners until commit; ShouldQueueAfterCommit on one listener delays only its job.

solid answer

~40 s

`OrderPlaced::dispatch($order)` inside `DB::transaction()` normally invokes listeners at once, so a queued listener's job reaches the queue before `COMMIT`. A fast worker restores the order through `SerializesModels`, the uncommitted row is invisible to its connection, and the restore throws `ModelNotFoundException`; if the transaction rolls back, synchronous listeners have already sent mail for an order that never existed. Implementing `ShouldDispatchAfterCommit` on the **event** makes the dispatcher register a callback with the transaction manager: every listener runs only after the outermost transaction commits, nothing runs on rollback, and outside a transaction the event fires immediately. Implementing `ShouldQueueAfterCommit` on a single **queued listener** delays only that listener's job, while synchronous listeners still run inside the transaction. A `delay` is not a fix; it only narrows the race.

code

php · 15 lines
php
<?php

namespace App\Events;

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

class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable, SerializesModels;

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

go deeper

for a junior

Recall that events fired inside a transaction reach their listeners before the commit unless you ask Laravel to wait.

for a middle

Explain why a worker cannot see uncommitted rows and how SerializesModels turns that into ModelNotFoundException.

for a senior

Choose between deferring the whole event and deferring one listener, handle rollback correctly, and explain why delays are not a fix.

for a principal

Set a team rule for domain events raised inside transactions, weighing consistency against losing the ability to roll back on a listener failure.

## The failure A checkout typically writes the order, its lines and a stock movement in one transaction and announces the result: ```php DB::transaction(function () use ($cart) { $order = Order::create([...]); // ... order lines, payment record OrderPlaced::dispatch($order); }); ``` By default `dispatch()` calls the listeners **immediately**, while the transaction is still open. For a queued listener that means the `CallQueuedListener` job is written to the queue before `COMMIT`. Two things can then go wrong: 1. **The worker is faster than the commit.** It deserializes the event, and `SerializesModels` re-queries the order by key. The worker uses a different database connection, which cannot see uncommitted rows, so the restore throws `ModelNotFoundException` and the job fails. 2. **The transaction rolls back.** A payment error after the dispatch undoes the order, but the job is already queued and synchronous listeners have already run. The customer gets a confirmation email for an order that does not exist. The first case is intermittent: it depends on how busy the workers are, which is why it tends to appear only in production. ## Fix 1: defer the whole event Implementing `Illuminate\Contracts\Events\ShouldDispatchAfterCommit` on the event class changes the dispatcher's behaviour for that event: - if a transaction is open, `dispatch()` registers a callback with the **database transactions manager** and returns without calling any listener - the callback runs when the **outermost** transaction commits, so nested `DB::transaction()` calls do not release it early - on rollback the callback is discarded, so no listener runs at all - with no transaction open, the event is dispatched immediately, exactly as before Because the gate sits in front of the listeners, it applies to synchronous and queued listeners alike. ## Fix 2: defer one queued listener Implementing `Illuminate\Contracts\Queue\ShouldQueueAfterCommit` on a listener marks its job as after-commit. The interface extends `ShouldQueue`, so it also makes the listener queued. The listener is invoked during the transaction as usual, but its job is only pushed once the transaction commits and is dropped on rollback. Synchronous listeners of the same event are unaffected and still run inside the transaction. ## Choosing between them | Mechanism | Declared on | Affects | Sync listeners | |---|---|---|---| | `ShouldDispatchAfterCommit` | the event | every listener of that event | deferred too | | `ShouldQueueAfterCommit` | one listener | that listener's queued job | not affected | | queue connection `after_commit` option | the connection config | all queued work on it | not affected | | a `delay` on the listener | the listener | nothing reliably | not affected | Guidance for the order fan-out: - put `ShouldDispatchAfterCommit` on `OrderPlaced` when **no** reaction should happen for an order that might still roll back, which is usually the case for "something happened" events - use `ShouldQueueAfterCommit` when only one queued listener cares and the others must run inside the transaction (for example a synchronous listener that writes an audit row in the same transaction) - do not rely on a delay: a slow commit or a paused worker still loses the race, and a rollback still leaves the job behind ## Diagnosing it in production The pattern is recognisable once you know it: - failures cluster at busy times and disappear when you retry the failed job by hand, because by then the row has been committed - the failed job's exception is a `ModelNotFoundException` for a key that exists when you look it up - occasionally a customer receives an email for an order that is not in the database, which is the rollback case - the problem cannot be reproduced locally with the `sync` connection, because there the listener runs inside the same process and the same transaction Reproducing it deliberately (a short pause after the dispatch and before the end of the transaction, with a worker running) confirms the diagnosis before you add the interface. ## Things to keep in mind - after-commit dispatch means a listener that throws can no longer roll the order back; the transaction is already committed - `dispatch()` returns `null` for a deferred event, so do not rely on listener return values - a transaction opened by an outer caller (a service that wraps several actions in one `DB::transaction()`) keeps the event pending until that outer transaction commits - the same model re-query problem appears for queued jobs in general; this answer only covers events and their listeners

  • What does a ShouldDispatchAfterCommit event do when it is dispatched with no transaction open?
    It is dispatched immediately. The transactions manager runs a registered callback at once when no transaction is pending, so the interface only changes behaviour inside `DB::transaction()` or a manual `beginTransaction()`.
  • How is Event::defer() different from ShouldDispatchAfterCommit?
    `Event::defer(fn () => ...)` holds events raised inside the closure until the closure returns, then dispatches them; if the closure throws, they are dropped. It is tied to a block of code, not to a database commit, so it does not protect against a transaction that commits later or rolls back outside that block.
  • Why is ShouldDispatchAfterCommit not a way to roll back the order when a listener fails?
    Because the listeners only run after the commit. By then the order is durable, so an exception in a synchronous listener fails the request but cannot undo the order. Anything whose failure must cancel the order has to run inside the transaction as a direct call, not as an after-commit listener.

saying these in an interview costs you the question

  • Queued listeners always run after the request's transaction commits.
  • A short delay on the listener reliably fixes the missing-order error.
  • ShouldDispatchAfterCommit only affects queued listeners, not synchronous ones.
  • A ShouldDispatchAfterCommit event is discarded if no transaction is open.
  • A nested DB::transaction releases the event when the inner block commits.