Laravel Magazine
Database Transactions and Deadlock Retries Done Right

Database Transactions and Deadlock Retries Done Right

Eric Van Johnson ·

Any operation that writes to more than one table has a consistency risk. If the second write fails, you are left with half-finished data. Transactions solve this by making a group of statements all-or-nothing, and Laravel gives you a closure-based helper that handles the fiddly parts, including retrying on deadlock, so you do not have to manage commit and rollback by hand.

The Closure Form

Pass a closure to DB::transaction(). If the closure runs to completion, the transaction commits. If it throws, Laravel rolls everything back and re-throws the exception:

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create($orderData);

    $order->items()->createMany($lineItems);

    Inventory::decrementFor($order);
});

No matter which line throws, you never end up with an order that has no line items or inventory that was decremented for an order that failed to save.

Automatic Deadlock Retries

Under concurrency, databases occasionally abort a transaction to break a deadlock. DB::transaction() accepts a second argument: the number of times to retry the whole closure if a deadlock occurs:

DB::transaction(function () {
    // ...
}, 3);

If the transaction deadlocks, Laravel runs the closure again, up to three times, before giving up and throwing. This is a small addition that meaningfully improves reliability on busy tables, and it is easy to forget exists.

Manual Control When You Need It

Sometimes the logic does not fit neatly in a closure, for example when you need to make a decision based on an external call partway through. Use the manual methods, but be disciplined with a try/catch:

DB::beginTransaction();

try {
    // ... writes ...
    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

The closure form is preferable precisely because it makes forgetting the rollback impossible. Reach for the manual form only when you genuinely cannot express the work as a single closure.

Do Not Do Slow Work Inside a Transaction

A transaction holds locks for its entire duration. Anything slow inside it, an HTTP call to a payment gateway, sending an email, dispatching to a third-party API, keeps those locks open and invites the very deadlocks you were trying to avoid. Keep transactions tight around the database writes and move slow side effects outside:

$order = DB::transaction(fn () => $this->persistOrder($orderData));

// Side effects run after the transaction has committed
PaymentGateway::charge($order);
$order->customer->notify(new OrderConfirmed($order));

Better still, dispatch side effects to a queue and use the afterCommit behavior so they only fire once the transaction has actually committed. Transactions are cheap insurance when they are kept small and focused. The trouble starts when they grow to wrap work that has no business being inside them.

Further Reading

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.