The Job That Ran Before the Row Existed: Dispatching Inside Laravel Transactions

Why Laravel dispatch job after commit fixes intermittent ModelNotFoundException in queued jobs, how after_commit works, and what it will not protect you from.

Steven Richardson
Steven Richardson
· 9 min read

A ProcessOrder job started throwing ModelNotFoundException for order IDs that were sitting in the database when I went looking for them. Roughly one dispatch in fifty. Never locally, never with a debugger attached, only on the endpoint that wrapped its work in DB::transaction().

The job was fine. The worker had simply got there first.

Reproducing the race in thirty lines#

You need two processes to see this, and a transaction that stays open long enough for a worker to win. A sleep() inside the transaction body makes it deterministic:

Route::post('/orders', function () {
    return DB::transaction(function () {
        $order = Order::create([
            'reference' => 'ORD-'.Str::upper(Str::random(8)),
            'total' => 4999,
        ]);

        ProcessOrder::dispatch($order);

        // Stand in for the slow work that really happens here:
        // a Stripe call, an inventory check, three more inserts.
        sleep(2);

        return $order;
    });
});

The job does nothing exotic — it just resolves the model Laravel serialised for it:

class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order) {}

    public function handle(): void
    {
        // Never reached. The model cannot be found yet.
        Log::info('Processing '.$this->order->reference);
    }
}

Run php artisan queue:work redis in one terminal, hit the endpoint, and the worker fails inside two seconds:

Illuminate\Database\Eloquent\ModelNotFoundException: No query results for model [App\Models\Order] 91
  at vendor/laravel/framework/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php:110

Note where it fails. Not in handle() — in model restoration, before your code runs. Laravel serialised the order as a class name plus an ID, and by the time the worker unserialised it, ID 91 did not exist on any connection but the one holding the open transaction.

Why the worker gets there first#

Three actors, one timeline:

Time Web process Database Worker
t+0ms BEGIN transaction open idle
t+2ms INSERT order 91 row visible only inside the transaction idle
t+3ms ProcessOrder::dispatch()
t+4ms pops job, unserialises, Order::find(91)null
t+2004ms COMMIT row visible to everyone already failed

dispatch() pushes to Redis synchronously. Redis does not know or care that a MySQL transaction is open, and the worker is a separate OS process on a separate database connection. Uncommitted rows are invisible to it. The window is small, which is exactly why it shows up as a one-in-fifty flake instead of an outright bug — and why it gets misdiagnosed as a queue problem when you are scaling Laravel queues in production.

There is one configuration where this cannot happen, and knowing it explains a lot of bug reports. With the database queue driver pointed at the same connection as your application data, the INSERT into the jobs table happens inside your transaction. A worker cannot see the job row until you commit, and a rollback deletes it. Accidentally correct. Which is why a team that moves from database to Redis for throughput suddenly starts seeing ModelNotFoundException in jobs that had been stable for a year — they did not introduce the race, they removed the thing that was hiding it.

Dispatch after commit with one line in config/queue.php#

Set after_commit on the connection:

'redis' => [
    'driver' => 'redis',
    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
    'block_for' => null,
    'after_commit' => true, // ships as false
],

Laravel now registers the push as a transaction callback instead of executing it, and runs it when the outermost transaction commits. Two things people expect to be true here, and both are:

Dispatching outside any transaction still dispatches immediately. DatabaseTransactionsManager::addCallback() invokes the callback on the spot when there is no pending transaction, so there is no queued-up latency, no deferred flush, no cost. This is the part worth internalising: turning after_commit on globally has no downside for the 90% of your dispatches that never touch a transaction. The default is false for backwards compatibility, not because it is risky.

A rollback discards the dispatch entirely. The callback is dropped with the transaction record, so a job that would have operated on rolled-back data never reaches the queue. That is the behaviour you want and it is the strongest argument for the global setting.

The setting also covers queued event listeners, mailables, notifications and broadcast events on that connection — not just jobs. ShouldQueue plus an open transaction is the same race every time.

Per-job and per-dispatch afterCommit overrides#

If you cannot change global config, or you want one job to be explicit regardless of how the connection is configured, Laravel 13 gives you three narrower levers.

The cleanest is the interface, which extends ShouldQueue and replaces it:

use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;

// Note: replaces ShouldQueue, does not sit alongside it.
class ProcessOrder implements ShouldQueueAfterCommit
{
    use Queueable;
}

The older property route still works and is read by Queue::shouldDispatchAfterCommit():

class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public $afterCommit = true;
}

And per dispatch, in either direction:

ProcessOrder::dispatch($order)->afterCommit();  // defer this one
ProcessOrder::dispatch($order)->beforeCommit(); // opt out when config is true

Precedence is worth knowing because it is not alphabetical. The interface wins over the connection config, but an explicit ->beforeCommit() still overrides the interface — internally that sets $afterCommit = false, and the check is ! (isset($job->afterCommit) && $job->afterCommit === false). So the escape hatch always works, which is what you want when a job genuinely must fire mid-transaction.

For events and listeners the interfaces are named differently, and mixing them up is a quiet way to fix nothing:

// The event class — defer dispatch until commit.
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;

class OrderShipped implements ShouldDispatchAfterCommit {}

// A queued listener — defer the queue push until commit.
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;

class SendShipmentNotification implements ShouldQueueAfterCommit {}

Nested transactions and which commit counts#

afterCommit fires when the outermost transaction commits, not when an inner savepoint releases. DatabaseTransactionsManager::afterCommitCallbacksShouldBeExecuted($level) is literally return $level === 0;.

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

    DB::transaction(function () use ($order) {   // level 2 (savepoint)
        ProcessOrder::dispatch($order);
    });                                 // level 2 commits — nothing dispatched

    sleep(2);
});                                     // level 0 reached — job dispatched here

This is the behaviour you want, and it matters if you have a service class that opens its own transaction while a controller has already opened one. The inner commit is not a durability boundary, so treating it as a dispatch boundary would reintroduce the race.

The inverse also holds: roll back the inner savepoint and callbacks registered inside it are discarded, while the outer transaction's callbacks survive to fire on its commit.

What afterCommit does not fix#

Replication lag. The row is committed, the worker queries a read replica, and the replica has not caught up. afterCommit closed the transaction race and nothing else — this is a different problem with a different fix, covered in routing reads to a follower without serving stale data. If you have replicas and you are still seeing missing models after enabling after_commit, stop tuning the queue and go look at lag.

dispatchAfterResponse(). Constantly confused with afterCommit because both sound like "later". They have nothing to do with each other:

afterCommit() dispatchAfterResponse()
Waits for the outermost DB commit the HTTP response being sent
Runs where a queue worker, another process the same PHP process, in-request
On rollback dispatch is discarded still runs
Survives a crash yes, it is on the queue no, it dies with the process

dispatchAfterResponse() bypasses the queue push entirely, so it never consults after_commit. Using it inside a transaction gives you the original race back, plus no retries.

Long transactions. If your transaction is open for eight seconds, afterCommit correctly delays the dispatch by eight seconds. The fix for a slow transaction is a shorter transaction, not a queue setting.

Gotchas and Edge Cases#

Queue::fake() will not catch this. QueueFake::push() records the job the moment it is called, with no after_commit handling at all. A test that asserts Queue::assertPushed(ProcessOrder::class) passes whether or not you fixed anything. Test the rollback path instead — that is the assertion the fake can actually carry:

it('does not dispatch when the transaction rolls back', function () {
    Queue::fake();

    try {
        DB::transaction(function () {
            $order = Order::create(['reference' => 'ORD-TEST', 'total' => 100]);
            ProcessOrder::dispatch($order)->afterCommit();

            throw new RuntimeException('payment declined');
        });
    } catch (RuntimeException) {
        // expected
    }

    Queue::assertNothingPushed();
});

That test fails without ->afterCommit() and passes with it, which is exactly the regression you want pinned. If you write a lot of these, custom Pest expectations make the intent read better than a bare assertNothingPushed.

Unique locks on rollback are handled now. The ShouldBeUnique lock is acquired in PendingDispatch::shouldDispatch(), which runs before the deferred push — so historically a rollback could leave the lock set with no job queued, blocking the next legitimate dispatch until uniqueFor expired. Laravel 13's registerRollbackCallbacksForJobsThatDispatchAfterCommit() releases both the UniqueLock and the newer DebounceLock on rollback. The residual edge is narrow: release depends on $job->uniqueLockOwner, which is only set when your cache store implements LockProvider. Redis, database, file, memcached, DynamoDB and array all do; apc does not. If you are running unique jobs on an APC cache store, do not rely on this.

Turning it on is a behaviour change. Safe, but not a no-op. Anything relying on a job running during an open transaction will break — usually because it was a bug. Find the candidates before you flip the flag:

# Dispatches that sit inside a transaction closure
rg -n --type php -B5 'dispatch\(' app/ | rg -A5 'DB::transaction|beginTransaction'

The anti-fixes. A sleep(2) at the top of the job, a bumped $backoff, or a if (! $model) return; guard all mask the race and cost you real failures later. If the guard is already there, an explicit typed exception is at least honest about it — see renderable and reportable exceptions. Retry-shaped fixes belong in job middleware, for problems that are actually transient.

Wrapping Up#

Set 'after_commit' => true on every queue connection in config/queue.php, then grep for dispatches inside transaction closures to confirm nothing depended on the old timing. Add the rollback test to whichever job hurt you most — it is the only assertion that proves the fix, since Queue::fake() is blind to the race by design.

If your workers are still failing on models that exist, the next suspect is replica lag rather than the queue. And once dispatch timing is correct, chains versus batches is the next thing worth getting right, because both inherit the same after-commit behaviour from the connection.

FAQ#

Why does my Laravel queued job say the model was not found?

Almost always because the job was dispatched inside a database transaction that had not committed when a worker picked it up. Laravel serialises Eloquent models as a class name and primary key, then re-queries them in the worker — a separate process on a separate connection, which cannot see uncommitted rows. The failure happens during model restoration, before handle() runs, and it is intermittent because it depends on whether the worker wins the race.

What does afterCommit do in Laravel?

It defers the actual queue push until all open database transactions have committed. Instead of pushing to Redis or SQS immediately, Laravel registers the push as a transaction callback and runs it on the outermost commit. If the transaction rolls back, the callback is discarded and the job never reaches the queue. When no transaction is open, the dispatch happens immediately as normal.

Should I set after_commit to true globally in config/queue.php?

For most applications, yes. There is no cost when no transaction is open — Laravel executes the push straight away in that case — so the setting only changes behaviour for dispatches that were already racing. It defaults to false for backwards compatibility, not because it carries risk. Treat it as a behaviour change rather than a pure bug fix, and check for any code that deliberately relied on a job running mid-transaction before you enable it.

Does afterCommit work for events and notifications?

Yes. Setting after_commit on the connection defers queued event listeners, mailables, notifications and broadcast events as well as jobs. For per-class opt-in the interfaces differ by target: ShouldQueueAfterCommit on a queued listener, and ShouldDispatchAfterCommit on the event class itself. Non-queued listeners can use ShouldHandleEventsAfterCommit or an $afterCommit property.

What happens to a dispatched job if the transaction rolls back?

The dispatch is discarded and the job is never queued. Because the push is held as a callback on the transaction record, rolling back drops the record and the callback with it. Laravel 13 also registers rollback callbacks that release a ShouldBeUnique job's uniqueness lock and any debounce lock, so a rolled-back dispatch does not block the next legitimate one.

What is the difference between afterCommit and dispatchAfterResponse?

They solve unrelated problems. afterCommit() waits for the outermost database transaction to commit and then pushes the job to a queue worker, discarding it on rollback. dispatchAfterResponse() skips the queue entirely and runs the job in the same PHP process after the HTTP response is sent — it ignores transactions, still runs after a rollback, and is lost if the process dies. Using dispatchAfterResponse() inside a transaction reintroduces the original race with no retry safety net.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.