
September 07, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
When two checkout jobs update the same inventory row in different order, your app does not throw a friendly validation error—it throws a database deadlock. Laravel Database Transactions and Deadlocks are tightly linked: transactions define the atomic unit of work, and deadlocks appear when those units acquire row or table locks in conflicting sequences. On production Laravel 13 apps backed by PostgreSQL 18 or MySQL 9.7, I treat both as infrastructure concerns, not something you debug only after a payment webhook fails. This guide covers how Laravel wraps transactions, why deadlocks happen under concurrent load, and the patterns that actually survive Black Friday traffic or a burst of booking requests on a trek booking platform.
DB::transaction(), consistent lock order, short write scopes, and retry logic for MySQL error 1213 or PostgreSQL 40001. Keep related updates in one transaction, index foreign keys, and avoid long-running locks inside HTTP requests.What Are Laravel Database Transactions and Why Do Deadlocks Happen?
A transaction groups multiple SQL statements into one atomic operation: either every statement commits, or none of them do. Laravel exposes this through the query builder and Eloquent via DB::transaction(), manual beginTransaction() / commit() / rollBack(), or nested transactions using savepoints on engines that support them.
Deadlocks are a database engine problem, not a Laravel bug. Session A locks row 1, then waits for row 2. Session B locks row 2, then waits for row 1. Neither can proceed. InnoDB (MySQL) and PostgreSQL both detect the cycle, abort one session, and return an error. Laravel surfaces that as a QueryException. Your job is to design writes so cycles are rare, and to retry when the engine picks your request as the victim.
Laravel 13 runs on PHP 8.3 or higher. The database layer has not changed the fundamentals: PDO still talks to the engine, and Eloquent still issues individual queries inside your closure. What changed across recent Laravel versions is better typing, improved queue retry integration, and clearer exception messages—but the deadlock mechanics remain engine-level.
ACID Properties in Practice
- Atomicity: Order creation plus inventory decrement must succeed together. Partial commits corrupt business data.
- Consistency: Foreign keys, unique indexes, and check constraints enforce rules the application assumes.
- Isolation: Concurrent sessions see predictable snapshots depending on isolation level.
- Durability: After commit, data survives crash—assuming replication lag is handled separately.
For background on schema design that reduces lock contention, see database migrations and seeding best practices in Laravel and database indexing for performance.
How Do You Use DB::transaction() Correctly in Laravel?
The closure form is the default pattern. Laravel opens a transaction, runs your callback, commits on success, and rolls back on any throwable:
use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\Product;
DB::transaction(function () use ($cartItems, $user) {
$order = Order::create([
'user_id' => $user->id,
'total' => $cartItems->sum('line_total'),
]);
foreach ($cartItems as $item) {
$product = Product::whereKey($item['product_id'])
->lockForUpdate()
->firstOrFail();
if ($product->stock < $item['qty']) {
throw new \RuntimeException('Insufficient stock');
}
$product->decrement('stock', $item['qty']);
$order->items()->create([
'product_id' => $product->id,
'qty' => $item['qty'],
'price' => $item['price'],
]);
}
return $order;
}); Three details matter here:
lockForUpdate()takes a row-level exclusive lock until the transaction ends. Without it, two requests can both read stock = 5 and both decrement, overselling inventory.- Throwing any exception triggers rollback. Use domain exceptions for business rule failures, not silent returns inside the closure.
- Keep the closure fast. A 30-second Stripe API call inside
DB::transaction()holds locks for 30 seconds—a common source of deadlocks and timeouts.
Manual Control and Nested Transactions
DB::beginTransaction();
try {
/* writes */
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
} Laravel supports nesting via savepoints when the driver allows it. The outermost commit persists everything; an inner rollback rolls back only to its savepoint. In practice, I prefer one clear transaction boundary per use case rather than deeply nested calls spread across service classes—easier to reason about lock duration.
Eloquent models can participate too:
$user->getConnection()->transaction(function () use ($user) {
$user->wallet->decrement('balance', 500);
$user->payments()->create(['amount' => 500, 'status' => 'paid']);
}); Payment flows like those in Laravel payment integrations or Khalti integration should charge the gateway after the database transaction commits, or use idempotent webhook handlers that reconcile state outside the lock window.
What Causes Deadlocks in Laravel Apps Under Load?
Deadlocks require a cycle of waiting locks. Typical Laravel triggers:
- Inconsistent lock order: Job A updates
usersthenorders. Job B updatesordersthenusers. Cycle forms under concurrency. - Gap and next-key locks: MySQL InnoDB may lock index gaps during
INSERTor range scans, surprising teams that expected only row locks. - Missing indexes: A
WHERE status = 'pending'without an index scans many rows and locks more than intended. - Long transactions: Batch imports, report generation, or debug statements left open inside a transaction.
- Multiple connections: Reading on the read replica and writing on primary is fine; writing related rows through unrelated connection pools without a shared transaction is not.
On a digital gift card platform like Nepal Gift Card, redeeming codes while admins adjust balances creates exactly this pattern if both code paths touch gift_cards and ledger_entries in different orders. The fix is not "catch and ignore"—it is consistent ordering plus retry.
MySQL vs PostgreSQL Deadlock Signals
| Engine | Typical error | SQLSTATE | Notes |
|---|---|---|---|
| MySQL 9.7 / InnoDB | 1213 Deadlock found | 40001 | SHOW ENGINE INNODB STATUS shows latest deadlock graph |
| PostgreSQL 18 | deadlock detected | 40P01 | Also watch 40001 serialization_failure under SERIALIZABLE |
| Both via Laravel | QueryException | Wrapped in message string | Parse $e->getCode() and message; do not rely only on English text |
Official references: Laravel database transaction documentation, MySQL InnoDB deadlock documentation, and PostgreSQL explicit locking guide.
How Should You Prevent and Retry Deadlocks in Production Laravel?
Prevention beats retry, but retry is mandatory because you cannot eliminate deadlocks entirely under real concurrency.
Lock Ordering Convention
Pick a rule and enforce it in every write path:
/* Always lock parent before child, always ascending primary key */
$ids = collect($lineItems)->pluck('product_id')->sort()->values();
$products = Product::whereIn('id', $ids)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id'); Sorting IDs before lockForUpdate() is the simplest deadlock prevention technique I use on enterprise Laravel applications. Document the rule in your team's architecture guidelines so queue workers and HTTP controllers behave identically.
Automatic Retry Wrapper
Laravel's DB::transaction() accepts an optional second argument: number of attempts. On deadlock or serialization failure, Laravel retries the entire closure:
use Illuminate\Database\QueryException;
DB::transaction(function () {
/* critical section */
}, 5); That retry count is not magic—it re-runs the closure from scratch. Make your callback idempotent where possible: check existing records before insert, use unique constraints with upsert patterns, or store an idempotency key on payment rows.
For finer control:
public function runWithDeadlockRetry(callable $callback, int $maxAttempts = 5)
{
$attempt = 0;
beginning:
$attempt++;
try {
return DB::transaction($callback);
} catch (QueryException $e) {
if ($attempt < $maxAttempts && $this->isDeadlock($e)) {
usleep(random_int(50_000, 200_000));
goto beginning;
}
throw $e;
}
}
private function isDeadlock(QueryException $e): bool
{
$msg = $e->getMessage();
return str_contains($msg, '1213')
|| str_contains($msg, 'Deadlock')
|| str_contains($msg, 'deadlock detected')
|| str_contains($msg, '40001');
} Exponential or jittered backoff reduces thundering herds when many queue workers collide. Wire this into Horizon-supervised jobs processing orders, wallet transfers, or document status updates on a client portal with payments.
Isolation Levels and lockForUpdate()
MySQL InnoDB default is REPEATABLE READ with next-key locking. PostgreSQL default is READ COMMITTED. Changing isolation globally is rarely needed; targeted pessimistic locks are clearer:
/* Shared lock — others can read, not write */
$report = Invoice::where('month', $month)->sharedLock()->get();
/* Exclusive row lock */
$invoice = Invoice::whereKey($id)->lockForUpdate()->first(); lockForUpdate() must run inside an active transaction. Calling it on a plain Eloquent query outside DB::transaction() still works on MySQL autocommit—each statement becomes its own transaction—but that defeats the purpose of coordinating multi-table updates.
Optimistic locking via a version column avoids long-held row locks for low-contention edits:
public function updateWithVersion(array $attrs, int $expectedVersion): bool
{
return DB::transaction(function () use ($attrs, $expectedVersion) {
$updated = DB::table('documents')
->where('id', $this->id)
->where('version', $expectedVersion)
->update([...$attrs, 'version' => $expectedVersion + 1]);
if ($updated === 0) {
throw new \RuntimeException('Concurrent modification');
}
return true;
});
} For distributed workflows spanning multiple services, database transactions alone are insufficient—study the saga pattern for distributed transactions and CQRS when it helps before pushing cross-aggregate logic into one giant SQL transaction.
How Do You Debug Laravel Database Deadlocks on MySQL and PostgreSQL?
Reproduction beats guessing. Start from slow query and deadlock logs, then map stack traces to application entry points.
MySQL Diagnostics
SHOW ENGINE INNODB STATUS\G Scroll to LATEST DETECTED DEADLOCK. You will see the two transactions, the exact indexes, and the statements. Enable the performance schema and log queries longer than 500 ms. Cross-reference with Laravel's DB::listen() in staging or a sampled production trace.
Common fixes after reading the graph:
- Add a composite index matching your
WHEREandORDER BYso InnoDB locks fewer gaps. - Split hot rows—shard counters into bucket tables or use append-only ledger entries instead of updating one balance column millions of times.
- Move heavy reads to a replica; see database read replicas for Laravel setup.
- Ensure queue workers do not exceed sensible concurrency on single-row hotspots.
PostgreSQL Diagnostics
SELECT * FROM pg_locks WHERE NOT granted;
SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock'; PostgreSQL's deadlock message names both queries. Serialization failures under SERIALIZABLE or REPEATABLE READ with conflicting writes also deserve retry logic identical to deadlocks.
Validate fixes under load using testing and optimization practices—synthetic parallel requests beat assuming CI's single-threaded tests catch concurrency bugs. A quick way to inspect exception payloads during development is a JSON formatter on logged QueryException responses.
Queue Jobs and Cache Interaction
Queue workers often process the same aggregate faster than HTTP requests. Configure job middleware to retry deadlocks without marking the job failed:
public function handle(): void
{
DB::transaction(function () {
/* job logic */
}, 5);
}
public function backoff(): array
{
return [1, 5, 15, 30];
} Do not cache partial state mid-transaction. If you flush tags after commit, stale cache is harmless; if you flush before commit and rollback, you serve wrong data. Pair transactional writes with query caching strategies that invalidate only after success.
Redis 8.10 locks (Redlock-style) are sometimes used for cross-system coordination. They do not replace database transactions—they coordinate who may enter the transaction. Use them sparingly; incorrect TTL or clock drift creates new failure modes.
What Production Mistakes Should Laravel Teams Avoid?
These show up repeatedly on client codebases I audit:
- External I/O inside transactions: SMS gateways, PDF generation, S3 uploads, and payment API calls belong after commit or in a queued listener.
- Implicit lazy loading inside transactions: N+1 queries extend lock time. Eager load relationships before locking; see patterns in building RESTful APIs with Laravel for lean query shapes.
- Swallowing deadlock exceptions: Returning HTTP 200 after a silent catch loses orders. Log, retry, then fail loudly with a support reference.
- Migration gaps: Adding foreign keys without indexes on the referencing column slows locks on parent deletes. Plan indexes during team migration workflows.
- Never testing rollback paths: Database restore testing validates backups; transactional tests validate business invariants when commit never happens.
On legal-tech portals where document uploads and payment records must stay aligned, I keep the transaction limited to database rows and dispatch a job for email plus file virus scanning. That pattern has prevented lock pile-ups during deadline rushes when many clients submit forms simultaneously.
For ongoing monitoring after launch, pair application logs with support and maintenance runbooks that document which jobs use retry wrappers and which tables require ordered locking.
Key Takeaways
- Wrap multi-step writes in
DB::transaction()and keep closures short—no HTTP calls to payment or SMS providers while locks are held. - Prevent deadlocks by sorting IDs and locking tables in a documented, consistent order across HTTP and queue paths.
- Pass
5(or similar) as the second argument toDB::transaction()or implement jittered retry on MySQL 1213 and PostgreSQL deadlock or serialization errors. - Use
lockForUpdate()for inventory and balances; use optimistic versioning for low-contention editorial content. - Read
SHOW ENGINE INNODB STATUSorpg_lockswhen debugging— the engine tells you which queries formed the cycle. - Index every column used in transactional
WHEREclauses; missing indexes turn row locks into range locks and widen deadlock windows.
People Also Ask
Does Laravel automatically retry deadlocks?
Only if you pass an attempts count as the second parameter to DB::transaction($callback, $attempts). The default is one attempt. Laravel retries the entire closure when the driver reports a deadlock or serialization failure, then rethrows if attempts are exhausted.
What is the difference between lockForUpdate and sharedLock?
lockForUpdate() acquires an exclusive row lock for writing. sharedLock() allows other readers but blocks writers. Both require a transaction. Use exclusive locks when updating balances or stock; shared locks are rare in web apps unless you implement manual reporting consistency during long reads.
Can deadlocks happen with SQLite in Laravel tests?
SQLite locking behaves differently from MySQL or PostgreSQL. Feature tests on SQLite may never reproduce InnoDB deadlocks. Use MySQL or PostgreSQL in CI for concurrency-sensitive modules, or run dedicated stress tests against staging with realistic parallel workers.
Should I use SERIALIZABLE isolation in Laravel?
Usually no. SERIALIZABLE increases serialization failures and latency. Prefer explicit lockForUpdate(), strict lock ordering, and targeted retries. Reserve higher isolation for rare financial reconciliation scripts, not every controller action.
Ship Concurrency-Safe Laravel Data Layers
Laravel Database Transactions and Deadlocks stop being mysterious once you treat lock duration as a resource, enforce ordering, and retry deterministically. The patterns above work on Laravel 13 with PHP 8.3+, MySQL 9.7, and PostgreSQL 18—the same stack I use on production eCommerce and booking systems. If your app loses orders, double-charges wallets, or throws intermittent 500 errors under parallel requests, the fix is usually architectural, not a one-line patch. Review your hottest write paths, add ordered locking plus retry, and load-test before the next traffic spike. Need help auditing a live app? Contact us or explore recent portfolio work including Laravel eCommerce with inventory constraints.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

