The job your transaction rolled back is already running.

An order confirmation email arrived three minutes after a customer’s payment had been declined. The purchase never went through. The customer opened their inbox to a cheerful “Thanks for your order!” while their card statement showed nothing. Support ticket filed. Refund requested that did not need to exist. An hour of engineering time spent figuring out what happened.
The cause was a single dispatch() call inside a DB::transaction() block. The transaction rolled back. The job did not.
This is not a rare edge case. It is the default behavior in Laravel when you use Redis as your queue driver. Most teams do not discover it until they are already explaining something inexplicable to a customer.
What You Assume and What Laravel Does
The mental model most developers carry is reasonable: if I dispatch a job inside a transaction and the transaction rolls back, the dispatch rolls back too. That assumption is correct for exactly one queue driver: the database driver.
For Redis, SQS, Beanstalk and every other non-database queue backend, the assumption is wrong.
Here is what happens with the database driver:
// database queue driver: dispatch inside transaction
DB::transaction(function () use ($order) {
$order->markAsFailed();
ProcessRefund::dispatch($order);
});The ProcessRefund job gets inserted into the jobs table. That table is part of the same database connection. When the transaction rolls back, the row in jobs disappears with it. The job never runs. This is the behavior you expect.
Now switch to Redis:
// redis queue driver: dispatch inside transaction
// config/queue.php: 'after_commit' => false (the default)
DB::transaction(function () use ($order) {
$order->markAsFailed();
ProcessRefund::dispatch($order); // already in Redis at this point
});
// transaction rolls back
// order was never marked as failed in the DB
// but ProcessRefund is running right nowThe moment dispatch() is called, Laravel calls RPUSH on your Redis queue. That push is not inside a database transaction. Redis has no concept of your MySQL or PostgreSQL transaction scope. The job is in the queue the instant you dispatched it.
The transaction rolls back. The model changes unwind. But the job is already there, waiting for a worker. When the worker picks it up, it queries for an order that may be in an inconsistent state. Or, in some scenarios, an order record that never reached the state the job expects.
The worker throws a model-not-found exception, retries three times, and eventually lands in the failed jobs table. You wake up to alerts. Or you do not, because the job silently processes stale data and corrupts something downstream.
Why the Default Is False
Laravel ships config/queue.php with 'after_commit' => false in every connection config, including Redis:
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', '{default}'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false, // this is the default
],The default exists for backward compatibility. Changing it to true globally shifts the dispatch timing for every job, listener, queued notification and mailable in the application at once. Without true in the config, none of this protection exists. That is a large surface area to change without understanding what moves. So Laravel leaves it off and documents the option.
Most developers read the queue documentation once, configure Redis, and move on. The after_commit line is easy to miss. There is no warning from the framework when you dispatch inside an open transaction with it set to false. Nothing in Horizon's UI, nothing in your logs. The phantom job just runs.
The Three Fixes
None of these are complicated. The question is which one fits your situation.
Fix 1: Per-dispatch afterCommit
Chain afterCommit() directly onto the dispatch call:
DB::transaction(function () use ($order) {
$order->markAsFailed();
ProcessRefund::dispatch($order)->afterCommit();
});When the transaction commits, Laravel pushes the job to Redis. When the transaction rolls back, the job is discarded. This is the most surgical fix. It changes only this specific dispatch, leaving everything else untouched.
Use this when you are adding transaction awareness to a specific job in an existing codebase and do not want to audit every other dispatch site.
Fix 2: ShouldQueueAfterCommit on the job class
Implement the ShouldQueueAfterCommit interface directly on the job:
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
class ProcessRefund implements ShouldQueue, ShouldQueueAfterCommit
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// the job body stays unchanged
}This approach is self-documenting. Anyone reading the job class immediately sees that it always waits for a transaction to commit before being pushed to the queue, regardless of where it is dispatched from. No call-site discipline required.
The interface was added in Laravel 10.30 and is available in all supported versions as of mid-2026.
Use this when the job is inherently transactional by nature and should never be processed outside a committed state. Refunds, order fulfillment triggers, audit record creation: all good candidates.
Fix 3: Set after_commit globally
Set 'after_commit' => true in the queue connection config:
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', '{default}'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => true,
],This applies to every dispatch on this connection: jobs, queued listeners, queued mailables, queued notifications and queued broadcasts. All of them will wait for any open transaction before being pushed.
This is the cleanest option if you are starting a new application or doing a deliberate audit of all dispatch sites. It is a meaningful behavior change for existing applications. Test it.
Choosing between the three:
- Patching a specific job in production today: use Fix 1 (
->afterCommit()) - Job is inherently transaction-dependent and dispatched from multiple places: use Fix 2 (
ShouldQueueAfterCommit) - Greenfield app or full audit of dispatch sites complete: use Fix 3 (global config)
The Nested Transaction Problem
Fix 3 (and by extension Fix 2 and Fix 1) waits for all open parent transactions to commit. Nested transactions are where this gets complicated.
Laravel supports nested transactions via savepoints. When you call DB::transaction() inside another DB::transaction(), the inner block uses a savepoint rather than a new transaction. From the perspective of Laravel's DatabaseTransactionsManager, the outer transaction is still open.
A job dispatched inside the inner block will not fire until the outer transaction commits:
// Outer transaction
DB::transaction(function () use ($order, $payment) {
$payment->markAsProcessed();
// Inner savepoint; outer transaction is still open
DB::transaction(function () use ($order) {
$order->markAsFulfilled();
SendFulfillmentEmail::dispatch($order)->afterCommit();
// job does NOT fire here; outer transaction still open
});
// job fires here when the outer transaction commits
});This is the correct behavior. But it surprises teams who think the inner commit is enough. If the outer transaction rolls back after the inner savepoint commits, the job is discarded. The inner commit does not protect the outer.
The Unique Job Lock Edge Case
There is a documented bug affecting jobs that implement both ShouldBeUniqueUntilProcessing and ShouldQueueAfterCommit. When a transaction rolls back, the job is not dispatched. But Laravel has already set the uniqueness lock in Redis. That lock is never released on rollback.
The result is that you cannot dispatch the job again until the lock expires. If the $uniqueFor property is not set, the lock never expires. Re-dispatch silently fails.
The workaround is to set a reasonable $uniqueFor value so the lock eventually expires:
class ProcessRefund implements ShouldQueue, ShouldQueueAfterCommit, ShouldBeUniqueUntilProcessing
{
// Lock expires after 10 minutes if the transaction rolled back
public int $uniqueFor = 600;
public function uniqueId(): string
{
return (string) $this->order->id;
}
}This is not an ideal fix. A failed transaction will block re-dispatch for up to 10 minutes. But it is better than an infinite lock. Track GitHub issue #47761 if your application relies heavily on unique jobs.
What the Database Driver Gets Right for Free
If you are running the database queue driver, most of this does not apply to you. Jobs dispatched inside a transaction are inserted into the jobs table, which is part of the same database connection. Transaction rollback discards the row. The job never runs.
The database driver trades throughput for simplicity. It is genuinely fine for lower-volume applications where you do not need the speed of Redis. If your queue volume is under a few hundred jobs per minute and you want the simplest possible operational story, the database driver protects you here by default.
Once you move to Redis for the throughput, you inherit the responsibility of managing transaction awareness yourself.
Driver comparison:
Database queue driver
- Transaction-aware by default: yes, the job row is part of the DB transaction
- Requires after_commit config: no
- Throughput: moderate; bounded by your database’s write capacity
- Best when: lower volume, want rollback protection without configuration
Redis queue driver
- Transaction-aware by default: no; push happens outside the DB transaction
- Requires after_commit config: yes, to get rollback protection
- Throughput: high; Redis push is a single non-blocking network call
- Best when: high volume, low latency dispatch, willing to configure after_commit correctly
How to Actually Test This
The failure mode is invisible in local development. Your test suite probably does not cover it either. Here is a minimal integration test that will catch the regression:
// tests/Feature/ProcessRefundTest.php
use App\Jobs\ProcessRefund;
use Illuminate\Support\Facades\Queue;
it('does not dispatch ProcessRefund when transaction rolls back', function () {
Queue::fake();
try {
DB::transaction(function () {
$order = Order::factory()->create();
ProcessRefund::dispatch($order);
throw new \Exception('Simulated failure');
});
} catch (\Exception) {
// expected
}
Queue::assertNotPushed(ProcessRefund::class);
});With after_commit => false and a real queue driver, Queue::fake() intercepts the dispatch before it hits Redis. The assertion assertNotPushed will fail in that case, revealing exactly the problem you need to fix.
Run this against your staging queue connection config. The sync or null driver will not expose the issue.
Adding this test to jobs that touch financial records or user-state-critical operations takes ten minutes. Finding the bug in production costs considerably more.
The Listener, Mailable and Notification Blind Spots
Most conversations about transaction-aware dispatch focus on jobs. Queued listeners, mailables and notifications have the same problem. They are easier to forget.
If you fire an event inside a transaction and a queued listener handles it, that listener is pushed to the queue immediately. Same as a dispatched job, same default behavior. Transaction rollback does not pull it back.
Setting after_commit => true globally protects all of these. The ShouldQueueAfterCommit interface also works on listeners:
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendInvoiceMail implements ShouldQueue, ShouldQueueAfterCommit
{
use InteractsWithQueue;
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->email)->send(new InvoiceMail($event->order));
}
}The mailing itself is synchronous inside the listener. What waits for the transaction is the listener dispatch. By the time the listener runs, the order is in the database.
One More Thing to Check
Open config/queue.php. Look at after_commit on your production connection. If the key is missing entirely, it defaults to false. If you are on Redis and dispatching jobs inside transactions anywhere in the codebase, you are operating without rollback protection right now.
The fix is one line. You probably have not set it yet.


