Back to articles

Handling Race Conditions in Laravel

It was 2020. I was building a fintech application, confident in my logic. The code looked perfect. Validation was strict, database transactions were in place, and unit tests were passing.


Then, it happened.


Users started reporting "double credits" to their wallets. A single transfer was being processed twice, sometimes thrice, within split seconds. Money was being created out of thin air. I had just been burnt by a race condition.


If you've never encountered one, a race condition feels like a ghost in the machine. It’s the bug that only appears when your app is under load, the one you can't replicate on your local machine because you're the only user.


The Anatomy of a Race Condition


A race condition occurs when two or more processes attempt to access and manipulate the same resource concurrently, and the outcome of the execution depends on the sequence or timing of those events.


In web applications, this often manifests as the "Time of Check to Time of Use" (TOCTOU) bug. Stanford researchers and security experts have extensively documented this class of vulnerability, often highlighting how standard web architectures—where requests are handled in parallel—are inherently susceptible to it without explicit safeguards.


Here is the classic scenario that burnt me:


Request A comes in to transfer $100. It checks the balance, sees the user has funds, and proceeds. But milliseconds later, Request B comes in for another transfer. It also checks the balance. Because Request A hasn't finished deducting the money yet, Request B also sees the funds are there. Both requests proceed to deduct the money, and suddenly, the user has spent more than they own.


Both requests saw the same initial state, so both proceeded to act on it. The gap between checking the balance and updating it is where the race happens.


Why Transactions Aren't Enough


A common misconception is that wrapping code in a database transaction solves this. It doesn't.


Transactions ensure atomicity (all or nothing), but they don't inherently prevent concurrency issues unless you use the right isolation levels or locking mechanisms. In the example above, if both requests are inside their own transactions, they are isolated from each other until they commit. Standard isolation levels often allow both to read the "original" unmodified data.


The Laravel Solution: Atomic Locks


One of the most elegant ways to solve this in Laravel is using Atomic Locks.


Laravel provides a simple interface for atomic locks via the cache driver. This allows you to obtain a "lock" for a specific key (like a user ID) before performing an action. If another process holds the lock, your code waits or fails gracefully.


It's like a bathroom key at a gas station. Only one person can hold it at a time. If someone else has it, you wait.


Here is how you can use it to fix the wallet double-spend issue:

use Illuminate\Support\Facades\Cache;

// Attempt to get a lock for 10 seconds
$lock = Cache::lock("wallet_transfer_{$userId}", 10);

if ($lock->get()) {
    try {
        // 1. Check Balance
        if ($user->balance < $amount) {
            throw new Exception("Insufficient funds");
        }

        // 2. Deduct Money
        $user->decrement('balance', $amount);
        
    } finally {
        $lock->release();
    }
} else {
    // Could not get lock, maybe ask user to try again
    abort(429, "Too many requests.");
}

Or even cleaner, using the block method, which waits for the lock instead of failing immediately:

Cache::lock("wallet_transfer_{$userId}", 10)->block(5, function () use ($user, $amount) {
    // This code only runs when the lock is acquired.
    
    $user->refresh(); // CRITICAL: Get fresh data after waiting!
    
    if ($user->balance < $amount) {
        throw new Exception("Insufficient funds");
    }

    $user->decrement('balance', $amount);
});

Using block is often smoother because it queues the requests. Request B will simply wait a few milliseconds for Request A to finish, and then proceed.

Another Approach: Pessimistic Locking


If your logic is strictly database-bound, you can use Pessimistic Locking directly in your query.

DB::transaction(function () use ($userId, $amount) {
    // "lockForUpdate" prevents any other transaction from 
    // reading or writing to this row until WE are done.
    $user = User::where('id', $userId)->lockForUpdate()->first();

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

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

This is incredibly robust because the database engine itself enforces the order. Request B will strictly pause at the lockForUpdate() line until Request A commits its transaction.

The Optimist's Choice: Versioning


Sometimes, you don't want to lock the database for every read. Locking builds queues, and queues slow down your app. Enter Optimistic Locking.

The philosophy here is: "Let everyone read the data, but only the first one to save wins."

You implement this by adding a version (or updated_at timestamp) check to your update query.

// 1. Read the user (e.g., version is 5)
$user = User::find($userId);

// 2. Perform checks
if ($user->balance < $amount) {
    throw new Exception("Insufficient funds");
}

// 3. Update ONLY if the version matches what we read
$affected = User::where('id', $userId)
    ->where('version', $user->version) // Crucial Check!
    ->update([
        'balance' => $user->balance - $amount,
        'version' => $user->version + 1, // Increment version
    ]);

if ($affected === 0) {
    // Zero rows affected means someone else changed the version 
    // while we were thinking. The race was lost.
    throw new Exception("Data was modified by another process. Please try again.");
}

This approach is lock-free, making it extremely fast for reads. However, it shifts the burden of handling failures to the user (or a retry mechanism), as "losing" the race means your request fails entirely.


Wrapping Up


Concurrency bugs are humbling. They teach you that code doesn't run in a vacuum. It runs in a chaotic, parallel world where milliseconds matter.


Whether you choose atomic locks or database-level locking depends on your use case. Just remember: checking a condition and acting on it are two separate steps. If you don't bridge that gap with a lock, you're leaving the door open for a race condition. And trust me, eventually, someone—or something—will walk through it.

Home
About
Work
Projects
Articles
Notes
Theme