Skip to content
OpenCloseEsc
Blog

7 min read

Preventing race conditions with atomic locks in Laravel

Two requests, one seat, two owners: how the check-then-write gap bites, and when to reach for Cache::lock versus lockForUpdate.

There's a bug that only shows up when two people do the same thing at the same time. In testing you'll never see it. You click the button, it works. Your colleague clicks the button, it works. Then one day in production two users click within the same few milliseconds and you end up with two orders for one seat, or a customer charged twice, or a coupon used more times than it should be.

That's a race condition. Here's how I handle them in Laravel, with the simplest tool that actually works.

The problem

Say you're selling seats for an event. The obvious code looks like this:

public function reserve(int $seatId)
{
    $seat = Seat::findOrFail($seatId);
 
    if (! $seat->is_available) {
        abort(409, 'Seat already taken');
    }
 
    $seat->update([
        'is_available' => false,
        'user_id'      => auth()->id(),
    ]);
 
    return $seat;
}

Reads fine. Check if it's free, then take it. The trouble is the gap between the check and the update. It's tiny, a few milliseconds, but it's there.

Now imagine two requests arrive almost together:

  1. Request A loads the seat. is_available is true.
  2. Request B loads the seat. is_available is still true — A hasn't saved yet.
  3. Request A passes the check, updates the row.
  4. Request B passes the check too (it already read true), updates the row.

Both users get a success page. One seat, two owners. And nothing in your logs looks wrong, because each request on its own did exactly what the code said.

The same shape shows up everywhere: "check balance, then deduct", "check stock, then sell", "check if the coupon was used, then mark it used". Any time you read something and then write based on what you read, two requests can slip through the gap together.

The fix: let only one request in at a time

What we want is simple. For a given seat, only one request should be allowed to run that check-and-update block at once. Everyone else should wait their turn.

Laravel's atomic locks do exactly that:

use Illuminate\Support\Facades\Cache;
 
public function reserve(int $seatId)
{
    return Cache::lock("seat:{$seatId}", 10)->block(5, function () use ($seatId) {
        $seat = Seat::findOrFail($seatId);
 
        if (! $seat->is_available) {
            abort(409, 'Seat already taken');
        }
 
        $seat->update([
            'is_available' => false,
            'user_id'      => auth()->id(),
        ]);
 
        return $seat;
    });
}

Walking through the two numbers:

  • Cache::lock("seat:{$seatId}", 10) — a lock named after this specific seat, that expires on its own after 10 seconds. The expiry is a safety net: if the process dies mid-way, the lock doesn't stay stuck forever.
  • ->block(5, ...) — try to get the lock, and if someone else has it, wait up to 5 seconds for them to finish. Once we have it, run the callback and release the lock when it's done.

Now the race looks like this:

  1. Request A takes the lock for seat:42, loads the seat, it's free, reserves it, releases the lock.
  2. Request B was waiting. It now gets the lock, loads the seat, sees is_available is false, and gets a 409.

One seat, one owner. The second user gets a clear "already taken" message instead of a false success.

Notice the lock is per seat. Two people reserving different seats don't wait on each other at all. That matters — you don't want one global lock that turns your whole app into a single-file queue.

A couple of small things worth knowing:

  • If block can't get the lock within the wait time, it throws Illuminate\Contracts\Cache\LockTimeoutException. For a short block like this it's rare, but it's worth catching and showing a "please try again" message rather than a 500.
  • Atomic locks need a cache driver that supports them. Redis, Memcached, database and file drivers all do. The array driver works too but only within a single process, so it's fine for tests and useless in production.

The other option: database row locks

You don't always need the cache for this. If the thing you're protecting lives in one database row, you can ask the database to lock that row for you:

public function reserve(int $seatId)
{
    return DB::transaction(function () use ($seatId) {
        $seat = Seat::where('id', $seatId)
            ->lockForUpdate()
            ->firstOrFail();
 
        if (! $seat->is_available) {
            abort(409, 'Seat already taken');
        }
 
        $seat->update([
            'is_available' => false,
            'user_id'      => auth()->id(),
        ]);
 
        return $seat;
    });
}

lockForUpdate() tells MySQL (or Postgres) to hold that row until the transaction ends. Any other transaction that tries to lockForUpdate() the same row simply waits. Same result as the cache lock, but the database is doing the work.

It only works inside a transaction. Outside one, the lock is released as soon as the query finishes, which is before your update runs, so you're back to the original bug.

Which one should you use?

I use this as a rough guide:

Use lockForUpdate when the whole operation is "read a row, write that same row, done" and it's all in one database. It's simpler, there's no cache involved, and the lock disappears automatically when the transaction commits or fails.

Use Cache::lock when the critical section is bigger than one row. For example: check the seat, call a payment API, then save. You don't want to hold a database transaction open while waiting on an external HTTP call, but you still want to stop a second request from starting the same flow. A cache lock covers that gap cleanly. It's also the only option if the thing you're protecting isn't a database row at all, like "don't run this report generation twice at the same time."

Both are fine. The important thing is to use one of them any time you see the "check, then write" pattern on something that shouldn't happen twice.

A note on the if check

People sometimes ask: if the lock stops two requests running together, do I still need the is_available check inside?

Yes. The lock only makes requests take turns. It doesn't know what the first request did. The second request still has to look at the row and discover the seat is now gone. The lock gives you the ordering, the check gives you the decision. You need both.

Wrapping up

Race conditions are annoying because they don't show up until you have real traffic, and when they do, each request looks innocent on its own. The fix is small though: wrap the check-and-write in a lock so only one request can be inside at a time. Cache::lock for the general case, lockForUpdate when it's all about one row.

Once you've seen the pattern, you start noticing it in a lot of places. That's a good thing. It's much cheaper to add a lock up front than to explain to a customer why their seat was sold twice.