DEV Community

Tuğrul Yıldırım
Tuğrul Yıldırım

Posted on

Laravel Queues and Database Transactions: Why Your Job Ran Before the Order Existed

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));
});
Enter fullscreen mode Exit fullscreen mode

The sequence that breaks it:

  1. The transaction inserts the order and its lines. Nothing is committed yet.
  2. ReserveStock::dispatch($order) pushes a payload to Redis immediately. Because the job uses SerializesModels, the payload contains the model class and its ID, not the data.
  3. A queue worker on another process picks up the job within milliseconds.
  4. The worker uses its own database connection. It tries to load Order with that ID. The row is not committed, so the worker cannot see it. The job fails with ModelNotFoundException.
  5. 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 status from draft to confirmed) 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 sync queue driver. Locally, many of us run QUEUE_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,
],
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

Option 6: queued notifications

For a single notification:

use App\Notifications\OrderConfirmed;

$customer->notify((new OrderConfirmed($order))->afterCommit());
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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 the sync driver.
  • Laravel's tools: after_commit on the queue connection, afterCommit() / beforeCommit() per dispatch, ShouldDispatchAfterCommit for events, ShouldQueueAfterCommit for queued listeners, ShouldHandleEventsAfterCommit for observers, and afterCommit() for notifications.
  • My recommendation for business apps: turn on after_commit for your main queue connection, then use beforeCommit() 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?

Top comments (0)