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:

PHP
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:

PHP
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:

PHP
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:

PHP
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.

A boundary diagram with three lanes — an HTTP controller on the left coordinating without deciding, a central Application lane where an ApproveOrder Action wraps a small DB::transaction with attempts: 3, and a red panel on the right listing the things that must never live inside the transaction such as Stripe charges, S3 uploads, sleeps, and 30-second reports. A second timeline below the lanes shows the COMMIT moment fanning out to afterCommit consumers — queued mail, cache invalidation, domain events, webhooks — and a dashed rollback panel reminds the reader that those closures are silently dropped if the transaction fails.
The Action owns the box. External I/O stays outside; everything that depends on the commit lives in afterCommit.

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:

PHP
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():

PHP
SendApprovalEmail::dispatch($order)->afterCommit();

Or you can configure the job class to default to it:

PHP
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.

A nested transaction diagram with an outer DB::transaction holding two inner blocks. The first inner block uses a savepoint and rolls back independently. The second inner block commits. After the outer COMMIT, an afterCommit closure fires that dispatches a queued email job. A side note shows the same diagram with a final ROLLBACK and the afterCommit closure crossed out.
Nested transactions use savepoints. afterCommit runs only when the outermost block actually commits.

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.

PHP
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.

Three vertical panels showing the same ApproveOrder Action across three lenses. The left panel renders the local PHP code with DB::transaction wrapping the writes and DB::afterCommit deferring SendEmail. The middle panel is a millisecond-level production timeline: BEGIN, two UPDATEs that are visible only to this connection, COMMIT at fourteen milliseconds, the afterCommit dispatch firing immediately after, and a worker reading the row at thirty-two milliseconds finding it present. The right panel shows the observability outputs — Horizon job tagged by order id, Pulse spans without external HTTP, log lines correlated by order id, and a red anti-pattern panel listing pre-commit cache fills, jobs failing on missing rows, deadlocks under long reports, and observers opening fresh transactions.
Same Action, three lenses: how it reads in code, how it lands in production timing, and what shows up on the dashboard.

A Quick Tour Of The Transaction Mistakes

Things that show up in PRs and need a comment every time:

  • try/catch inside DB::transaction that 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::transaction inside a for loop, 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::forget or Cache::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 into afterCommit.
  • A model observer that opens a new transaction inside a saving callback. 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.