There is a familiar bug. A user pays. The order is created. The webhook fires a ShouldQueue job that emails a confirmation. Two seconds later support gets a ticket: the email arrived but the order is not in the dashboard. You check the database and the order is there. You check Horizon and the job ran. Both succeeded. Both correct. The problem is that the job ran on a worker that read the database before the controller's transaction committed — so it serialized the email against an order that did not yet exist when it queried, retried, and gave up gracefully.
This is what transactions are about in a real Laravel app. Not "wrap the SQL in DB::transaction," which everyone knows. The real question is which boundary owns the transaction, what runs inside it, and what runs only after the commit lands. Laravel has good primitives for this. They reward thinking about boundaries; they punish reflexive use.
The Default You Actually Want
DB::transaction is the helper that gets used 90% of the time, and for good reason:
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($order, $payload) {
$order->update(['status' => 'paid']);
Payment::create([
'order_id' => $order->id,
'amount' => $order->amount,
'method' => $payload['method'],
]);
InventoryReservation::where('order_id', $order->id)->delete();
});
The closure runs inside BEGIN / COMMIT. If anything throws, Laravel issues ROLLBACK and re-throws so the caller sees the original exception. No half-applied state.
The second argument is the part teams sometimes forget — deadlock retries:
DB::transaction(function () use ($order) {
// ...
}, attempts: 3);
If the transaction aborts because of a deadlock, Laravel retries up to attempts times. This matters on hot tables where two requests genuinely race — the INSERT wins one, the UPDATE wins the next, and the third try succeeds clean. Without retries you serve the user a 500 for a transient race that should be invisible.
The manual form (DB::beginTransaction, DB::commit, DB::rollBack) exists for the rare case you need explicit control of the boundary — usually when committing depends on something you only know after a sequence of network calls. Reach for it when the closure form really cannot express the flow, not as the default.
Where The Transaction Belongs
The mistake teams make is wrapping at the wrong boundary. Two common shapes that cause trouble.
The first is wrapping in the controller. This works for small features and looks tidy at first:
public function approve(Request $request, Order $order)
{
DB::transaction(function () use ($request, $order) {
$order->update(['status' => 'approved']);
OrderApproved::dispatch($order);
});
return response()->noContent();
}
The problem appears six months later when you need to approve an order from a Slack command, an Artisan task, or a scheduled job. The transaction logic lives inside an HTTP controller. Either you copy-paste it or you call the controller from places it was not meant to be called.
The second is wrapping inside the model — an event listener on saving that opens a transaction and then writes to other tables. Now every save on that model has hidden multi-table effects, and the transaction owner is invisible at every call site.
The shape that holds up is wrapping inside the unit of business logic — usually an Action or service class:
final class ApproveOrder
{
public function execute(Order $order, User $approver): Order
{
return DB::transaction(function () use ($order, $approver) {
$order->fill([
'status' => 'approved',
'approved_by' => $approver->id,
'approved_at' => now(),
])->save();
$order->items()->update(['locked' => true]);
OrderApproved::dispatch($order->fresh());
return $order;
});
}
}
The Action owns the boundary. The controller calls it. The Slack command calls it. The Artisan task calls it. They all get the same transactional guarantee without copying anything.
The afterCommit Rule
Now the bug at the top of the article. Inside a transaction, your INSERT is visible only to your own session. It is not committed yet. If you fire a queued job from inside the transaction, the worker that picks the job up is on a different connection — it sees the database as it was before your transaction started. The worker queries for the order, finds nothing, and either fails or worse, succeeds in the wrong way.
Laravel's answer is DB::afterCommit, available since Laravel 8.17:
DB::transaction(function () use ($order) {
$order->update(['status' => 'approved']);
DB::afterCommit(function () use ($order) {
SendApprovalEmail::dispatch($order);
});
});
The closure passed to afterCommit only runs if the outermost transaction commits. If it rolls back, the closure is silently dropped — which is exactly what you want, because the email should not be sent for an order that was never actually approved.
For events and jobs, Laravel provides shortcuts that do this for you. ShouldQueue jobs can be dispatched with ->afterCommit():
SendApprovalEmail::dispatch($order)->afterCommit();
Or you can configure the job class to default to it:
class SendApprovalEmail implements ShouldQueue
{
public bool $afterCommit = true;
// ...
}
You can also flip the global default via queue.connections.<name>.after_commit = true in config/queue.php. With that on, every queued job from that connection waits for the surrounding transaction to commit before being released to a worker. Most teams should enable this — the default of "dispatch immediately" is rarely what people actually want once they think about it.
Events follow the same idea. An event listener implementing ShouldHandleEventsAfterCommit runs only after the transaction commits, instead of synchronously when dispatch() is called.
Nested Transactions And Savepoints
Call DB::transaction inside another DB::transaction and Laravel does not start a real new transaction — it issues a SAVEPOINT. The inner block can throw and roll back to its savepoint without aborting the outer one. The outer commit is the one the database actually honors.
DB::transaction(function () use ($order) {
$order->update(['status' => 'reviewing']);
try {
DB::transaction(function () use ($order) {
// savepoint, not a new transaction
$order->fraudCheck()->update(['result' => 'flagged']);
throw new ManualReviewRequired();
});
} catch (ManualReviewRequired) {
$order->update(['status' => 'manual_review']);
}
});
The fraud-check write is rolled back to the savepoint. The status updates are committed by the outer block. This is occasionally exactly what you want — most often when a sub-step is a "best effort" that should not abort the larger operation.
It is also the source of confusing bugs if the team forgets that the inner block is not a real transaction. afterCommit registered inside the inner block does not fire until the outermost block commits. Listeners that assume "I am at the end of a transaction" can fire later than the listener thinks. Treat the outer boundary as the one that matters and design the inner blocks as fallbacks.
What Does Not Belong Inside The Transaction
A transaction is a database resource. Holding it longer than necessary holds locks longer than necessary, which is how you get deadlocks and queued requests.
Things that absolutely do not belong inside DB::transaction:
- External HTTP calls. A Stripe charge, a Slack message, a third-party webhook. The network call can take seconds, and you are holding row locks the entire time. The pattern is to make the call first, then enter the transaction with the result. Or wrap the call in a saga that compensates on failure.
- File I/O outside the database. Writing to S3, generating a PDF, reading a 100 MB upload. Same problem — the transaction is paying for unrelated I/O.
- Sleeping or waiting. Polling a queue, waiting for a webhook, retrying a network call. None of this should hold a database transaction.
- Long computations. A report generation that takes 30 seconds will lock every row it touched for 30 seconds. Compute first, then write inside a fast transaction.
The shape that works: do the slow work outside. Enter the transaction with the data you need to write. Commit. Use afterCommit for any side effect that the outside world should observe.
A Quick Tour Of The Transaction Mistakes
Things that show up in PRs and need a comment every time:
try/catchinsideDB::transactionthat swallows the exception. The transaction does not see the exception, so it commits the partial state. If you must catch, rethrow — or do the catching outside the closure.DB::transactioninside aforloop, opening a fresh transaction per iteration. Either lift it outside (one transaction, all-or-nothing) or skip it (each iteration is independent). Both are valid. The accidental middle ground is worst.event(new SomethingHappened($model))fired inside a transaction with synchronous listeners that read from the database. Those listeners read pre-commit state and can disagree with what the controller observes after returning.Cache::forgetorCache::tags()->flush()inside the transaction. The cache is invalidated; concurrent requests refill it from the pre-commit state; you are now caching stale data. Move cache invalidation intoafterCommit.- A model observer that opens a new transaction inside a
savingcallback. The save is already inside someone's transaction. Now you have nested savepoints whose error behavior nobody expects.
The mental model that makes all of this fit: a transaction is a box around a small, fast, all-or-nothing database write. Anything that talks to the outside world goes outside the box. Anything that depends on the write being durable goes after the box, via afterCommit. The Action that owns the operation owns the box, and the controller, the job, and the command all just call it. Once a team agrees on that, the data-consistency bugs that look like timing issues stop showing up — because the boundary stopped being a guess.




