Understanding MySQL Deadlocks in Laravel
When high concurrent traffic hits an endpoint executing database updates, MySQL eventually drops SQLSTATE[40001]: Error 1213 "Deadlock found when trying to get lock; try restarting transaction". It's not a syntax error or a bug in your PHP code. It's InnoDB protecting database integrity when two concurrent database sessions request locks on the exact same rows in opposite order.
Consider an ecommerce checkout system. Session A updates order 101, then requests an exclusive lock on inventory item 55. Simultaneously, Session B updates inventory item 55 and requests an exclusive lock on order 101. Neither process can move forward. Session A waits for Session B to unlock item 55, while Session B waits for Session A to unlock order 101. InnoDB detects this circular dependency in its lock wait graph and instantly kills one of the transactions (the victim) with a 1213 error.
Deterministic Lock Ordering
The fastest way to eliminate deadlocks from your application is sorting your records before acquiring locks. If every transaction locks database rows in ascending numerical order by primary key, circular lock dependencies become mathematically impossible.
Here's how developers accidentally create deadlocks when accepting batch payload requests from front-end applications built on React 19 or Next.js 16:
// Bad: Locking records based on un-ordered array inputs
$userIds = $request->input('user_ids'); // e.g. [42, 15, 89]
DB::transaction(function () use ($userIds) {
foreach ($userIds as $id) {
User::where('id', $id)->lockForUpdate()->first();
}
});If Request 1 sends array [42, 15] and Request 2 sends array [15, 42] at the exact same millisecond, you've created a guaranteed deadlock under load. Always sort primary keys before invoking lockForUpdate():
// Good: Sort keys before executing queries
$userIds = collect($request->input('user_ids'))->sort()->values()->toArray();
DB::transaction(function () use ($userIds) {
$users = User::whereIn('id', $userIds)
->orderBy('id', 'asc')
->lockForUpdate()
->get();
foreach ($users as $user) {
$user->decrement('credits', 10);
}
});Laravel's Automatic Retry Mechanism
Even when your Eloquent queries strictly order their locks, complex foreign key constraints, secondary index updates, or cascade deletes can still cause transient deadlocks under heavy load. Laravel provides built-in retry handling directly inside the DB::transaction() closure.
Pass an integer as the second argument to specify how many times Laravel should re-try the transaction before throwing an exception:
use Illuminate\Support\Facades\DB;
use App\Models\Account;
// Retry up to 5 times before failing
$account = DB::transaction(function () {
$account = Account::where('id', 101)->lockForUpdate()->first();
$account->balance -= 50;
$account->save();
return $account;
}, 5);How Deadlock Detection Works in Laravel 12
Under the hood, Laravel's ManagesTransactions trait catches PDOException instances during closure execution. It passes the exception to causedByDeadlock(). In Laravel 12 on PHP 8.3, this method checks the SQLSTATE code and error message against known database server strings:
- MySQL & MariaDB: Error 1213 (Deadlock found when trying to get lock) or Error 1205 (Lock wait timeout exceeded).
- PostgreSQL: Error code 40001 (serialization_failure) or 40P01 (deadlock_detected).
- SQLite: Code 5 (database is locked) or Code 6 (database table is locked).
If a deadlock is matched, Laravel rolls back the failed transaction attempt, pauses briefly using a randomized sleep delay to avoid immediate lock re-contention, and executes the closure again. If the maximum attempt count is reached and all fail, Laravel throws the original QueryException.
Isolation Levels: REPEATABLE READ vs READ COMMITTED
MySQL defaults to the REPEATABLE READ isolation level. To enforce repeatable reads, InnoDB uses next-key locking and gap locking. Gap locks prevent phantom reads by locking the imaginary space between index records, even when no row currently exists there.
Gap locks cause a huge percentage of unexpected deadlocks during high-concurrency INSERT or range update queries. Session A locks a gap where it plans to insert a record, while Session B locks the same gap. When both sessions attempt their inserts, neither can proceed because each session holds a gap lock that blocks the other's insert.
Switching your database connection to READ COMMITTED disables gap locking for most operations. Row locks are retained, but range-based gap locks disappear.
You can configure the isolation level directly in your config/database.php file:
// config/database.php
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'isolation_level' => 'READ COMMITTED',
],I recommend READ COMMITTED for virtually all high-throughput Laravel web APIs. It drops deadlock rates drastically while preserving total data safety for typical web transactional patterns.
The Queue Worker Race Condition
A major gotcha in Laravel applications involves dispatching queued jobs inside an uncommitted database transaction. Look at this common pattern:
// Problematic pattern
DB::transaction(function () use ($orderId) {
$order = Order::findOrFail($orderId);
$order->update(['status' => 'completed']);
// Dispatches instantly to Redis
ProcessOrderReceipt::dispatch($order);
});When ProcessOrderReceipt::dispatch() runs, Laravel pushes the job payload onto Redis instantly. A fast background queue worker picks up the job in under 3 milliseconds. However, your primary HTTP process might still be running database cleanup, auditing, or network requests inside the open transaction block.
The queue worker tries to read the order state from MySQL. Because the primary HTTP transaction hasn't issued a COMMIT yet, the worker reads an uncommitted snapshot showing status = pending, or fails with a ModelNotFoundException if the row was just created. The background job executes against stale state or crashes entirely.
Option 1: Global Queue Configuration
You can force all queue dispatchers across your application to wait for open database transactions to commit. Enable the after_commit option in config/queue.php:
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => true,
],With after_commit set to true, Laravel defers pushing jobs to the message broker until all outer database transactions have committed successfully. If a transaction rolls back because of an error or deadlock, the queued job is automatically dropped without executing.
Option 2: ShouldQueueAfterCommit Interface
If you prefer explicit control over specific background jobs, implement the ShouldQueueAfterCommit marker interface on your job class:
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Queue\Queueable;
class ProcessOrderReceipt implements ShouldQueue, ShouldQueueAfterCommit
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
// Runs only after the parent database transaction commits
}
}Alternatively, append afterCommit() directly at the dispatch site:
ProcessOrderReceipt::dispatch($order)->afterCommit();Testing Deadlocks and Retry Resilience
Don't wait for production spikes to find out if your retry logic works. Write unit and feature tests in PHPUnit or Pest that simulate database deadlocks explicitly.
Here's how to mock a transaction failure using PDOException with SQLSTATE 40001 to verify your retry attempt counter:
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TransactionRetryTest extends TestCase
{
use RefreshDatabase;
public function test_db_transaction_retries_on_deadlock_exception(): void
{
$attempts = 0;
DB::transaction(function () use (&$attempts) {
$attempts++;
if ($attempts === 1) {
throw new \PDOException('Deadlock found when trying to get lock', '40001');
}
DB::table('users')->insert([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}, 3);
$this->assertEquals(2, $attempts);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
}Combining deterministic row sorting, READ COMMITTED isolation, built-in transaction retries, and deferred queue dispatches gives your Laravel application strong protection against standard database deadlocks under high concurrency.












