Two users click "Buy" at the exact same moment. Your stock check passes for both. Both orders go through. You just sold one item twice. This is a race condition - and it doesn't require thousands of users to happen. It only needs two requests hitting the same row at the same time. Laravel gives you several tools to prevent this, and choosing the right one depends on how much contention you expect and what you can afford to sacrifice in performance.
📋 Table of Contents
- ⚡ What Is a Race Condition?
- 🔒 Pessimistic Locking - Lock First, Ask Questions Later
- 🎯 Optimistic Locking - Trust But Verify
- ⚛️ Atomic Operations with Redis
- 🧪 Testing Race Conditions with Pest
- 🧱 Deadlock Prevention
- ✅ Conclusion
⚡ What Is a Race Condition?
A race condition occurs when the outcome of an operation depends on the sequence or timing of uncontrollable events - typically concurrent requests reading and writing the same data.
The classic example with stock:
// app/Actions/PlaceOrderAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Product;
use App\Models\Order;
class PlaceOrderAction
{
public function handle(int $productId, int $quantity): Order
{
$product = Product::findOrFail($productId);
// ❌ Race condition - two requests can both pass this check
if ($product->stock < $quantity) {
throw new \RuntimeException('Insufficient stock.');
}
$product->decrement('stock', $quantity);
return Order::create([
'product_id' => $productId,
'quantity' => $quantity,
]);
}
}
Between the if check and the decrement, another request can read the same stock value, pass the check, and decrement independently. You end up with stock = -1.
This isn't theoretical. It happens under normal load on any endpoint without protection.
🔒 Pessimistic Locking - Lock First, Ask Questions Later
Pessimistic locking tells the database: "I'm going to read this row, and nobody else is allowed to modify it until I'm done." It's implemented with SELECT ... FOR UPDATE under the hood.
// app/Actions/PlaceOrderAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class PlaceOrderAction
{
public function handle(int $productId, int $quantity): Order
{
return DB::transaction(function () use ($productId, $quantity) {
// Locks the row - other transactions wait here
$product = Product::lockForUpdate()->findOrFail($productId);
if ($product->stock < $quantity) {
throw new \RuntimeException('Insufficient stock.');
}
$product->decrement('stock', $quantity);
return Order::create([
'product_id' => $productId,
'quantity' => $quantity,
]);
});
}
}
lockForUpdate() acquires an exclusive lock on the row. Any other transaction trying to read the same row with lockForUpdate() will block until the first transaction commits or rolls back.
**sharedLock()** is the read-only variant - multiple transactions can hold a shared lock simultaneously, but none of them can modify the row until all shared locks are released:
// Use when you need to read and guarantee the row won't change,
// but you don't intend to modify it yourself
$product = Product::sharedLock()->findOrFail($productId);
Transaction isolation levels matter here. The default in MySQL is REPEATABLE READ. If you need READ COMMITTED for specific operations:
DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
DB::transaction(function () {
// ...
});
When to use pessimistic locking:
- High contention on the same rows (flash sales, seat reservations, inventory)
- Short transactions (lock is held briefly)
- You can afford to block concurrent requests temporarily
When not to use it:
- Long-running transactions - locks block other users the whole time
- Low contention scenarios - the overhead isn't worth it
- Distributed systems across multiple databases -
lockForUpdate()is per-database
🎯 Optimistic Locking - Trust But Verify
Optimistic locking doesn't lock anything. Instead it adds a version column to the row. When you update, you check that the version hasn't changed since you read it. If it has - someone else modified it - you retry or fail.
Laravel doesn't have built-in optimistic locking, but it's straightforward to implement:
Migration:
// database/migrations/2026_03_01_add_version_to_products_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('version')->default(0)->after('stock');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('version');
});
}
};
Action with optimistic locking:
// app/Actions/PlaceOrderAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class PlaceOrderAction
{
private const MAX_RETRIES = 3;
public function handle(int $productId, int $quantity): Order
{
$attempts = 0;
while ($attempts < self::MAX_RETRIES) {
$product = Product::findOrFail($productId);
if ($product->stock < $quantity) {
throw new \RuntimeException('Insufficient stock.');
}
$updated = DB::table('products')
->where('id', $productId)
->where('version', $product->version) // The guard
->update([
'stock' => $product->stock - $quantity,
'version' => $product->version + 1,
]);
if ($updated === 1) {
// Our update won - create the order
return Order::create([
'product_id' => $productId,
'quantity' => $quantity,
]);
}
// Someone else updated first - retry
$attempts++;
}
throw new \RuntimeException('Could not complete order after ' . self::MAX_RETRIES . ' attempts.');
}
}
The where('version', $product->version) condition is the key. If another request incremented the version between our read and update, $updated will be 0 and we retry with fresh data.
When to use optimistic locking:
- Low to medium contention - most updates succeed on first try
- Read-heavy workflows where writes are occasional
- Distributed systems where database-level locks are impractical
When not to use it:
- High contention - too many retries degrade performance
- Operations that can't be retried cleanly (side effects like emails sent)
⚛️ Atomic Operations with Redis
For operations that don't touch the database directly - rate limiting, distributed counters, one-time processing - Redis provides atomic operations through Cache::lock().
// app/Actions/ProcessPaymentAction.php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
class ProcessPaymentAction
{
public function handle(int $orderId): void
{
$lock = Cache::lock("order.payment.{$orderId}", seconds: 10);
if (! $lock->get()) {
throw new \RuntimeException('Payment already being processed.');
}
try {
$order = Order::findOrFail($orderId);
if ($order->isPaid()) {
return; // Idempotent - already done
}
// Process payment...
$order->markAsPaid();
} finally {
$lock->release();
}
}
}
Cache::lock() uses Redis SET NX PX under the hood - an atomic operation. The seconds: 10 is a TTL; if the process crashes, the lock expires automatically.
**block() - wait for the lock instead of failing immediately:**
// app/Actions/ProcessPaymentAction.php
$lock = Cache::lock("order.payment.{$orderId}", seconds: 10);
$lock->block(seconds: 5); // Wait up to 5 seconds for the lock
try {
// Safe to run - we have the lock
$order->markAsPaid();
} finally {
$lock->release();
}
Owner tokens - for locks across queue jobs:
// app/Jobs/ProcessPaymentJob.php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
class ProcessPaymentJob implements ShouldQueue
{
use Queueable;
public string $lockOwner;
public function __construct(public readonly int $orderId)
{
$lock = Cache::lock("order.payment.{$orderId}", seconds: 60);
$this->lockOwner = $lock->acquire(); // Returns the owner token
}
public function handle(): void
{
// Restore the lock with the owner token - only this job can release it
Cache::restoreLock("order.payment.{$this->orderId}", $this->lockOwner)
->release();
}
}
Owner tokens prevent a scenario where a slow job holds a lock that has already expired and been acquired by another process.
When to use Redis locks:
- Preventing duplicate job processing (
ShouldBeUniqueuses this internally) - Rate limiting expensive operations
- Coordinating across multiple queue workers
- Any operation where database-level locking doesn't apply
Requirement: Your CACHE_STORE must be redis (not file or array) for locks to work across multiple processes.
🧪 Testing Race Conditions with Pest
Testing concurrency is hard in PHP because it's single-threaded by default. The best approach is to simulate the sequence of events that causes the race condition.
Test pessimistic locking:
// tests/Feature/PlaceOrderTest.php
declare(strict_types=1);
use App\Actions\PlaceOrderAction;
use App\Models\Product;
it('prevents overselling with pessimistic locking', function () {
$product = Product::factory()->create(['stock' => 1]);
$action = app(PlaceOrderAction::class);
// Simulate two concurrent requests by calling sequentially
// with the same initial state
$action->handle($product->id, 1);
expect(fn () => $action->handle($product->id, 1))
->toThrow(\RuntimeException::class, 'Insufficient stock.');
expect($product->fresh()->stock)->toBe(0);
});
Test optimistic locking retry:
// tests/Feature/PlaceOrderOptimisticTest.php
declare(strict_types=1);
use App\Actions\PlaceOrderAction;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
it('retries when version has changed', function () {
$product = Product::factory()->create(['stock' => 2, 'version' => 0]);
// Simulate another process incrementing the version between our read and write
DB::table('products')
->where('id', $product->id)
->update(['version' => 1, 'stock' => 1]);
// The action should retry and succeed on the next attempt
$order = app(PlaceOrderAction::class)->handle($product->id, 1);
expect($order)->not->toBeNull();
expect($product->fresh()->stock)->toBe(0);
});
it('throws after max retries', function () {
$product = Product::factory()->create(['stock' => 5, 'version' => 0]);
// Simulate version always changing (always losing the race)
DB::shouldReceive('table->where->where->update')->andReturn(0);
expect(fn () => app(PlaceOrderAction::class)->handle($product->id, 1))
->toThrow(\RuntimeException::class, 'Could not complete order');
});
Test Redis lock:
// tests/Feature/ProcessPaymentTest.php
declare(strict_types=1);
use App\Actions\ProcessPaymentAction;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
it('prevents duplicate payment processing', function () {
$order = Order::factory()->unpaid()->create();
// Hold the lock externally to simulate concurrent processing
Cache::lock("order.payment.{$order->id}", 10)->get();
expect(fn () => app(ProcessPaymentAction::class)->handle($order->id))
->toThrow(\RuntimeException::class, 'Payment already being processed.');
});
🧱 Deadlock Prevention
A deadlock happens when two transactions each hold a lock the other needs, and both wait indefinitely. MySQL detects this and kills one of them with an error.
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock
The most common cause: two transactions locking the same rows in different orders.
// Transaction A: locks order, then tries to lock product
// Transaction B: locks product, then tries to lock order
// → Deadlock
Fix: always lock in the same order.
// app/Actions/PlaceOrderAction.php
DB::transaction(function () use ($productId, $orderId) {
// Always lock lower ID first - consistent order prevents deadlocks
$ids = collect([$productId, $orderId])->sort()->values();
foreach ($ids as $id) {
Product::lockForUpdate()->find($id);
}
// Now safe to proceed
});
Adjust the wait timeout - the default is 50 seconds, which means blocked requests hang for nearly a minute:
// config/database.php (or set per-transaction)
DB::statement("SET innodb_lock_wait_timeout = 5");
With 5 seconds, a blocked transaction fails fast and you can handle it cleanly instead of leaving users waiting.
Catch and retry deadlocks:
// app/Actions/PlaceOrderAction.php
use Illuminate\Database\QueryException;
public function handle(int $productId, int $quantity): Order
{
$attempts = 0;
while ($attempts < 3) {
try {
return DB::transaction(function () use ($productId, $quantity) {
$product = Product::lockForUpdate()->findOrFail($productId);
if ($product->stock < $quantity) {
throw new \RuntimeException('Insufficient stock.');
}
$product->decrement('stock', $quantity);
return Order::create([
'product_id' => $productId,
'quantity' => $quantity,
]);
});
} catch (QueryException $e) {
if ($e->getCode() !== '40001') {
throw $e; // Not a deadlock - re-throw
}
$attempts++;
usleep(random_int(10_000, 100_000)); // Random backoff in microseconds
}
}
throw new \RuntimeException('Could not complete transaction due to deadlock.');
}
The random backoff (usleep) reduces the chance of two processes retrying at exactly the same moment and deadlocking again.
✅ Conclusion
- Race conditions don't require high traffic - two concurrent requests are enough to trigger them
- Use pessimistic locking (
lockForUpdate()inside a transaction) for high-contention, short-lived operations like inventory and seat reservations - Use optimistic locking (version column + conditional update) for low-contention scenarios where reads vastly outnumber writes
- Use Redis
Cache::lock()for distributed coordination, duplicate job prevention, and anything outside the database - Always write a test that simulates the race condition before and after the fix - it documents the bug and guards against regression
- For deadlock prevention: lock rows in a consistent order, shorten
innodb_lock_wait_timeout, and add retry logic with random backoff
Follow me on LinkedIn for more Laravel tips!