How to handle concurrency in Laravel
If your Laravel application is hit by a surge in traffic, carrying out regular database operations may quickly bring up some critical bugs. For example, suppose you have a flash sale for e-commerce where there is 1 unit left in stock: two customers trigger the "Buy Now" button simultaneously. Both database queries fetch stock value of 1, both decrease the quantity and both purchases go through, resulting in a negative inventory (-1).
Other examples of race condition problems include managing wallet balance, limiting coupon uses, seat booking, and reference counters on invoices. Standard if ($stock > 0) operation in PHP is not going to work as standard database queries do not lock database records from being read and modified concurrently.
In order to resolve race conditions and ensure data integrity at high concurrency, you may want to use database locks. In this article, we will show you how to implement Pessimistic Locks (lockForUpdate and sharedLock) and Optimistic Locking using Laravel Eloquent, explain their underlying SQL mechanisms, and decide when to use either approach.
Understanding Race Conditions: The Lost Update Problem
To see why standard queries break under concurrency, let's examine a typical wallet deduction:
// VULNERABLE TO RACE CONDITIONS
$wallet = Wallet::find($userId);
if ($wallet->balance >= $amount) {
$wallet->balance -= $amount;
$wallet->save();
}
If two requests (Request A and Request B) arrive at the exact same moment for a user with a balance of $100, and both attempt to deduct $100:
- Request A reads balance: $100.
- Request B reads balance: $100.
- Request A computes $100 - $100 = $0 and saves.
- Request B computes $100 - $100 = $0 and saves.
The user withdrew $200, but their remaining balance is $0 instead of being rejected on the second attempt. This is called a Lost Update.
Pessimistic Locking: The Strict Approach
Pessimistic locking takes into account the possibility of concurrent conflicts. The rows within the database are locked explicitly during a database transaction by the SQL engine level in pessimistic locking, making the competing requests to queue up until the ongoing transaction commits or rolls back.
There are two kinds of pessimistic locks provided in Laravel using Eloquent – lockForUpdate() and sharedLock().
Pre-requisite: For the pessimistic locks to work, there must be an active database transaction (DB::transaction()). Otherwise, the lock will automatically get released once the query is executed.
1. Exclusive Locking: lockForUpdate()
When you call lockForUpdate(), Laravel appends FOR UPDATE to your SQL SELECT statement. This applies an exclusive lock (write lock) on the matched rows.
While an exclusive lock is active on a row:
- Other transactions cannot update or delete that row.
- Other transactions requesting a
lockForUpdate()orsharedLock()on that row must wait.
Here is how to solve our wallet withdrawal safely:
namespace App\Services;
use App\Models\Wallet;
use Illuminate\Support\Facades\DB;
use Exception;
class WalletService
{
public function withdraw(int $userId, float $amount): Wallet
{
return DB::transaction(function () use ($userId, $amount) {
// Acquires an exclusive lock on this specific wallet row
$wallet = Wallet::where('user_id', $userId)
->lockForUpdate()
->firstOrFail();
if ($wallet->balance < $amount) {
throw new Exception('Insufficient funds.');
}
$wallet->balance -= $amount;
$wallet->save();
return $wallet;
});
}
}
Let's look at the raw SQL executed by Laravel:
START TRANSACTION;
SELECT * FROM `wallets` WHERE `user_id` = 1 LIMIT 1 FOR UPDATE;
UPDATE `wallets` SET `balance` = 0.00, `updated_at` = '2026-09-09 15:30:00' WHERE `id` = 1;
COMMIT;
When Request B tries to execute its SELECT ... FOR UPDATE query, MySQL halts Request B at the database level until Request A finishes COMMIT. Request B then re-reads the updated balance ($0) and immediately throws the Insufficient funds exception.
2. Shared Locking: sharedLock()
When you invoke sharedLock(), Laravel adds LOCK IN SHARE MODE (for MySQL) or FOR SHARE (for PostgreSQL). In this case, Laravel applies the shared lock (read lock) to the selected rows.
Shared lock is used to allow any other transaction to access the row for reading, while not allowing any transaction to modify or delete the row until the current transaction is completed.
Real-world Use Case: The shared lock is perfect when you read data from the parent record to ensure that the child record could be created and the parent record remains unchanged during validation.
use App\Models\Account;
use App\Models\AuditRecord;
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($accountId) {
// Read the account and guarantee nobody can modify or archive it right now
$account = Account::where('id', $accountId)
->sharedLock()
->firstOrFail();
AuditRecord::create([
'account_id' => $account->id,
'snapshot_balance' => $account->balance,
]);
});
Optimistic Locking: The Non-Blocking Approach
While pessimistic locking works reliably, holding database row locks can hurt performance if transactions take too long, potentially leading to connection pool exhaustion and deadlocks.
Optimistic locking takes the opposite stance: it assumes conflicts are rare. It never locks rows during reads. Instead, it checks whether the record was modified by another transaction before applying updates.
Implementing Optimistic Locking with a Version Column
To implement optimistic locking in Laravel, add an integer version or timestamp column to your database migration:
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('version')->default(1);
});
When updating the product, you only update if the version in the database still matches the version you initially read:
namespace App\Services;
use App\Models\Product;
use Exception;
class InventoryService
{
public function purchaseItem(int $productId, int $quantity): void
{
$product = Product::findOrFail($productId);
if ($product->stock < $quantity) {
throw new Exception('Out of stock.');
}
// Attempt conditional update matching the current version
$affectedRows = Product::where('id', $product->id)
->where('version', $product->version)
->update([
'stock' => $product->stock - $quantity,
'version' => $product->version + 1,
]);
if ($affectedRows === 0) {
// Another transaction modified the record in the meantime!
throw new Exception('Conflict detected! Please refresh and try again.');
}
}
}
If another request updated the product a fraction of a millisecond earlier, the version will no longer match. $affectedRows returns 0, preventing dirty writes without ever placing a lock on the database.
Pessimistic vs. Optimistic Locking Comparison
| Criteria | Pessimistic Locking (lockForUpdate) |
Optimistic Locking (Version Check) |
|---|---|---|
| Conflict Assumption | High conflict frequency (Flash sales, Wallets). | Low/medium conflict frequency (CMS editing). |
| Locking Mechanism | SQL-level row lock (FOR UPDATE). |
Application-level condition (WHERE version = X). |
| Database Throughput | Lower (threads wait on lock release). | Higher (no waiting threads; fails fast). |
| Deadlock Risk | Possible if locking multiple rows out of order. | Zero risk of SQL deadlocks. |
| Schema Requirement | InnoDB engine (no extra columns needed). | Requires version or timestamp column. |
Important Things to Remember
- 📌 Locks Require an Indexed WHERE Clause: In MySQL (InnoDB), row-level locks are placed on index records. If your query uses a non-indexed column in the
WHEREclause, MySQL will escalate the lock to an entire table lock, freezing all operations on that table until the transaction finishes. Always lock by primary key or indexed columns.
- ⚡ Keep Transactions Short: Never make external HTTP API calls, send emails, or dispatch slow external jobs inside a
DB::transaction()holding alockForUpdate(). Hold the lock for only the exact milliseconds needed to read and write database rows.
- ⚠️ Locking Order and Deadlocks: If Transaction 1 locks Row A then Row B, and Transaction 2 locks Row B then Row A, you will encounter a database deadlock. Always acquire locks in a predictable, consistent order (e.g., sorted by ID:
Wallet::whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get()).
- 🔄 Automatic Deadlock Recovery: Pass a retry integer as the second argument to
DB::transaction($callback, 5)so Laravel automatically retries the operation if a transient deadlock exception occurs.
Conclusion
Concurrency bugs can silently corrupt financial balances and stock counts without generating any errors in regular software code. With lockForUpdate() used for high-contention financial operations and optimistic locking for extended periods of editing tasks, it is possible to create an absolutely robust back-end system capable of handling high concurrency traffic.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
-compressed.jpg)