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' => trueon 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
SerializesModelsjob fails at once and skips its retries, a job that callsfindOrFail()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)withoutafterCommitpushes one job per attempt. ShouldBeUniquetakes its lock at dispatch, not at commit. The lock is held for the rest of the transaction and released if it rolls back. WithoutafterCommit, a rollback leaves the job queued and the lock held.Queue::fake()ignoresafter_commit. It records the job whendispatch()runs, soassertPushedpasses 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.
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
uniqueForTTL 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.xmlsetsQUEUE_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 notafterCommitis set. Queue::fake()records at dispatch time. The fake doesn't consultafter_commitor the transaction manager, so a job dispatched in a transaction that rolls back still satisfiesQueue::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.
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.
Keep reading
11 min read
Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request
Log and measure every outgoing HTTP call in Laravel with a Guzzle middleware and on_stats, including...
13 min read
Laravel Horizon in Prometheus: Why Exported Throughput Reads Low
Why Horizon's metrics undercount in Prometheus, and a small counter exporter for Laravel queues with PromQL,...