6 min read

Laravel in Production: Avoiding Race Conditions

A race condition is one of those bugs that can remain invisible during development and suddenly become a serious problem in production.

Your code may work perfectly when one user performs an action.

Then hundreds of users do the same thing at nearly the same time.

And suddenly, your application produces a result that should have been impossible.

A classic example is inventory management.

Imagine a product has only one item left in stock.

Two customers try to purchase it at almost exactly the same time.

If the application isn't designed to handle concurrent requests correctly, both customers could successfully purchase the last item.

Let's see why.


The Race Condition

Suppose our products table contains:

Product: Laravel T-Shirt
Stock:   1

Our checkout code might initially look perfectly reasonable:

$product = Product::findOrFail($productId);

if ($product->stock > 0) {
    $product->stock--;
    $product->save();

    // Create order...
}

At first glance, there doesn't seem to be anything wrong with this code.

But consider what happens when two requests arrive at almost the same time.

Request A                    Request B

Read stock = 1               Read stock = 1

stock > 0 ✓                  stock > 0 ✓

stock = 0                    stock = 0

Save                         Save

Create order ✓               Create order ✓

The application has now sold two items even though only one was available.

This is a race condition.


Why This Happens

The problem isn't the if statement itself.

The problem is that reading the value and updating the value are separate operations.

Between those operations, another request can read the same data.

This is known as a time-of-check to time-of-use problem.

In simplified terms:

Check → Wait → Use

Another request can modify the data during that gap.

And the more concurrent your application becomes, the more important this becomes.


Solution 1: Atomic Updates

For simple inventory operations, you can often avoid the race condition entirely by letting the database perform the operation atomically.

Instead of:

$product = Product::findOrFail($productId);

if ($product->stock > 0) {
    $product->stock--;
    $product->save();
}

Use a conditional update:

$updated = Product::whereKey($productId)
    ->where('stock', '>', 0)
    ->decrement('stock');

Now the database performs the check and update as one operation.

You can check whether anything was actually updated:

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

The important part is:

stock > 0
     +
decrement stock
     =
one database operation

There is no opportunity for another request to sneak between the check and the update.


Solution 2: Pessimistic Locking

Atomic updates are excellent when the operation is simple.

But sometimes the business logic is more complicated.

For example:

Check inventory
↓
Apply discount
↓
Reserve inventory
↓
Create order
↓
Create payment

In situations where you need to read a record, perform several operations, and then update it, a database lock can be more appropriate.

Laravel provides lockForUpdate() for this purpose.

DB::transaction(function () use ($productId) {

    $product = Product::whereKey($productId)
        ->lockForUpdate()
        ->firstOrFail();

    if ($product->stock <= 0) {
        throw new OutOfStockException();
    }

    $product->decrement('stock');

    // Create order...
});

The important part is:

->lockForUpdate()

The database locks the selected row until the transaction completes.

Another transaction attempting to acquire the same lock has to wait.

The flow becomes:

Transaction A
↓
Lock product
↓
Check stock
↓
Decrease stock
↓
Commit
↓
Release lock

Transaction B
↓
Wait for lock
↓
Read updated stock
↓
See stock = 0
↓
Reject purchase

Now only one customer can successfully purchase the final item.


When Should You Use Which?

There isn't one solution that should be used everywhere.

Use an atomic update when:

  • The operation is simple.
  • You only need to increment or decrement a value.
  • The database can express the condition directly.
  • You don't need to perform several dependent operations between the read and write.

For example:

Product::whereKey($productId)
    ->where('stock', '>', 0)
    ->decrement('stock');

Use lockForUpdate() when:

  • You need to read a record first.
  • Multiple operations depend on that value.
  • Several related database changes must happen together.
  • You need to protect a critical section inside a transaction.

For example:

DB::transaction(function () use ($productId) {

    $product = Product::whereKey($productId)
        ->lockForUpdate()
        ->firstOrFail();

    // Multiple dependent operations...
});

What About Cache::lock()?

Database locks aren't the only option.

Laravel also provides distributed atomic locks through Cache::lock().

For example:

Cache::lock(
    "product:{$productId}",
    10
)->block(5, function () use ($productId) {

    // Critical section...
});

This can be useful when the operation isn't limited to a single database transaction or when you need coordination across multiple application servers.

However, a cache lock and a database transaction solve different problems.

A cache lock can coordinate application-level access, while a database transaction and row lock protect database consistency.

Choose the mechanism based on what you're actually trying to protect.


A Common Mistake

One of the most common mistakes is assuming that this is safe:

if ($product->stock > 0) {
    $product->decrement('stock');
}

It isn't necessarily safe.

The value was read before the update.

Another request could change the stock between those operations.

The safer version is to make the condition part of the database operation:

Product::whereKey($productId)
    ->where('stock', '>', 0)
    ->decrement('stock');

The database can then guarantee that the condition and update happen atomically.


Race Conditions Aren't Just About Inventory

Inventory is an easy example to understand, but the same problem appears in many other systems.

Wallet balances

Balance = ₹1,000

Request A → Withdraw ₹800
Request B → Withdraw ₹800

Both see ₹1,000

Without proper concurrency control, the account could end up with an invalid balance.

Coupon usage

Remaining uses = 1

Request A → Apply coupon
Request B → Apply coupon

Both requests may consume the final usage.

Seat reservations

Available seats = 1

User A → Reserve seat
User B → Reserve seat

Without concurrency control, the same seat could be assigned twice.

Subscription limits

Plan limit = 10 projects

Two requests create projects simultaneously.

Both may observe the same count before either request updates it.


The Bigger Lesson

Race conditions aren't caused by Laravel.

They're caused by concurrent access to shared state.

Laravel gives you several tools to handle them:

  • Atomic database updates
  • Database transactions
  • lockForUpdate()
  • Cache::lock()
  • Unique database constraints
  • Optimistic locking patterns

The important part isn't knowing every tool.

It's recognizing when your code contains this pattern:

Read shared state
↓
Make a decision
↓
Modify shared state

Whenever multiple requests can execute that sequence concurrently, you should ask yourself:

What happens if two requests execute this at exactly the same time?

That question alone can prevent some very expensive production bugs.


Final Thoughts

A race condition can be difficult to reproduce locally because everything appears to work when requests arrive one at a time.

Production is different.

Multiple users, multiple workers, multiple servers, retries, queues, and concurrent requests can expose problems that were invisible during development.

That's why production-ready Laravel applications need to consider not only what happens when the code runs, but also what happens when the code runs concurrently.

When shared state is involved, don't just ask:

"Does this code work?"

Ask:

"What happens if two requests execute this code simultaneously?"

That's where production engineering begins.

Featured Projects

Beyond enterprise work, I build open-source tools and "spare-time" projects to push the boundaries of the Laravel ecosystem. Explore a collection of my technical experiments and side-hustles.

Open Source
Laravel Packages
SaaS Boilerplates