Tech Verse Logo
Enable dark mode
A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

Tech Verse Daily

Tech Verse Daily

4 min read

Race conditions are one of the most dangerous and hard-to-detect bugs in web applications. They occur when multiple concurrent requests access and modify shared resources (like database records) simultaneously, leading to inconsistent or incorrect data.

In Laravel apps, common symptoms include:

  • Oversold inventory during flash sales

  • Negative account balances or double deductions

  • Duplicate records or lost updates

  • Incorrect counters or ticket bookings

These issues rarely appear in local development or low-traffic testing but can cost real money and damage trust in production. In this guide, you'll learn how to detect race conditions in Laravel, understand why they happen, and fix them using proven techniques like database transactions, pessimistic locking, optimistic locking, atomic operations, and cache locks.

What Are Race Conditions and Why Do They Matter in Laravel?

A race condition happens during a read-modify-write (RMW) pattern:

  1. Read current value (e.g., stock = 5)

  2. Modify in application memory (stock = 4)

  3. Write back to database

If two requests read the same value before either writes, both may succeed, resulting in lost updates (stock ends up at 4 instead of 3).

Common Laravel scenarios:

  • E-commerce checkout (inventory + wallet balance)

  • Ticket or seat booking systems

  • User balance/wallet transfers

  • Like/counter increments

  • High-traffic API endpoints

Scenario 1: Vulnerable E-commerce Checkout (The Classic Race Condition)

Imagine a flash sale with limited stock. Here's a naive implementation that fails under concurrency.

public function checkout(Request $request)
{
    $validated = $request->validate([
        'user_id' => 'required|exists:users,id',
        'product_id' => 'required|exists:products,id',
        'quantity' => 'required|integer|min:1',
    ]);

    $product = Product::findOrFail($validated['product_id']);
    $user = User::findOrFail($validated['user_id']);

    $amount = $product->price * $validated['quantity'];

    if ($user->balance < $amount) {
        return response()->json(['error' => 'Insufficient funds'], 400);
    }

    if ($product->stock < $validated['quantity']) {
        return response()->json(['error' => 'Insufficient stock'], 400);
    }

    // Vulnerable read-modify-write
    $user->balance -= $amount;
    $user->save();

    $product->stock -= $validated['quantity'];
    $product->save();

    Order::create([
        'user_id' => $user->id,
        'product_id' => $product->id,
        'quantity' => $validated['quantity'],
        'amount' => $amount,
    ]);

    return response()->json(['success' => true]);
}

Why this fails: Two users can both read stock = 1 and balance >= amount, then both deduct, resulting in negative stock or overselling.

Detecting Race Conditions with Concurrent Testing

Use Laravel's feature tests with parallel or simulated concurrent requests.

public function test_concurrent_checkout_prevents_overselling()
{
    $product = Product::factory()->create(['stock' => 1]);
    $users = User::factory(5)->create(['balance' => 1000]);

    $responses = [];

    // Simulate 5 concurrent requests
    foreach ($users as $user) {
        $responses[] = $this->postJson('/api/checkout', [
            'user_id' => $user->id,
            'product_id' => $product->id,
            'quantity' => 1,
        ]);
    }

    // Wait for all (in real tests, use Promise or parallel testing tools)
    $product->refresh();

    $this->assertEquals(0, $product->stock, 'Stock should not go negative');
    $this->assertEquals(1, Order::where('product_id', $product->id)->count(), 'Only one order should be created');
}

Run this test multiple times. The vulnerable version will often show negative stock or multiple orders.

Solution 1: Database Transactions with Pessimistic Locking (Recommended for Most Cases)

Wrap operations in a transaction and use lockForUpdate() to prevent other processes from reading or modifying the row.

use Illuminate\Support\Facades\DB;

public function checkout(Request $request)
{
    return DB::transaction(function () use ($request) {
        $validated = $request->validate([...]);

        // Lock both records
        $product = Product::where('id', $validated['product_id'])
            ->lockForUpdate()
            ->firstOrFail();

        $user = User::where('id', $validated['user_id'])
            ->lockForUpdate()
            ->firstOrFail();

        $amount = $product->price * $validated['quantity'];

        if ($user->balance < $amount) {
            throw new \Exception('Insufficient funds');
        }

        if ($product->stock < $validated['quantity']) {
            throw new \Exception('Insufficient stock');
        }

        $user->balance -= $amount;
        $user->save();

        $product->stock -= $validated['quantity'];
        $product->save();

        $order = Order::create([...]);

        return ['success' => true, 'order' => $order];
    });
}

Benefits:

  • Ensures atomicity

  • Prevents concurrent modifications

  • Works great with MySQL, PostgreSQL, etc.

Note: Keep transactions short to avoid deadlocks and performance issues. Use sharedLock() when you only need to prevent writes (not reads).

Solution 2: Atomic Database Operations (Best for Simple Increments/Decrements)

Use Laravel's decrement() or raw queries with conditions for true atomicity.

// Atomic stock reduction with condition
$updated = Product::where('id', $productId)
    ->where('stock', '>=', $quantity)
    ->decrement('stock', $quantity);

if ($updated === 0) {
    // Out of stock
}

For wallet:

$updated = User::where('id', $userId)
    ->where('balance', '>=', $amount)
    ->decrement('balance', $amount);

if ($updated === 0) {
    // Insufficient funds
}

This is extremely fast and avoids locking overhead in many cases.

Solution 3: Optimistic Locking (Great for High Concurrency, Low Conflict)

Add a version column (integer) to your models. Increment it on every update and check it before saving.

// In your Model
protected $fillable = ['...', 'version'];

// In checkout (inside transaction or separately)
$product = Product::findOrFail($productId);

if ($product->stock < $quantity) {
    // out of stock
}

$updated = Product::where('id', $productId)
    ->where('version', $product->version)
    ->update([
        'stock' => $product->stock - $quantity,
        'version' => $product->version + 1
    ]);

if ($updated === 0) {
    // Conflict! Retry or fail
    throw new \Exception('Stock changed by another request. Please try again.');
}

Laravel doesn't have built-in optimistic locking, but this pattern is simple and scalable.

Solution 4: Laravel Cache Atomic Locks (For Non-Database or Distributed Logic)

Use Redis-backed locks for critical sections.

use Illuminate\Support\Facades\Cache;

public function checkout(...)
{
    $lock = Cache::lock("checkout-product-{$productId}", 10); // 10 seconds

    if ($lock->get()) {
        try {
            // Perform the full checkout logic here
            return $this->processCheckout($request);
        } finally {
            $lock->release();
        }
    }

    return response()->json(['error' => 'Please try again later'], 429);
}

Ideal for queue jobs or when combining multiple resources.

Additional Best Practices to Prevent Race Conditions in Laravel

  1. Always use database transactions for related updates.

  2. Prefer atomic operations (increment, decrement, $inc in MongoDB) over read-modify-write.

  3. Queue non-urgent operations and use ShouldBeUnique middleware for jobs.

  4. Add unique indexes/constraints in the database (e.g., prevent duplicate orders).

  5. Monitor and test under load — tools like Laravel Telescope, Horizon, or Apache JMeter help.

  6. Handle retries gracefully for optimistic locking or lock failures.

  7. For MongoDB: Use atomic operators like $inc with conditions ($gte) and check getModifiedCount().

Performance Considerations

  • Pessimistic locking can cause contention under very high traffic — consider optimistic locking or sharding.

  • Short transactions = better scalability.

  • Use Redis for cache/locks in production for speed.

  • Test with tools like artisan test --parallel or load testing.

    Latest Posts

    View All

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    Handling Large Datasets with Pagination and Cursors in Laravel MongoDB: Offset vs Cursor Pagination

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    A Complete Guide: Detecting and Fixing Race Conditions in Laravel Applications

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0

    PestPHP Intellisense in Laravel VS Code Extension v1.7.0

    Laravel Starter Kits Now Come with Built-in Toast Notifications

    Laravel Starter Kits Now Come with Built-in Toast Notifications

    Implement Laravel Search in a Right Way

    Implement Laravel Search in a Right Way

    Installing FreeSWITCH 1.10.X on Ubuntu 18.04 | 20.04 | 22.04 LTS

    Installing FreeSWITCH 1.10.X on Ubuntu 18.04 | 20.04 | 22.04 LTS

    Introducing the Laravel AI SDK — Build Smarter Apps with AI

    Introducing the Laravel AI SDK — Build Smarter Apps with AI

    Laravel AI SDK: Building AI-Powered Applications the Laravel Way

    Laravel AI SDK: Building AI-Powered Applications the Laravel Way

    Getting Started with Mago – The Fastest PHP Tooling Chain

    Getting Started with Mago – The Fastest PHP Tooling Chain

    Best Stack Recommendations for Laravel Projects (Battle-Tested in Production)

    Best Stack Recommendations for Laravel Projects (Battle-Tested in Production)