artisanplaybook .dev
ARTICLES / QUEUES

Why your job runs before the data it depends on exists

Dispatching a job from inside a DB::transaction() block feels completely natural — the order gets created, so you dispatch a job to email the confirmation, right there in the same block. But queue workers, especially on Redis, can pick a job up within milliseconds. If that's faster than your transaction takes to commit, the job's handle() method runs against a database that doesn't have the row it's looking for yet.

ON THIS PAGE

The race condition, concretely

DB::transaction(function () {
    $order = Order::create([...]);

    SendOrderConfirmation::dispatch($order);
});

This looks correct, and most of the time it works — the transaction usually commits before the worker gets around to the job. "Usually" is the problem. Under load, or with a fast Redis-backed queue and a worker sitting idle waiting for jobs, the dispatch can reach the worker and start executing before the transaction wrapping it has committed. SerializesModels re-fetches Order fresh inside handle(), and that fresh fetch — from a separate connection, in a separate process — simply doesn't see a row that hasn't been committed yet. The job throws ModelNotFoundException, which looks exactly like a "the order was deleted" bug even though the order was, from the user's perspective, just created seconds ago.

Why this doesn't show up locally

If you're testing with QUEUE_CONNECTION=sync, jobs run inline, synchronously, at the moment dispatch() is called — inside the very same request, on the very same database connection, still inside the open transaction. Uncommitted rows are visible to other queries on that same connection, so the sync driver never reproduces this bug. It genuinely cannot — there's no second process involved.

The problem only appears once a real worker (queue:work) is reading from a separate connection as a separate process, which typically means staging or production. That gap between "works every time locally" and "fails intermittently once real workers are involved" is exactly why this one tends to surface as a confusing, hard-to-reproduce production bug rather than something caught in development.

The fix: don't dispatch until the transaction actually commits

Option one — move the dispatch outside the transaction closure, after it returns:

$order = DB::transaction(function () {
    return Order::create([...]);
});

SendOrderConfirmation::dispatch($order);

This is the most explicit fix, but easy to miss if the dispatch call is buried somewhere deep in whatever the transaction closure calls.

Option two — implement ShouldQueueAfterCommit on the job itself, which tells Laravel to hold the dispatch until any currently-open transaction commits, no matter where in the call stack dispatch() happens to be called:

use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendOrderConfirmation implements ShouldQueue, ShouldQueueAfterCommit
{
    // ...
}

Option three — set it at the connection level in config/queue.php, so every job on that connection gets this behavior without needing the interface on each one:

'redis' => [
    'driver' => 'redis',
    // ...
    'after_commit' => true,
],

Connection-level after_commit is the safer default for a whole application, since it protects every job dispatched from inside a transaction rather than relying on each job author remembering to add the interface.

Nested transactions: after_commit means the outermost one

If your code has nested DB::transaction() calls, be aware that inner transactions compile to savepoints, not independent commits. after_commit (and ShouldQueueAfterCommit) waits for the outermost transaction to commit — a job dispatched inside an inner transaction block won't fire the moment that inner block "finishes," only once the entire outer transaction actually commits to the database. If your code assumes otherwise, the timing will be later than expected, not earlier — generally the safer direction to be wrong in, but worth knowing if a job's timing genuinely matters.

If you can't restructure the dispatch site right now

Sometimes the dispatch call is buried in code you don't want to touch yet. As a stopgap — not a substitute for the fix above — a short, deliberate retry on "not found" can paper over the gap:

public $tries = 3;

public function backoff(): array
{
    return [2, 5]; // seconds — enough for a slow commit to land
}

This is a band-aid. It works because commits are usually fast, not because the underlying race is actually fixed — prefer ShouldQueueAfterCommit or restructuring the dispatch once you can.

Checklist before dispatching a job near a transaction

  • Is this job ever dispatched from inside a DB::transaction() closure, directly or several calls deep?
  • Does the job implement ShouldQueueAfterCommit, or is after_commit set on the queue connection it runs on?
  • If there are nested transactions involved, is the timing assumption based on the outermost commit, not the inner savepoint?
  • Locally, are you testing against a real queue connection (database, redis) at least occasionally, rather than only sync, which can't reproduce this race at all?

Related reading

ARCHIVE →