Your Laravel App Has a Race Condition. You Just Haven’t Noticed Yet.

Atomic locks, database-level locking, optimistic vs pessimistic concurrency — most Laravel apps process concurrent requests as if they’re the only request in the world. Here’s how to find the race conditions hiding in your code and fix them before your users do.


Most race conditions don’t manifest in development. You test with one browser, one user, one request at a time. Everything works perfectly. Then you ship to production, a hundred users hit the same endpoint simultaneously, and the bugs appear — oversold inventory, duplicate orders, double-charged payments, corrupted counters.

By then, the evidence is gone. The HTTP requests completed. The database shows the wrong state. Nobody can reproduce it. It’s a “known issue that only happens occasionally” until it costs you a customer or money.

Race conditions in Laravel are not exotic. They follow predictable patterns, they have well-understood fixes, and they can be found by reading code rather than by waiting for production to break.


What a Race Condition Actually Is

A race condition occurs when the correctness of a computation depends on the relative timing of two or more concurrent operations. In a web application, this typically means two requests that each read the same data, make a decision based on it, and then write — without knowing the other request is doing the same thing.

The classic pattern:

Request A: reads stock = 1        Request B: reads stock = 1
Request A: checks stock >= 1 ✓   Request B: checks stock >= 1 ✓
Request A: places order           Request B: places order
Request A: decrements stock → 0   Request B: decrements stock → -1

Both requests read 1. Both checks pass. Both orders are placed. The product is now oversold. The check-then-act pattern is the root of nearly every race condition.


The Five Most Common Race Conditions in Laravel Apps

1. The Inventory Oversell

The most common e-commerce race condition:

// ✗ Race condition — check and decrement are two separate operations
public function placeOrder(Product $product, int $qty): Order
{
    // Request A and B both read stock = 5 here
    if ($product->stock < $qty) {
        throw new InsufficientStockException($product, $qty);
    }

    // Both checks pass — both proceed to this point
    $product->decrement('stock', $qty);

    return Order::create([...]);
}

If two requests read stock = 5 before either writes, both decrement successfully. Stock goes from 5 → 4 → 3, not 5 → 3 as expected. Ten concurrent requests for the last item in stock will all succeed.

2. The Coupon Use-Once Race

// ✗ Race condition — validate and increment are two separate operations
public function applyCoupon(string $code, User $user): Discount
{
    $coupon = Coupon::where('code', $code)->firstOrFail();

    // Two requests for the same single-use coupon both see used_count = 0
    if ($coupon->used_count >= $coupon->max_uses) {
        throw new CouponExhaustedException($code);
    }

    // Both pass the check and both increment
    $coupon->increment('used_count');

    return new Discount($coupon->discount_percent);
}

A coupon with max_uses = 1 gets used twice. A coupon with max_uses = 100 gets used 150 times during a flash sale.

3. The Duplicate Submission

// ✗ Race condition — double form submission or double API call
public function createSubscription(User $user, Plan $plan): Subscription
{
    // User clicks "Subscribe" twice quickly — both requests run this check
    if ($user->subscriptions()->where('plan_id', $plan->id)->exists()) {
        throw new AlreadySubscribedException();
    }

    // Both checks return false — both create subscriptions
    return Subscription::create([
        'user_id' => $user->id,
        'plan_id' => $plan->id,
        'status'  => 'active',
    ]);
}

The user now has two active subscriptions. They’re charged twice. Your support inbox lights up.

4. The “Check Then Create” UniqueID Race

// ✗ Race condition — generating a "unique" identifier without atomicity
public function generateOrderNumber(): string
{
    $last = Order::max('order_number');  // both requests might read the same max
    $next = $last + 1;

    return str_pad($next, 8, '0', STR_PAD_LEFT);
    // Two orders can get the same number
}

5. The Wallet Double-Spend

// ✗ Race condition — read balance, check, deduct in non-atomic steps
public function deductBalance(User $user, int $amount): void
{
    $wallet = $user->wallet;

    // Two concurrent withdrawals both read balance = 100
    if ($wallet->balance < $amount) {
        throw new InsufficientFundsException();
    }

    // Both checks pass for a 60-unit withdrawal (100 >= 60 ✓)
    // Both deduct 60 — balance ends at -20
    $wallet->decrement('balance', $amount);
}

Fix Strategy 1: Atomic Database Operations

The simplest fix for many race conditions is replacing read-then-write patterns with a single atomic database operation. The database engine guarantees atomicity for single statements.

Atomic Decrement with a Where Clause

// ✓ Atomic — the check and decrement happen in one SQL statement
public function placeOrder(Product $product, int $qty): Order
{
    // UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?
    $updated = Product::where('id', $product->id)
        ->where('stock', '>=', $qty)
        ->decrement('stock', $qty);

    // $updated is the number of rows affected (0 or 1)
    if ($updated === 0) {
        throw new InsufficientStockException($product->fresh(), $qty);
    }

    return Order::create([
        'product_id' => $product->id,
        'quantity'   => $qty,
    ]);
}

The WHERE stock >= ? condition and the decrement happen atomically. If two requests execute this simultaneously, the database serialises the updates — only the first one that finds stock >= qty will succeed; the second will find stock - qty < qty and return 0 rows affected.

Atomic Increment with Max Check

// ✓ Atomic coupon increment with max_uses guard
$updated = Coupon::where('code', $code)
    ->whereColumn('used_count', '<', 'max_uses')
    ->increment('used_count');

if ($updated === 0) {
    throw new CouponExhaustedException($code);
}

Fix Strategy 2: Database-Level Locking (Pessimistic)

When a single atomic operation isn’t enough — when the business logic between the read and the write is too complex — use pessimistic locking to serialise access at the database level.

Pessimistic locking assumes conflicts will happen. It acquires a lock on the row as soon as it reads it, preventing other transactions from modifying it until the lock is released.

lockForUpdate() — Exclusive Lock

// ✓ Pessimistic locking — serialises concurrent access to this row
public function deductBalance(User $user, int $amount): void
{
    DB::transaction(function () use ($user, $amount) {
        // SELECT * FROM wallets WHERE user_id = ? FOR UPDATE
        // Other requests wanting to modify this row must wait until this transaction completes
        $wallet = Wallet::where('user_id', $user->id)->lockForUpdate()->first();

        if ($wallet->balance < $amount) {
            throw new InsufficientFundsException();
        }

        $wallet->decrement('balance', $amount);
    });
    // Lock is released when the transaction commits
}

lockForUpdate() acquires an exclusive lock on the row. Any other SELECT ... FOR UPDATE on the same row will block until this transaction finishes. The second request will then read the updated balance (after the first transaction committed) and correctly evaluate its own check.

sharedLock() — Shared Read Lock

// Use sharedLock() when multiple readers should see consistent data
// but you don't need exclusive write access
$balance = Wallet::where('user_id', $user->id)
    ->sharedLock()
    ->value('balance');

sharedLock() prevents other transactions from modifying the row while allowing other shared locks to read it. Use it when you need a consistent read but aren’t writing.

The Complete Order Placement with Pessimistic Locking

public function placeOrder(PlaceOrderRequest $request): Order
{
    return DB::transaction(function () use ($request) {
        // Lock all product rows we're about to modify
        $productIds = collect($request->items)->pluck('id');
        $products   = Product::whereIn('id', $productIds)
            ->lockForUpdate()
            ->get()
            ->keyBy('id');

        // Now we have exclusive locks — check inventory safely
        foreach ($request->items as $item) {
            $product = $products[$item['id']];
            if ($product->stock < $item['qty']) {
                throw new InsufficientStockException($product, $item['qty']);
            }
        }

        // Create the order and update stock atomically within the transaction
        $order = Order::create([
            'user_id' => $request->user()->id,
            'status'  => 'pending',
            'total'   => $this->calculateTotal($products, $request->items),
        ]);

        foreach ($request->items as $item) {
            $order->items()->create([
                'product_id' => $item['id'],
                'quantity'   => $item['qty'],
                'price'      => $products[$item['id']]->price,
            ]);

            $products[$item['id']]->decrement('stock', $item['qty']);
        }

        return $order;
    });
}

Lock ordering matters. When locking multiple rows in one transaction, always lock them in the same order across all transactions (e.g., ordered by ID). Inconsistent lock ordering is the classic cause of deadlocks: Transaction A locks row 1 then waits for row 2; Transaction B locks row 2 then waits for row 1. Neither can proceed.

// ✓ Always lock in the same order to prevent deadlocks
$products = Product::whereIn('id', $productIds)
    ->orderBy('id')         // consistent ordering prevents deadlocks
    ->lockForUpdate()
    ->get()
    ->keyBy('id');

Fix Strategy 3: Optimistic Locking

Optimistic locking assumes conflicts are rare. Instead of blocking concurrent reads with a database lock, it adds a version column to the table and checks it on write. If the version has changed since the read, another transaction modified the data — the write fails and the operation should be retried.

Setting Up Optimistic Locking

// Migration — add a version column
Schema::table('products', function (Blueprint $table) {
    $table->unsignedBigInteger('version')->default(0);
});
// Model — update with version check
class Product extends Model
{
    public function updateWithOptimisticLock(array $attributes): bool
    {
        $currentVersion = $this->version;

        $updated = static::where('id', $this->id)
            ->where('version', $currentVersion)  // check version hasn't changed
            ->update([
                ...$attributes,
                'version' => $currentVersion + 1,  // increment version
            ]);

        if ($updated === 0) {
            throw new OptimisticLockException(
                "Product #{$this->id} was modified by another request. Please retry."
            );
        }

        $this->version = $currentVersion + 1;
        return true;
    }
}
// Usage with retry logic
public function updateProductPrice(Product $product, int $newPrice): void
{
    $maxAttempts = 3;

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            $product->fresh()->updateWithOptimisticLock(['price' => $newPrice]);
            return;  // success
        } catch (OptimisticLockException $e) {
            if ($attempt === $maxAttempts) throw $e;
            usleep(rand(10_000, 50_000));  // wait 10-50ms before retry
        }
    }
}

When to Use Optimistic vs Pessimistic Locking

Pessimistic (lockForUpdate):
✓ High-conflict scenarios — many users competing for the same limited resource
✓ Short, fast transactions — the lock time is minimal
✓ When correctness is non-negotiable and retries are unacceptable (payments, orders)
✓ Simple read-modify-write patterns

Optimistic (version check):
✓ Low-conflict scenarios — most reads don't conflict with concurrent writes
✓ Long operations where holding a database lock would be too expensive
✓ When retry is acceptable and cheap
✓ Read-heavy workloads where conflicts are rare exceptions
✗ Never for payment processing or inventory — the retry cost on conflict is too high

Fix Strategy 4: Cache Atomic Locks (Redis)

For operations that don’t fit neatly into a database transaction — coordinating distributed systems, preventing duplicate job processing, rate-limiting operations across servers — Redis atomic locks are the tool.

Laravel’s Cache::lock() uses Redis SET NX PX (set if not exists, with expiry) which is an atomic Redis command. Either your code acquires the lock and runs, or it doesn’t.

Basic Cache Lock

use Illuminate\Support\Facades\Cache;

// ✓ Prevent duplicate subscription creation using a Redis lock
public function createSubscription(User $user, Plan $plan): Subscription
{
    $lockKey = "subscription:{$user->id}:{$plan->id}";

    return Cache::lock($lockKey, seconds: 10)->block(
        seconds: 5,            // wait up to 5 seconds to acquire the lock
        callback: function () use ($user, $plan) {
            // Only one request enters this block at a time
            if ($user->subscriptions()->where('plan_id', $plan->id)->exists()) {
                throw new AlreadySubscribedException();
            }

            return Subscription::create([
                'user_id' => $user->id,
                'plan_id' => $plan->id,
                'status'  => 'active',
            ]);
        }
    );
}

The get/release Pattern for More Control

public function processPayout(int $userId): void
{
    $lock = Cache::lock("payout:{$userId}", seconds: 60);

    if (!$lock->get()) {
        // Another request is already processing this user's payout
        throw new PayoutAlreadyProcessingException($userId);
    }

    try {
        // Lock acquired — process the payout
        $this->executePayoutLogic($userId);
    } finally {
        $lock->release();  // always release, even if an exception occurs
    }
}

Owner-Based Lock Release

For situations where a lock might be acquired in one request and released in another (e.g., a long-running job):

// Request 1: acquire the lock and store the owner token
$lock  = Cache::lock("export:{$userId}", seconds: 300)
$token = $lock->owner();

if ($lock->get()) {
    // Store the token so another process can release it
    Cache::put("export:{$userId}:token", $token, seconds: 300);
    dispatch(new GenerateExportJob($userId, $token));
    return response()->json(['status' => 'processing']);
}

// Job process: release using the stored token
class GenerateExportJob implements ShouldQueue
{
    public function handle(): void
    {
        try {
            $this->generateExport();
        } finally {
            // Release the lock using the owner token
            Cache::restoreLock("export:{$this->userId}", $this->token)->release();
        }
    }
}

Fix Strategy 5: Database Unique Constraints

The database is your last line of defence. For truly unique values — one subscription per user per plan, one vote per user per post, one email registration — a unique constraint at the database level is non-negotiable, regardless of application-level checks.

// Migration — unique constraint prevents duplicates even if application code fails
Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('plan_id')->constrained();
    $table->string('status');
    $table->timestamps();

    // This is the safety net — database will reject duplicate rows
    $table->unique(['user_id', 'plan_id']);
});
// Handle the unique constraint violation gracefully
use Illuminate\Database\QueryException;

public function createSubscription(User $user, Plan $plan): Subscription
{
    try {
        return Subscription::create([
            'user_id' => $user->id,
            'plan_id' => $plan->id,
            'status'  => 'active',
        ]);
    } catch (QueryException $e) {
        // Error code 23000 = integrity constraint violation (includes unique)
        if ($e->getCode() === '23000') {
            throw new AlreadySubscribedException();
        }
        throw $e;
    }
}

Combining a database unique constraint with an application-level lock gives you defence in depth: the lock prevents duplicate work; the constraint prevents duplicate data even if the lock fails.


Finding Race Conditions in Your Existing Code

Knowing the patterns makes them findable by code review. These are the specific things to look for:

Pattern: Check-Then-Act

// ✗ Flag it — check and act are two separate database operations
if ($product->stock >= $qty) {       // read
    $product->decrement('stock', $qty);  // write
}

Every if statement that reads a value from the database and then modifies it is a potential race condition. Ask: “What happens if two requests both pass this if-check before either executes the modification?”

Pattern: Read-Increment-Write

// ✗ Flag it — three non-atomic operations
$current = $counter->value;  // read
$counter->update(['value' => $current + 1]);  // write

Always use increment() or decrement() — or raw SQL with UPDATE table SET col = col + 1 — for counters. Never read the current value and write the new value as separate operations.

Pattern: First-or-Create Without Guard

// ✗ Can create duplicates if two requests hit simultaneously
$subscription = Subscription::firstOrCreate([
    'user_id' => $user->id,
    'plan_id' => $plan->id,
]);
// firstOrCreate is NOT atomic — it's a SELECT then INSERT
// Two concurrent requests can both pass the SELECT with no result
// and both execute the INSERT
// ✓ Two options:
// Option 1: Use updateOrCreate with a database unique constraint as the backstop
// Option 2: Wrap in a cache lock before the firstOrCreate

Note: firstOrCreate (and updateOrCreate) are not atomic. They perform a SELECT, then an INSERT if no record is found. Two concurrent requests can both find no record and both insert. Always pair with a unique constraint.

Pattern: Manual Unique ID Generation

// ✗ Two requests can read the same maximum and generate the same ID
$nextId = Order::max('id') + 1;

Use database auto-increment for IDs. Use UUIDs generated in application code (which are astronomically unlikely to collide). Never generate sequential IDs by reading the current maximum.


A Complete Audit Checklist

Walk through your codebase looking for these patterns:

Database checks:
✓ Every if() checking a database value before writing → should use atomic operation or lock
✓ Every read followed by decrement/increment of the same field → use atomic WHERE condition
✓ Every firstOrCreate() → is there a unique constraint backing it?
✓ Every max() used to generate sequential values → replace with auto-increment or UUID
✓ Every "check user hasn't done X, then let them do X" → needs a lock or unique constraint

Transactions:
✓ Every multi-step operation that must succeed or fail together → wrap in DB::transaction()
✓ Every lockForUpdate() → is lock ordering consistent to prevent deadlocks?
✓ Every transaction → is the duration as short as possible?

Queue jobs:
✓ Every job that processes a unique resource (user payout, order fulfillment) →
  does it prevent duplicate processing with a cache lock?
✓ Every job that checks then modifies → is the check inside the same lock as the modification?

Cache locks:
✓ Every Cache::lock() → does it have a finally block releasing the lock?
✓ Every Cache::lock() timeout → is it long enough for the operation to complete?
✓ Every distributed operation → is it protected against the same operation running on multiple servers?

The Deadlock: When Locks Create Their Own Problem

Deadlocks occur when two transactions each hold a lock and each want the other’s lock. Both wait forever. The database times out one of them and returns an error.

Transaction A: locks user row 42, wants to lock wallet row 42
Transaction B: locks wallet row 42, wants to lock user row 42
→ Neither can proceed → deadlock → one is rolled back with an error

Fixing Deadlocks: Consistent Lock Ordering

// ✗ Different lock ordering can deadlock
// Transaction A:
User::where('id', $userId)->lockForUpdate()->first();
Wallet::where('user_id', $userId)->lockForUpdate()->first();

// Transaction B (different endpoint, same resources):
Wallet::where('user_id', $userId)->lockForUpdate()->first();
User::where('id', $userId)->lockForUpdate()->first();

// ✓ Consistent ordering — always lock User before Wallet
// Define a convention: alphabetical by table name, or by "importance"
// and stick to it everywhere

Detecting Deadlocks in Laravel

// Catch deadlock errors and retry
use Illuminate\Database\QueryException;

public function transferFunds(int $fromId, int $toId, int $amount): void
{
    $maxAttempts = 3;

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            DB::transaction(function () use ($fromId, $toId, $amount) {
                // Always lock in ID order to prevent deadlocks
                $ids     = collect([$fromId, $toId])->sort()->values();
                $wallets = Wallet::whereIn('user_id', $ids)
                    ->orderBy('user_id')
                    ->lockForUpdate()
                    ->get()
                    ->keyBy('user_id');

                // Deduct from sender
                if ($wallets[$fromId]->balance < $amount) {
                    throw new InsufficientFundsException();
                }
                $wallets[$fromId]->decrement('balance', $amount);

                // Add to receiver
                $wallets[$toId]->increment('balance', $amount);
            });

            return;  // success

        } catch (QueryException $e) {
            // MySQL deadlock error code: 1213
            if ($e->getCode() !== '40001' && $attempt < $maxAttempts) {
                usleep(rand(10_000, 100_000));  // wait 10-100ms before retry
                continue;
            }
            throw $e;
        }
    }
}

Final Thoughts

Race conditions are not advanced problems. They’re ordinary problems that appear ordinary codebases the moment multiple users do the same thing at the same time. The check-then-act pattern is in most Laravel applications. The coupon and inventory patterns are in most e-commerce applications. The duplicate submission pattern is in every application with a form.

The fixes are not complicated either:

  • Atomic database operations with WHERE guards for simple counters and stock
  • lockForUpdate() inside transactions for complex multi-step operations
  • Cache::lock() for distributed coordination and duplicate prevention
  • Database unique constraints as the last line of defence
  • Consistent lock ordering to prevent deadlocks

The hard part is finding them — and the way to find them is knowing the patterns. Read your code with this question: “What happens if two requests execute this simultaneously?” Every if that reads from the database, every increment that reads before writing, every “check if exists then insert” — these are the places to look.

The race condition that costs you a customer is the one you didn’t find in code review. The one you did find is a bug you fixed in 20 minutes.

Leave a Reply

Your email address will not be published. Required fields are marked *