A customer creates an invoice. The controller wraps the work in a transaction, saves the invoice and its lines, and dispatches a job to email the PDF. Most of the time it works. Then, on a busy morning, the logs fill with ModelNotFoundException: no query results for model App\Models\Invoice 4182. The invoice is there when you look. The job got there first.

This is the race between a database transaction and a queue. The fix is to hold the dispatch until the commit, and to drop it if the transaction rolls back. The examples below are Laravel 12.40.

01 The race you cannot see locally

The dispatch inside the transaction is what causes it. Queueable from Illuminate\Foundation\Queue includes SerializesModels, so the payload stores the invoice id, not the row.

InvoiceController.phpPHP
// app/Http/Controllers/InvoiceController.php
use App\Http\Requests\StoreInvoiceRequest;
use App\Jobs\SendInvoice;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;

class InvoiceController
{
    public function store(StoreInvoiceRequest $request, Team $team): RedirectResponse
    {
        $invoice = DB::transaction(function () use ($request, $team) {
            $invoice = $team->invoices()->create($request->safe()->except('lines'));
            $invoice->lines()->createMany($request->validated('lines'));

            SendInvoice::dispatch($invoice);

            return $invoice;
        });

        return to_route('invoices.show', $invoice);
    }
}

SendInvoice::dispatch() pushes the job onto Redis, SQS, or the database queue immediately. The transaction has not committed yet. Two things can now go wrong.

The worker is faster than the commit. It queries for invoice 4182, and that row is not visible on the worker's connection. You get ModelNotFoundException. If the job sets $deleteWhenMissingModels = true, Laravel deletes the job instead, and the customer never gets the email.

The transaction rolls back. A line fails a database constraint, nothing is saved, and the job is already on the queue for an invoice that will never exist.

Locally you rarely see either. The sync driver runs the job inline, on the same connection, so it can see the uncommitted row. With one worker on an idle laptop the commit almost always lands first. In production, with several workers waiting on a fast queue, the worker often gets there first.

Check yourself · 1 question

Question 1 of 1A job is dispatched inside DB::transaction(), and the transaction later rolls back. Without any after-commit setting, what happens to the job?

02 Dispatch after the commit

Laravel can hold the dispatch until the surrounding transaction commits. If the transaction rolls back, the job is never pushed.

The quickest fix is on the dispatch itself. afterCommit() lives on the pending dispatch, from the Queueable trait.

PHP
SendInvoice::dispatch($invoice)->afterCommit();

Put it on the job instead, so every caller gets the same behaviour without having to remember the chain.

SendInvoice.phpPHP
// app/Jobs/SendInvoice.php
namespace App\Jobs;

use App\Mail\InvoiceIssued;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;

class SendInvoice implements ShouldQueueAfterCommit
{
    use Queueable;

    public function __construct(public Invoice $invoice)
    {
    }

    public function handle(): void
    {
        Mail::to($this->invoice->team->billing_email)
            ->send(new InvoiceIssued($this->invoice));
    }
}

ShouldQueueAfterCommit extends ShouldQueue, so it is still a queued job. When there is no open transaction, it dispatches straight away. The job does not need to know whether the caller opened a transaction.

03 Make it the default

To opt every job on a connection in, set after_commit in config/queue.php. The framework default for this key is false.

queue.phpPHP
// config/queue.php
return [
    'redis' => [
        'driver' => 'redis',
        'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => 90,
        'block_for' => null,
        'after_commit' => true,
    ],
];

Every job on that connection then waits for the commit. For the rare job that must leave immediately, even mid-transaction, opt that dispatch back out with ->beforeCommit(). That sets the job's afterCommit flag to false, which wins over both the interface and the connection setting.

Setting it on the connection is the safer default for an app that uses transactions a lot. Setting it on the job class is easier to see in review. Pick one and write it down, so the next person does not mix them.

Check yourself · 1 question

Question 1 of 1SendInvoice implements ShouldQueueAfterCommit and is dispatched from a console command with no open transaction. When is it pushed to the queue?

04 It is not only jobs

Anything that leaves the process can hit the same race. In Laravel 12.40 the after-commit hook is not the same method on every type.

  • A queued job. Implement ShouldQueueAfterCommit, or call afterCommit() on the dispatch.
  • A queued mailable or notification. Implement ShouldQueueAfterCommit on the class, or set public $afterCommit = true. There is no afterCommit() method on the mailable or the notification.
  • An event. Implement ShouldDispatchAfterCommit.
  • A queued listener. Implement ShouldQueueAfterCommit, or set public $afterCommit = true.
  • A model observer. Implement ShouldHandleEventsAfterCommit. created() then runs once the commit lands, after the lines exist.
  • Anything else, such as a cache clear or an HTTP call. Pass a closure to DB::afterCommit().

Observers are the one most people miss. An InvoiceObserver::created() that pushes a webhook fires inside the transaction, before the lines are inserted, and a line count logged there is zero. ShouldHandleEventsAfterCommit moves that method to after the commit.

For a side effect that is not queued work, wait in the same transaction that changes the row.

PHP
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($invoice) {
    $invoice->update(['status' => 'void']);

    DB::afterCommit(fn () => Cache::forget("team:{$invoice->team_id}:outstanding"));
});

If you forget the cache key inside the transaction instead, another request can read the old row and write the stale total back before the commit lands.

05 What after-commit does not fix

After-commit closes the race between your database and your queue. It does not make delivery guaranteed.

A crash between the commit and the dispatch loses the job. The invoice is saved, the process dies before the callback runs, and nothing is queued. The callback lived in that process's memory. For most emails that is an acceptable risk. For money, it usually is not. The fix there is an outbox: write a row describing the work in the same transaction, and have a scheduled command push anything still unsent. The row commits or rolls back with the invoice.

It does not stop a job running twice. A worker can still die after the mail is sent and before the job is deleted. Pair this with an idempotent handle, so a second run does not send a second email. Jobs that don't fall over is that pattern.

Read replicas can still be behind. sticky on a database connection only keeps later reads on the write connection for the rest of that one request. A queue worker is a new process. For a job that must see the row it was just given, read from the write connection.

PHP
use App\Models\Invoice;

$invoice = Invoice::query()->useWritePdo()->findOrFail($id);

Nested transactions wait for the outermost commit. A dispatch inside an inner DB::transaction() is held until the outer one commits. If that inner transaction rolls back, the job is discarded with it. That is what you want, and it is surprising when a stack trace shows the job leaving a method you thought had already committed.

Check yourself · 1 question

Question 1 of 1The invoice is committed, then the PHP process is killed before the after-commit dispatch runs. What happens?

06 Testing it

Prove the case that matters: if the transaction rolls back, nothing is queued.

StoreInvoiceTest.phpPHP
// tests/Feature/StoreInvoiceTest.php
use App\Jobs\SendInvoice;
use App\Models\Invoice;
use App\Models\Team;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use RuntimeException;

it('does not queue the invoice email when the transaction rolls back', function () {
    Queue::fake();
    $team = Team::factory()->create();

    try {
        DB::transaction(function () use ($team) {
            $invoice = Invoice::factory()->for($team)->create();
            SendInvoice::dispatch($invoice);

            throw new RuntimeException('Line insert failed');
        });
    } catch (RuntimeException) {
        // The rollback is the behaviour under test.
    }

    expect(Invoice::query()->count())->toBe(0);
    Queue::assertNotPushed(SendInvoice::class);
});

Then remove ShouldQueueAfterCommit from the job and run the test again. If it still passes, it is not testing the dispatch. This project uses RefreshDatabase, which already has a transaction open around the test, so confirm the fake still waits for the real commit before you trust a green run.

07 Checklist

  • Any job, mail, notification, event, or observer started inside DB::transaction() should wait for the commit.
  • Prefer ShouldQueueAfterCommit on the class, or after_commit on the connection. Choose one and write it down.
  • Mailables and notifications use the interface or public $afterCommit = true, not a method call.
  • Use DB::afterCommit() for side effects that are not queued work, such as cache keys and outbound HTTP.
  • After-commit is not guaranteed delivery. Use an outbox for work you cannot afford to lose.
  • Keep the job idempotent anyway. Retries do not care how carefully you dispatched it.

08 Further reading