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:
Read current value (e.g., stock = 5)
Modify in application memory (stock = 4)
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
Always use database transactions for related updates.
Prefer atomic operations (increment, decrement, $inc in MongoDB) over read-modify-write.
Queue non-urgent operations and use ShouldBeUnique middleware for jobs.
Add unique indexes/constraints in the database (e.g., prevent duplicate orders).
Monitor and test under load — tools like Laravel Telescope, Horizon, or Apache JMeter help.
Handle retries gracefully for optimistic locking or lock failures.
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.









