skip to content

In Laravel, what does DB::transaction($callback, attempts: 5) actually retry, and what can go wrong when the loyalty-transfer closure runs a second time?

level: seniorimportance: should knowfreq 36%

answer

  1. default attempts is 1
  2. only concurrency errors retry
  3. SQLSTATE 40001 or deadlock messages
  4. nested level throws DeadlockException
  5. the whole closure re-runs

basics

~20 s

Only concurrency errors are retried: SQLSTATE 40001, deadlock and lock-wait-timeout messages, or SQLite's 'database is locked'. Any other exception is rolled back and rethrown at once. A retry re-runs the whole closure, so non-database side effects inside it happen again.

solid answer

~40 s

`transaction()` loops up to `$attempts` times, default 1. When the closure throws, it rolls back and asks `causedByConcurrencyError()`: a `PDOException` with code `40001`, or a message such as "Deadlock found when trying to get lock", "deadlock detected", "Lock wait timeout exceeded" or SQLite's "database is locked". Only those loop again while attempts remain; a validation failure or a unique-key violation is rethrown on the first attempt. A concurrency error at **commit** time is retried the same way. Inside a nested transaction nothing retries: Laravel throws `Illuminate\Database\DeadlockException`, and the outermost `transaction()` decides. Because the **whole closure** re-runs, anything it did outside the database, such as sending a notification or charging a card, repeats, and values computed before the closure are stale on the second pass.

code

php · 10 lines
php
<?php

use Illuminate\Support\Facades\DB;

// Risky: on a deadlock retry the SMS is sent twice
DB::transaction(function () use ($from, $to, $points, $sms) {
    DB::table('members')->where('id', $from)->decrement('points', $points);
    DB::table('members')->where('id', $to)->increment('points', $points);
    $sms->send($to, "You received {$points} points");
}, attempts: 3);

go deeper

for a junior

Recall that DB::transaction takes an optional attempts argument, defaulting to one, used for deadlocks.

for a middle

Explain which errors the concurrency detector recognises and that every other exception is rethrown after one rollback.

for a senior

Make the closure safe to re-run: no external side effects, fresh reads inside, attempts on the outermost call only.

for a principal

Decide when retries paper over a contention hotspot that needs a data-model or locking-order change instead.

## What the `attempts` argument controls `DB::transaction(Closure $callback, int $attempts = 1)` runs its body in a `for` loop from attempt 1 to `$attempts`. Each pass calls `beginTransaction()`, runs the closure, and commits. With the default of `1` there is no retry at all; the docs describe the second argument as the number of times the transaction is retried when a **deadlock** occurs. ## Which failures loop again When the closure throws, Laravel rolls back and then consults a **concurrency error detector**. The default `ConcurrencyErrorDetector` returns true when: - the exception is a `PDOException` whose code is `40001` (serialization failure), or - the message contains one of a fixed list of phrases, including "Deadlock found when trying to get lock" (MySQL), "deadlock detected" (PostgreSQL), "Lock wait timeout exceeded; try restarting transaction", "database is locked" (SQLite) and "has been chosen as the deadlock victim" (SQL Server). Only when the detector says yes **and** attempts remain does the loop continue. Every other exception is rethrown after the first rollback: | Failure inside the closure | Retried with `attempts: 5`? | |---|---| | Deadlock or serialization failure | yes, up to 5 passes | | Lock wait timeout | yes | | Unique constraint violation | no | | Your own `InsufficientPoints` exception | no | | Lost connection mid-closure | no | A concurrency error raised by the `COMMIT` itself is handled the same way: the loop rolls back if needed and tries again while attempts remain. The detector is bound through a contract, so an app can register its own implementation to recognise additional errors. ## Nested transactions never retry If the closure runs inside an outer transaction, the database has already rolled back the **whole** transaction on a deadlock, so retrying only the inner part would be meaningless. Laravel therefore decrements the level and throws `Illuminate\Database\DeadlockException` from the inner call. The exception keeps the original message, so the **outermost** `transaction()` recognises it as a concurrency error and retries the entire unit if it was given attempts. Put `attempts:` on the outermost call; on an inner call it has no effect for deadlocks. ## What a second pass repeats The retry re-executes the closure from the top. That is correct for database work, which was rolled back, but wrong for anything else: 1. **External side effects repeat.** A notification sent, a payment captured or a message published inside the closure happens once per attempt. Defer them with `DB::afterCommit()` or do them after `transaction()` returns. 2. **In-memory state is stale or doubled.** Values read before the closure, counters incremented in PHP, or objects mutated by the first pass carry over. Re-read what you depend on inside the closure. 3. **Exceptions change shape.** After the last attempt the original exception is rethrown, so callers still need to handle a deadlock that retries could not resolve. ## A retry-safe loyalty transfer ```php use Illuminate\Support\Facades\DB; $transferId = DB::transaction(function () use ($from, $to, $points) { $ids = [$from, $to]; sort($ids); DB::table('members')->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get(); // debit, credit and ledger insert, reading fresh values here return DB::table('point_transfers')->insertGetId([/* ... */]); }, attempts: 3); // side effects only after a successful commit $notifier->pointsReceived($to, $points); ``` Locking both member rows in id order reduces how often two opposite transfers deadlock. The retry covers the deadlocks that still happen, and the notification is sent exactly once, after the loop has succeeded. ## What to say in an interview The strong answer names the default of one attempt, the concurrency-only filter, the nested-transaction exception, and the rule that everything inside the closure must be safe to run twice. Retrying a closure with side effects inside is the defect interviewers are fishing for.

  • Why does a unique-key violation inside the closure not get retried even with attempts: 10?
    The retry loop only continues when the concurrency detector recognises the error: SQLSTATE 40001 or a deadlock, lock-timeout or locked-database message. A unique violation carries a different SQLSTATE and message, so the loop rolls back and rethrows it on the first attempt. Retrying would fail identically anyway, because the conflicting row is still there.
  • Could a retried transaction make things worse under heavy contention?
    Yes. Each retry re-runs the full closure immediately, with no delay between attempts, adding load to exactly the rows under contention. A high `attempts` value can hide a hot-row design problem; keep it small, lock in a consistent order, and treat a final failure as a signal rather than retrying forever.

saying these in an interview costs you the question

  • Thinks attempts retries on any exception thrown in the closure
  • Believes the default DB::transaction() already retries deadlocks
  • Sends notifications inside a closure that may be retried
  • Puts attempts on an inner nested transaction and expects retries
  • Assumes Laravel waits with backoff between attempts