The bug report said: "Sometimes the stock reservation job fails with No query results for model [App\Models\Order]." Sometimes. Not in local, not in staging, not in the test suite. Only in production, only under load, and only for a few orders a day.
The order was there when we looked. The job had still failed. Both things were true, and the reason was one line inside a database transaction.
If you dispatch queued work from inside DB::transaction() in Laravel, this article is for you. We will look at why the worker can run before your data exists, the tools Laravel gives you to fix it (there are more than people think), and the pitfalls that remain after the fix.
The problem: your worker cannot see uncommitted rows
Here is a simplified version of the code. It looks perfectly reasonable:
use App\Jobs\ReserveStock; use App\Models\Order; use App\Notifications\OrderConfirmed; use Illuminate\Support\Facades\DB; DB::transaction(function () use ($data, $customer) { $order = Order::create([ 'customer_id' => $customer->id, 'status' => 'confirmed', ]); foreach ($data['lines'] as $line) { $order->lines()->create($line); } ReserveStock::dispatch($order); $customer->notify(new OrderConfirmed($order)); }); The sequence that breaks it:
- The transaction inserts the order and its lines. Nothing is committed yet.
-
ReserveStock::dispatch($order)pushes a payload to Redis immediately. Because the job usesSerializesModels, the payload contains the model class and its ID, not the data. - A queue worker on another process picks up the job within milliseconds.
- The worker uses its own database connection. It tries to load
Orderwith that ID. The row is not committed, so the worker cannot see it. The job fails withModelNotFoundException. - A moment later the web request commits. The order now exists. Everyone who looks at it later sees a healthy order and a failed job.
It gets worse in two other cases:
- The job finds the row, but old data. If you update a record inside the transaction (for example, change
statusfromdrafttoconfirmed) and dispatch a job, the worker can read the row as it was before the update. No exception, just a wrong decision. - The transaction rolls back. The job was already pushed. A notification can go out for an order that never existed. In business software, "we emailed the customer a confirmation for an order we do not have" is a very bad support ticket.
The Laravel documentation says this directly: when you dispatch a job within a transaction, the job may be processed before the parent transaction has committed, and records created in the transaction may not exist in the database yet.
Why you never see it locally
Two reasons:
- The
syncqueue driver. Locally, many of us runQUEUE_CONNECTION=sync. The job runs immediately, in the same process, on the same database connection, inside the open transaction. A connection can always see its own uncommitted writes, so everything works. - Timing. With a real worker on a quiet machine, the request usually commits before the worker picks up the job. Under production load the race window opens.
So the bug lives exactly in the gap between "works on my machine" and "works under load".
The fix: dispatch after commit
Laravel has a solution at several levels. Pick the one that matches how much of your app needs it.
Option 1: make it the default for a queue connection
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' => 90, 'block_for' => null, 'after_commit' => true, ], With this option, Laravel waits until all open parent transactions are committed before it actually pushes the job. If no transaction is open, the job is dispatched immediately. If the transaction rolls back, the jobs dispatched inside it are discarded.
According to the docs, this setting also applies to queued event listeners, mailables, notifications and broadcast events on that connection. For most business applications, I think this is the right default.
Option 2: per dispatch
If you cannot change the global behavior yet (large legacy app, other teams, risk), opt in per job:
use App\Jobs\ReserveStock; ReserveStock::dispatch($order)->afterCommit(); And the reverse exists too. With after_commit => true, a job that must go out immediately can opt out:
use App\Jobs\WriteAuditTrail; WriteAuditTrail::dispatch($payload)->beforeCommit(); Use beforeCommit() only when the job does not read anything written in the current transaction.
Option 3: events that should only exist after commit
An event like OrderPlaced should not fire for an order that was rolled back. Implement ShouldDispatchAfterCommit on the event class:
<?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, ) {} } Now the event is held until the current transaction commits, and discarded if it fails. If no transaction is open, it dispatches immediately.
Option 4: queued listeners
If the event itself is fine to dispatch early, but one queued listener reads the data, implement ShouldQueueAfterCommit on that listener:
<?php namespace App\Listeners; use App\Events\OrderPlaced; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Queue\InteractsWithQueue; class SyncOrderToWarehouse implements ShouldQueueAfterCommit { use InteractsWithQueue; public function handle(OrderPlaced $event): void { // Safe: the order and its lines are committed. } } Option 5: model observers
Observers are a classic hiding place for this bug, because created() fires inside the transaction. Implement ShouldHandleEventsAfterCommit:
<?php namespace App\Observers; use App\Models\Order; use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit; class OrderObserver implements ShouldHandleEventsAfterCommit { public function created(Order $order): void { // Runs after the surrounding transaction commits. } } Option 6: queued notifications
For a single notification:
use App\Notifications\OrderConfirmed; $customer->notify((new OrderConfirmed($order))->afterCommit()); Or make it the default for that notification by calling afterCommit() in its constructor:
<?php namespace App\Notifications; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Notification; class OrderConfirmed extends Notification implements ShouldQueue { use Queueable; public function __construct() { $this->afterCommit(); } } The fixed version
With after_commit => true on the connection, the original code needs no change at all. Without the global setting, it looks like this:
DB::transaction(function () use ($data, $customer) { $order = Order::create([ 'customer_id' => $customer->id, 'status' => 'confirmed', ]); foreach ($data['lines'] as $line) { $order->lines()->create($line); } ReserveStock::dispatch($order)->afterCommit(); $customer->notify((new OrderConfirmed($order))->afterCommit()); }); Same code, same place, but now nothing leaves the process until the data is real.
Common pitfalls
1. after_commit only helps queued work. A synchronous HTTP call, a Mail::send() without a queue, or a file write inside the transaction still happens immediately and is not undone by a rollback. Keep side effects out of the closure, or move them into a queued job that is dispatched after commit.
2. $deleteWhenMissingModels can hide the bug. Laravel lets a job set public $deleteWhenMissingModels = true; so it is silently deleted when its model is gone. That is useful for records that are deleted on purpose. But if you add it to "fix" the error above, you have not fixed anything: the stock is simply never reserved, and now there is no failed job to tell you.
3. Nested transactions wait for the outermost commit. If a service method opens a transaction and is called from a controller that already opened one, the job waits until the outer transaction commits. That is correct behavior, but remember it when you debug "why did my job run later than expected".
4. Test with a real worker before you trust it. The sync driver will not show this race. Run at least one end-to-end check locally with Redis or the database driver and php artisan queue:work.
5. Commit and dispatch are still two steps. After commit, Laravel pushes the job. If the process dies exactly between those two steps, the job is lost. For most apps this window is acceptable. For money movement or stock, consider a transactional outbox: write an "outgoing message" row inside the same transaction, and let a separate process publish it. That is a bigger topic for another article.
Summary
- A queue worker uses its own connection and cannot see your uncommitted rows.
- Dispatching inside
DB::transaction()creates a race that only shows up under load and never with thesyncdriver. - Laravel's tools:
after_commiton the queue connection,afterCommit()/beforeCommit()per dispatch,ShouldDispatchAfterCommitfor events,ShouldQueueAfterCommitfor queued listeners,ShouldHandleEventsAfterCommitfor observers, andafterCommit()for notifications. - My recommendation for business apps: turn on
after_commitfor your main queue connection, then usebeforeCommit()for the rare exceptions.
Reference: Laravel documentation, Queues: Jobs & Database Transactions, Events, Eloquent: Observers and Database Transactions and Notifications.
Over to you: does your project set after_commit globally, or do you opt in job by job, and why?

