Skyline

Dispatching Laravel Jobs After the Transaction Commits

· 10 min read · Boring Observability

Verified against Laravel 13.24

A job dispatched inside a database transaction reaches Redis the moment you call dispatch(), and the rows it depends on only become visible to other connections when the transaction commits. A worker that is free can pick the job up in that window and fail with No query results for model [App\Models\Order] for an order that exists by the time you look. It never happens locally and shows up under load.

Laravel's fix is afterCommit(), which holds the push until the transaction commits. This post covers where to switch it on, when the push really happens once nested transactions, savepoints and deadlock retries are involved, how it interacts with ShouldBeUnique, and how events, mail and tests handle it. Every behaviour described here was checked against Laravel 13.24.

Key takeaways#

  • Set 'after_commit' => true on your queue connections. With no transaction open the job is pushed immediately, as before, so the setting only changes dispatches that were racing a commit.
  • The race doesn't always throw. A SerializesModels job fails at once and skips its retries, a job that calls findOrFail() usually succeeds on its second attempt, and a job reading a row updated in the transaction runs on the old values without any error.
  • Deferred jobs follow the outermost commit. A caught exception in an inner DB::transaction() discards only the jobs dispatched inside it. DB::transaction($callback, 3) without afterCommit pushes one job per attempt.
  • ShouldBeUnique takes its lock at dispatch, not at commit. The lock is held for the rest of the transaction and released if it rolls back. Without afterCommit, a rollback leaves the job queued and the lock held.
  • Queue::fake() ignores after_commit. It records the job when dispatch() runs, so assertPushed passes for a job whose transaction rolled back.

Why the worker can't see the row#

$order = DB::transaction(function () use ($request) {
    $order = Order::create($request->validated());
    $order->lines()->createMany($request->input('lines'));

    SendOrderConfirmation::dispatch($order);

    $this->inventory->reserve($order); // another 40 ms of queries

    return $order;
});

dispatch() serializes the job and writes it to the queue. With Redis, SQS or Beanstalkd that write is outside the database, so it lands straight away. The transaction still has the inventory work to do before it commits. A worker that looks at the queue in that window, because it has just finished another job or was blocking on the queue with block_for, pops the job, unserializes SendOrderConfirmation and queries for order 42 on its own connection. MySQL's default REPEATABLE READ and Postgres's default READ COMMITTED both hide uncommitted rows from other connections, so the query comes back empty.

Replication lag is not the cause, although it looks similar. SerializesModels restores models with useWritePdo(), so the worker already reads from the primary. The row simply isn't committed yet.

Two conditions have to line up: a worker free at that moment, and a transaction that keeps working after the dispatch. Locally there is usually no worker running, or the sync driver runs the job in the same process on the same connection, which can see its own uncommitted rows. In production at low traffic the gap is a few milliseconds and the worker rarely wins. Under load, transactions get slower and workers are constantly between jobs, so the failures start.

What losing the race looks like#

What happens next depends on how the job gets its data. Only the first case produces a failed job you would notice.

How the job gets its data Result when the worker is early
A model property with SerializesModels The model is restored before handle(), the restore throws ModelNotFoundException, and the queue handler calls fail() directly. $tries and backoff() are never consulted, so the job goes straight to failed_jobs.
The same, with $deleteWhenMissingModels = true The job is deleted. There is no failed job, no exception and no log line, so the confirmation email is lost silently.
An ID, with Order::findOrFail($this->orderId) in handle() An ordinary exception. The worker reports it and releases the job with its backoff, and by the second attempt the row has committed, so the job completes. Nothing reaches failed_jobs, and Horizon's dashboard shows a completed job.
A row that already existed and was updated in the transaction The worker reads the values from before the transaction and runs on them. A receipt for an order still marked unpaid, a search index written with the old title. Nothing is raised.

The third row is the one that goes unnoticed for months. The ModelNotFoundException does reach your error tracker, but with no failed job next to it, it reads as noise, and nothing connects it to the same job completing on its next attempt. What gives it away is one job ID with a release and then a completion. Skyline's job lifecycle logging writes both lines under the job's ID, the release with reason=exception at warning level, so a class that regularly fails its first attempt stands out.

Where to switch it on#

For a single dispatch, chain afterCommit():

SendOrderConfirmation::dispatch($order)->afterCommit();

For every dispatch of a class, implement ShouldQueueAfterCommit in place of ShouldQueue:

use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;

class SendOrderConfirmation implements ShouldQueueAfterCommit
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // ...
}

For every job on a connection, set after_commit in config/queue.php:

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

When they disagree, the class is checked first: a ShouldQueueAfterCommit job is deferred unless that dispatch called beforeCommit(). For any other job, an explicit afterCommit() or beforeCommit() on the dispatch wins, and the connection setting applies only when neither was called. The database, Beanstalkd, SQS and Redis connections in Laravel's default config/queue.php all ship with 'after_commit' => false.

The connection setting is the one to use. When no transaction is open, a deferred push runs straight away, so nothing changes for code that dispatches outside a transaction. It also covers the jobs you don't dispatch yourself. Queued listeners, mailables and notifications are pushed through the same connection, and each inherits after_commit unless its class says otherwise.

When the push actually happens#

Laravel keeps a record for every open transaction and attaches the deferred push to the most recent one. What happens to it after that depends on how the transaction ends.

Nested transactions wait for the outermost commit#

Committing an inner DB::transaction() only releases a savepoint, so nothing is visible to other connections yet. Laravel moves the inner record's callbacks onto the committed list and runs them once the transaction level returns to zero. A job dispatched three levels deep is pushed when the outer transaction commits.

A caught inner exception discards only the inner jobs#

DB::transaction(function () use ($order) {
    SendOrderConfirmation::dispatch($order)->afterCommit();

    try {
        DB::transaction(function () use ($order) {
            ApplyLoyaltyPoints::dispatch($order)->afterCommit();
            $this->loyalty->credit($order); // throws
        });
    } catch (LoyaltyServiceDown $e) {
        report($e);
    }
});

The inner transaction rolls back to its savepoint, and the ApplyLoyaltyPoints push is discarded with it. The outer transaction commits and SendOrderConfirmation is pushed. This matches the data, which is the point. Without afterCommit, ApplyLoyaltyPoints would already be on the queue, about to credit points that were rolled back.

Deadlock retries push once per attempt#

DB::transaction($callback, 3) retries the closure after a deadlock or serialization failure, rolling back and starting again. Every attempt runs every dispatch() in the closure. Without afterCommit each of those pushes lands immediately, so a transaction that deadlocks twice and commits on the third attempt leaves three copies of the job on the queue, two of them for rows that were rolled back. With afterCommit, the failed attempts' pushes are discarded along with their rows and only the committing attempt's job is queued.

Two database connections#

The push attaches to the most recently opened transaction on any connection, not to the connection your model uses. Two consequences follow, and both are easy to hit in an app with a separate reporting or tenant database:

  • A job dispatched inside DB::connection('reporting')->transaction(), which is itself inside a transaction on the default connection, is pushed when the reporting transaction commits. The default connection's rows are still uncommitted at that point.
  • A job dispatched inside a transaction on another connection waits for that transaction, even when the rows the job reads were written outside any transaction and committed long ago.

If you dispatch while transactions are open on two connections, dispatch from the outermost one or after both closures return.

Dispatch happens when PendingDispatch is destroyed

Job::dispatch() returns a PendingDispatch, and the push happens in its destructor. That is usually the end of the statement, but $pending = Job::dispatch($order); inside a closure pushes when $pending goes out of scope, and DB::transaction(fn () => Job::dispatch($order)) pushes after the commit because the transaction returns the object. Both look safe without afterCommit and stop being safe as soon as someone refactors them.

afterCommit and ShouldBeUnique#

ShouldBeUnique acquires its lock in PendingDispatch, before the job reaches the queue, and afterCommit only defers the push that comes after it. So for a unique job dispatched inside a transaction:

  • The lock is held for the rest of the transaction. A second request dispatching the same job meanwhile is dropped, although nothing is on the queue yet. The uniqueFor TTL also starts counting at dispatch, so a long transaction uses up part of it.
  • A rollback releases the lock. When the push is deferred, Laravel registers a rollback callback that releases the unique lock, so the next dispatch after a failed transaction goes through.
  • Without afterCommit, a rollback leaves both behind. The job is already queued and holds the lock. Every dispatch of it is dropped until a worker has processed the orphaned job, which is now working on rows that don't exist. If there's a backlog, that can take a while.

The other ways unique locks go wrong, including locks that never expire and duplicates that get through anyway, are covered in ShouldBeUnique vs ShouldBeUniqueUntilProcessing under real traffic.

Events, observers, mail and notifications#

Anything that runs on a database write can race a commit, and jobs aren't the only thing that does. Each of these has its own switch, and the connection-level after_commit only reaches the ones that go through the queue.

What How to defer it Covered by the connection's after_commit?
An event and all of its listeners The event class implements ShouldDispatchAfterCommit No
A synchronous listener Implement ShouldHandleEventsAfterCommit, or set public $afterCommit = true No
A queued listener Implement ShouldQueueAfterCommit, or set public $afterCommit = true Yes
A model observer public $afterCommit = true on the observer. creating, updating, saving, deleting, restoring and forceDeleting still run immediately, because they can cancel the write. No
A queued mailable or notification Implement ShouldQueueAfterCommit, or call ->afterCommit() on the instance before sending Yes

A synchronous listener that dispatches a job is the case people miss. The listener itself runs inside the transaction, and the job it dispatches follows the job's own rules. With after_commit on the connection the job is still deferred correctly. Without it, deferring the listener is what saves you.

Testing it#

RefreshDatabase and DatabaseTransactions wrap each test in a transaction that never commits, which would hold every deferred push forever. Laravel handles that. Both traits install a testing transaction manager that ignores the wrapping transaction, so a deferred job dispatched outside any DB::transaction() in your code is pushed at once, and one dispatched inside is pushed when that transaction returns.

Two things still get in the way:

  • The race itself can't reproduce. phpunit.xml sets QUEUE_CONNECTION=sync, which runs the job in the same process on the same connection, and a connection can read its own uncommitted rows. A test that dispatches before commit passes whether or not afterCommit is set.
  • Queue::fake() records at dispatch time. The fake doesn't consult after_commit or the transaction manager, so a job dispatched in a transaction that rolls back still satisfies Queue::assertPushed().

With a fake, assert the setting rather than the timing:

Queue::assertPushed(SendOrderConfirmation::class, fn ($job) =>
    $job instanceof ShouldQueueAfterCommit || $job->afterCommit === true
);

To test a rollback end to end, leave the queue unfaked. The sync driver honours after_commit, so a job that records it ran, dispatched inside a transaction you make throw, should not have run when the test checks.

When to dispatch before the commit#

Rarely. The honest case is a job that has to run even if the transaction fails, such as an alert that the transaction failed, and that job doesn't belong inside the transaction at all. Dispatch it from the catch block, after the rollback has finished.

Don't dispatch from DB::afterRollBack()

Rollback callbacks run while the transaction's record is still registered. With after_commit on, a job dispatched from one attaches to the transaction being rolled back and is thrown away with it. Nothing is raised and the job never runs. If you have to dispatch from a rollback callback, call beforeCommit() on that dispatch.

The other case is a job that reads nothing the transaction writes. Deferring it costs nothing but a few milliseconds, and it saves you from checking whether that stays true as the job changes. Turn on after_commit on the connection and use beforeCommit() for the exceptions you can name.

Frequently asked questions

What does afterCommit do in Laravel?

It holds the push of a queued job until every open database transaction has committed. The push is registered as a callback on the current transaction and runs on commit. If the transaction rolls back, the job is never queued. If no transaction is open when you dispatch, the job is pushed immediately, exactly as without afterCommit.

Why does my queued job throw ModelNotFoundException right after it was dispatched?

Usually because it was dispatched inside a database transaction that had not committed yet. With Redis or SQS the job lands on the queue at once, and a free worker can pick it up and query for the row on its own connection, which cannot see uncommitted rows. A job with a SerializesModels property fails immediately without using its retries. Dispatch it with afterCommit(), or set after_commit on the queue connection.

How do I make every Laravel job dispatch after commit?

Set 'after_commit' => true on each connection in config/queue.php. It applies to every job, queued listener, mailable and notification pushed through that connection, unless the class implements ShouldQueueAfterCommit or the dispatch calls beforeCommit(). Synchronous listeners, model observers and events are not covered: they need ShouldHandleEventsAfterCommit, $afterCommit = true or ShouldDispatchAfterCommit.

How does afterCommit work with nested transactions?

A job dispatched in a nested DB::transaction() is pushed when the outermost transaction commits, because committing an inner level only releases a savepoint. If an inner transaction throws and the exception is caught, only the jobs dispatched inside that inner transaction are discarded. When DB::transaction() retries after a deadlock, the failed attempts' deferred jobs are discarded, whereas jobs dispatched without afterCommit are pushed once per attempt.

Does Queue::fake() respect afterCommit?

No. The fake records a job when dispatch() runs and does not consult after_commit or the transaction manager, so Queue::assertPushed() passes even when the transaction rolled back. Assert that the pushed job is deferred, by checking it implements ShouldQueueAfterCommit or has afterCommit set to true, or leave the queue unfaked and use the sync driver, which does honour after_commit.

Horizon, with the buttons it leaves out.

Skyline is a drop-in replacement for Laravel Horizon. From its dashboard you can pause a single queue, run a delayed job now, delete a job or empty a queue, and the unique locks are released as jobs are removed. $99 once, for every app you run it on.

Buy Skyline — $99 once

Secure checkout by Anystack. 30 days to change your mind.