Deadlocks are the kind of bug that doesn't reproduce when you go looking for it. The error fires under concurrency — two requests, or a request and a job, colliding on the same rows at the same instant — and then everything's fine again until traffic lines up just so. That intermittency is exactly why they're worth understanding properly: you can't single-step your way to the cause, so you fix them by knowing the patterns that produce them and by watching the rate.
What a deadlock looks like in Laravel
Postgres deadlock
ERROR: deadlock detected
DETAIL: Process 5234 waits for ShareLock on transaction 99812; blocked by process 5221.
Process 5221 waits for ShareLock on transaction 99815; blocked by process 5234.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,42) in relation "orders"Surfaces in Laravel as
Illuminate\Database\QueryException
SQLSTATE[40P01]: Deadlock detected: 7 ERROR: deadlock detected
SQL: update "orders" set "status" = ? where "id" = ? and "user_id" = ?The SQLSTATE codes to care about
| Code | Meaning | Retry? |
|---|---|---|
| 40001 | Serialization failure (MySQL/Postgres) | Yes |
| 40P01 | Deadlock (Postgres) | Yes |
| 1205 | Lock wait timeout (MySQL) | Yes |
| 1213 | Deadlock (MySQL) | Yes |
Retry with exponential backoff
Laravel's DB::transaction has a built-in retry count, but the retry happens immediately. For heavily contended rows, add jittered backoff:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
function withRetry(callable $tx, int $maxAttempts = 3)
{
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return DB::transaction($tx);
} catch (QueryException $e) {
$code = $e->errorInfo[0] ?? null;
$deadlock = in_array($code, ['40001', '40P01']);
if (!$deadlock || $attempt === $maxAttempts) {
throw $e;
}
// Jittered exponential backoff — 50ms, 100ms, 200ms + random
usleep((2 ** $attempt) * 50000 + rand(0, 25000));
}
}
}Retries buy time, not a fix
A retry loop turns a deadlock from an error your user sees into a few milliseconds they don't. That's worth having, and you should have it. But a rising retry rate is still telling you that two code paths are fighting over the same rows, and retries get more expensive as contention climbs — each collision now costs an extra round trip plus a backoff sleep. Treat the retry as the seatbelt, not the repair. The prevention patterns below are the repair.
Prevention patterns
1. Consistent lock order
// Bad — different paths lock in different orders
DB::transaction(function () {
$user = User::lockForUpdate()->find($userId);
$account = Account::lockForUpdate()->find($accountId);
});
DB::transaction(function () {
$account = Account::lockForUpdate()->find($accountId);
$user = User::lockForUpdate()->find($userId);
});
// Good — always lock in the same order (alphabetical, ID order, whatever)
DB::transaction(function () use ($userId, $accountId) {
$user = User::lockForUpdate()->find($userId);
$account = Account::lockForUpdate()->find($accountId);
});2. Atomic updates over read-modify-write
// Bad — window between SELECT and UPDATE where two workers can conflict
$user = User::find($id);
$user->balance += 10;
$user->save();
// Good — atomic, no lock window
User::where('id', $id)->update(['balance' => DB::raw('balance + 10')]);
// Or with increment
User::where('id', $id)->increment('balance', 10);3. SKIP LOCKED for queue-like patterns
// Claim a pending job atomically — multiple workers won't collide
$job = DB::transaction(function () {
$row = Job::where('status', 'pending')
->orderBy('created_at')
->limit(1)
->lockForUpdate(skipLocked: true)
->first();
if ($row) {
$row->update(['status' => 'claimed', 'claimed_at' => now()]);
}
return $row;
});4. Keep transactions short
No external HTTP calls, no slow computations, no user-facing IO inside DB::transaction. A transaction that holds locks for 500ms while waiting on Stripe is an incident waiting to happen.
Monitoring deadlock rate
Aggregate QueryExceptions by SQLSTATE code over time. A sudden jump in 40P01 (Postgres) or 1213 (MySQL) correlates with:
- A recent deploy introducing a new lock-ordering bug
- Traffic spike on a hot row (flash sale on a single product)
- A long-running background job holding locks while user requests pile up
- Stale query plan picking a table scan instead of an index (bigger lock footprint)
THE EASY WAY
NightOwl groups exceptions by fingerprint — deadlocks surface automatically
QueryException with SQLSTATE 40P01 groups into one issue regardless of which query triggered it. Track count over time, drill in to see which requests hit it most, get alerted on rate spikes.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.