Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Database Transactions and Deadlocks

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.

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 Transaction LifecycleHTTP RequestController / JobDB::transaction()BEGIN implicitSQL WritesRow locks heldCOMMIT successROLLBACK errorDeadlock risk rises when two transactions lock the same rows in opposite orderKeep transactions short; never call external APIs while holding locks
How Laravel database transactions and deadlocks relate: locks acquired during writes persist until commit or rollback.

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:

  1. 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.
  2. Throwing any exception triggers rollback. Use domain exceptions for business rule failures, not silent returns inside the closure.
  3. 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 users then orders. Job B updates orders then users. Cycle forms under concurrency.
  • Gap and next-key locks: MySQL InnoDB may lock index gaps during INSERT or 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.
Classic Deadlock CycleTransaction ALocks Row 1 firstTransaction BLocks Row 2 firstRow 1Row 2holdsholdswaits for Row 2waits for Row 1Engine aborts one victim
Laravel database transactions and deadlocks often start with two workers locking the same rows in reverse sequence.

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

EngineTypical errorSQLSTATENotes
MySQL 9.7 / InnoDB1213 Deadlock found40001SHOW ENGINE INNODB STATUS shows latest deadlock graph
PostgreSQL 18deadlock detected40P01Also watch 40001 serialization_failure under SERIALIZABLE
Both via LaravelQueryExceptionWrapped in message stringParse $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.

Deadlock Handling Decision TreeQueryExceptionDeadlock or 40001?NoFail / alert opsYesAttempts left?Retry with jitter
Production flow for Laravel database transactions and deadlocks: detect, backoff, retry, then escalate.

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:

  1. Add a composite index matching your WHERE and ORDER BY so InnoDB locks fewer gaps.
  2. Split hot rows—shard counters into bucket tables or use append-only ledger entries instead of updating one balance column millions of times.
  3. Move heavy reads to a replica; see database read replicas for Laravel setup.
  4. 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.

Lock Strategy ComparisonPessimistic lockForUpdateBest for inventory and walletsShort transaction requiredDeadlock risk if order variesStrong consistencyOptimistic version columnBest for document editsRetry on version mismatchLower lock contentionScales read-heavy workloads
Choosing lock strategy reduces Laravel database transactions and deadlocks on high-contention tables.

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 to DB::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 STATUS or pg_locks when debugging— the engine tells you which queries formed the cycle.
  • Index every column used in transactional WHERE clauses; 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

They group multiple SQL statements into one atomic operation: either every statement commits, or none do. Laravel exposes this through DB::transaction(), manual beginTransaction/commit/rollBack, or nested savepoints on supported engines.

Deadlocks happen when two sessions acquire row or table locks in conflicting order and each waits on the other. Typical Laravel triggers include inconsistent lock order across jobs, MySQL gap and next-key locks on INSERT or range scans, missing indexes that widen locked rows, long transactions holding locks during batch work or debugging, and related writes split across unrelated connection pools. Laravel surfaces the engine error as a QueryException—it is not a framework bug.

Pass a closure: Laravel opens a transaction, runs your callback, commits on success, and rolls back on any throwable. For inventory or balance updates, use lockForUpdate() inside the closure so concurrent requests cannot read stale stock. Throw domain exceptions for business rule failures instead of silent returns. Keep the closure fast—external calls like Stripe or SMS inside a 30-second transaction hold locks that long and invite deadlocks and timeouts.

lockForUpdate() takes a row-level exclusive lock until the transaction ends. Without it, two checkout requests can both read stock of 5 and both decrement, overselling inventory. It must run inside an active transaction when coordinating multi-table updates. On MySQL autocommit each statement is its own transaction, which defeats coordinated writes. For low-contention edits, optimistic locking via a version column avoids long-held row locks instead of pessimistic locking every row.

InnoDB returns error 1213 with SQLSTATE 40001. Laravel wraps it in a QueryException—parse getCode() and the message string rather than relying on English text alone.

Prevention reduces frequency but cannot eliminate deadlocks under real concurrency. DB::transaction() accepts an optional second argument for attempt count—on deadlock Laravel re-runs the entire closure from scratch, so make callbacks idempotent with unique constraints or idempotency keys. For finer control, catch QueryException, detect 1213, Deadlock, deadlock detected, or 40001 in the message, apply jittered backoff between 50 and 200 milliseconds, and retry up to five times before escalating. Queue jobs should pair this with backoff arrays like 1, 5, 15, 30 seconds.

Both arrive as QueryException. MySQL 9.7 InnoDB returns error 1213 with SQLSTATE 40001—SHOW ENGINE INNODB STATUS reveals the latest deadlock graph with both transactions and exact indexes. PostgreSQL 18 returns deadlock detected with SQLSTATE 40P01, and the message names both queries. PostgreSQL also throws 40001 serialization_failure under SERIALIZABLE or conflicting writes under REPEATABLE READ, which deserves identical retry logic. Default isolation differs: MySQL InnoDB uses REPEATABLE READ with next-key locking; PostgreSQL defaults to READ COMMITTED.

Enforce consistent lock ordering everywhere—sort product IDs ascending before lockForUpdate(), always lock parent rows before child rows, and document the rule so HTTP controllers and queue workers behave identically. Index foreign keys and WHERE columns used in transactional updates so engines lock fewer gaps. Keep write scopes short, move heavy reads to replicas, and limit queue worker concurrency on single-row hotspots. Split hot counters into bucket tables or append-only ledger entries instead of updating one balance column millions of times.

No. External I/O inside transactions—SMS gateways, PDF generation, S3 uploads, and payment API calls—is one of the most common production mistakes. A Stripe or Khalti call inside the transaction holds row locks for the entire HTTP round trip. Charge the gateway after the database transaction commits, or use idempotent webhook handlers that reconcile payment state outside the lock window. The same rule applies to cache: flush tags only after commit, never mid-transaction before rollback.

Laravel supports nesting via savepoints when the database driver allows it. The outermost commit persists everything; an inner rollback rolls back only to its savepoint, not the entire outer transaction. In practice, one clear transaction boundary per use case is easier to reason about than deeply nested calls spread across service classes. Eloquent models participate through their connection: user->getConnection()->transaction() wraps wallet decrements and payment record creation atomically.

Run SHOW ENGINE INNODB STATUS and scroll to LATEST DETECTED DEADLOCK to see both transactions, indexes, and exact statements. Enable the performance schema and log queries longer than 500 ms. Cross-reference with Laravel DB::listen() in staging or sampled production traces. Common fixes after reading the graph: add composite indexes matching WHERE and ORDER BY, split hot rows, ensure queue concurrency stays sensible on contested rows, and validate under synthetic parallel load because single-threaded CI tests miss concurrency bugs.

Query pg_locks WHERE NOT granted and pg_stat_activity WHERE wait_event_type equals Lock to see waiting sessions. PostgreSQL's deadlock message names both conflicting queries directly. Serialization failures under SERIALIZABLE or REPEATABLE READ with conflicting writes need the same retry wrapper as deadlocks. During development, log QueryException payloads with a JSON formatter to inspect exception details quickly. Reproduce under load with parallel requests rather than guessing from stack traces alone.

Pass an attempt count as the second argument—e.g. 5—when writes run under concurrent queue workers or checkout load so Laravel automatically re-runs the closure on deadlock.

Optimistic locking uses a version column: update only where id and version match the expected value, increment version on success, and throw if zero rows updated meaning another session changed the row first. It avoids long-held exclusive locks and suits low-contention document or profile edits. lockForUpdate() suits high-contention inventory decrements or wallet transfers where reading stale data causes overselling. Choose pessimistic locks for financial hot paths; choose version columns when conflicts are rare and retrying a user edit is acceptable.

External I/O inside transactions, implicit N+1 lazy loading that extends lock duration, swallowing deadlock exceptions and returning HTTP 200 while losing orders, foreign keys without indexes on referencing columns, and never testing rollback paths. Do not cache partial state mid-transaction. Redis locks coordinate who enters a transaction but do not replace database atomicity—incorrect TTL or clock drift creates new failure modes. On legal-tech portals where document uploads and payment records must stay aligned, keep the transaction limited to database writes and reconcile everything else after commit.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: