The transaction retried and the email went out three times
One order row, three confirmation emails, nothing in the logs. The retry you added for deadlocks does not know which lines of your closure were safe to repeat.
ON THIS PAGE
One order, three emails
Support forwards you a screenshot. A customer has three identical order confirmations, timestamped within the same second. You check the database: one order row, one payment, correct total. Nothing in failed_jobs. Nothing in the application log. The order is fine. The customer received it three times.
The code looks like the careful version of itself:
DB::transaction(function () use ($cart, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'total' => $cart->total(),
]);
$order->lineItems()->createMany($cart->lines());
$this->inventory->decrement($cart->lines());
Mail::to($customer)->send(new OrderConfirmation($order));
return $order;
}, attempts: 5);
Someone added attempts: 5 six months ago, during a week of deadlocks on the inventory table. It fixed the deadlocks. It is also the reason for the three emails, and the two facts are the same fact.
The retry is the whole closure
The docs are precise about this, and it is easy to read past: the second argument "defines the number of times a transaction should be retried when a deadlock occurs". A transaction, not a statement. Laravel cannot retry the one query that deadlocked, because MySQL rolls back the entire transaction when it picks your connection as the victim — there is nothing left to continue from. So the retry loop starts over at the top of your closure.
You can watch it happen with a counter:
$runs = 0;
$emails = 0;
$result = DB::transaction(function () use (&$runs, &$emails) {
$runs++;
$emails++; // stands in for Mail::to(...)->send(...)
if ($runs < 3) {
throw new Exception('Deadlock found when trying to get lock; try restarting transaction');
}
return 'order-' . $runs;
}, attempts: 5);
The counters say what happened:
closure ran: 3 times | side effect fired: 3 times | returned: order-3
Three attempts, one result. The database work from attempts one and two is gone — rolled back, exactly as designed. The email is not, because Mail::send() is not a database statement and ROLLBACK has no opinion about it. Neither does an HTTP call to a payment gateway, a file written to S3, a Cache::put(), a Redis INCR, a webhook, or a line appended to a log. Every one of those fires once per attempt and stays fired.
This is a different failure from the two neighbouring ones you have probably already met. "firstOrCreate() is not atomic" is about two concurrent runs colliding; this is one run executing its own body repeatedly. And "your job runs before the data it depends on exists" is about a job that starts too early; here the job is queued correctly and the unqueued side effect next to it is the problem.
Which exceptions trigger it, and the two surprises
The retry only engages for what Laravel classifies as a concurrency error. Everything else propagates on the first attempt:
$plain = 0;
try {
DB::transaction(function () use (&$plain) {
$plain++;
throw new RuntimeException('card declined');
}, attempts: 5);
} catch (Throwable $e) {
// non-concurrency closure ran: 1 times
}
Good — a declined card is not retried five times. But the classification is message-based and broader than "deadlock". Illuminate\Database\ConcurrencyErrorDetector matches a list of driver strings, and two entries on it are worth knowing about.
The first is Lock wait timeout exceeded; try restarting transaction. That is not a deadlock — it is your transaction waiting out innodb_lock_wait_timeout, by default fifty seconds, on a row somebody else is holding. It counts as a concurrency error, so it retries:
lock-wait-timeout closure ran: 3 times
Five attempts at fifty seconds each is a request that hangs for four minutes and then sends five emails. If your closure holds locks long enough to time out, raising attempts makes the symptom longer, not better.
The second is database is locked, which is how SQLite reports write contention. A test suite on SQLite can therefore exercise the retry path in ways your MySQL production never will, or hide it entirely — depending on which way round you are lucky.
Then there is the case where the retry silently does not apply at all. If your retryable transaction is nested inside another transaction, Laravel cannot restart it, because the outer transaction owns the rollback:
$inner = 0;
DB::transaction(function () use (&$inner) {
DB::transaction(function () use (&$inner) {
$inner++;
throw new Exception('Deadlock found when trying to get lock');
}, attempts: 5);
});
The inner closure runs once:
nested inner ran: 1 times | threw: Illuminate\Database\DeadlockException
One attempt, and a DeadlockException thrown straight out past the attempts: 5 you asked for. A service method with its own attempts is not retryable when a caller wraps it in a transaction — and nothing warns you that the guarantee has been dropped.
afterCommit is the one side effect that behaves
There is a real exception to all of this, and it is worth measuring rather than assuming. A job dispatched with afterCommit() is registered against the current transaction and discarded when that transaction rolls back — so a retried closure queues it once, not once per attempt.
Counting rows in the jobs table rather than trusting a fake, because Queue::fake() pushes immediately and bypasses the transaction-aware path entirely:
$before = DB::table('jobs')->count();
$runs = 0;
DB::transaction(function () use (&$runs) {
$runs++;
dispatch(function () { /* ... */ })->afterCommit();
if ($runs < 3) {
throw new Exception('Deadlock found when trying to get lock');
}
}, attempts: 5);
// closure ran: 3 | jobs rows created: 1
Three runs, one job. That is the behaviour you want, and it is available to mail too — Mail::to($customer)->queue(new OrderConfirmation($order)) on a connection with after_commit enabled gets the same treatment, where send() does not.
So the smallest honest fix to the order closure is to stop sending mail inside it and queue it instead. That handles mail, notifications, broadcast events and queued listeners. It does nothing for the inventory service, the gateway call or the cache write, because those are not dispatches.
Move the effects out, or make them idempotent
The rule that covers all of it: a retryable closure should contain database work and nothing else. It computes, it writes, it returns. The caller does the I/O afterwards, where a rollback cannot strand it and a retry cannot repeat it:
$order = DB::transaction(function () use ($cart, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'total' => $cart->total(),
]);
$order->lineItems()->createMany($cart->lines());
return $order;
}, attempts: 5);
$this->inventory->decrement($order->lineItems);
Mail::to($customer)->send(new OrderConfirmation($order));
Where an effect genuinely has to happen inside the transaction, give it a natural key so a repeat is a no-op rather than a duplicate — a unique index on (order_id, type) for the notification record, an idempotency key on the gateway request, updateOrCreate instead of create. The unique index is doing the same job here as it does for firstOrCreate(): it moves the guarantee into the one place that can enforce it.
And if the closure is long or holds contended locks, reconsider attempts as the answer. Shortening the transaction reduces the contention that caused the retry; raising the retry count just buys more copies of whatever the closure does.
What to change
Grep for transactions with a retry count and read each closure line by line:
grep -rn "attempts:" app/ ; grep -rn "DB::transaction(" app/
For every one, mark each line as either a database statement or an effect on the outside world. If it is an effect, move it after the DB::transaction() call, convert it to a ->queue()/->afterCommit() dispatch, or give it a unique key — in that order of preference.
Then check for attempts inside anything a caller might wrap in its own transaction. Those retries are already not happening, and the method's signature still claims otherwise.