Race conditions will quietly corrupt your database if you let them. Imagine a flash sale where ten background workers read the same stock count of one at the exact same millisecond. Each worker checks if stock is greater than zero, decrements the counter, and saves the record. You just sold a single item ten times because standard read operations don't block concurrent access. Read more about protecting your data integrity by checking out Preventing Race Conditions in Laravel Seeders or handling edge cases through Query Builder vs Eloquent: Real Cost in Laravel.
Optimistic locking using version columns works fine for low-traffic admin panels, but it falls apart under heavy write concurrency. You'll end up catching retry exceptions all day long. Pessimistic locking takes the opposite approach. It tells the database engine to hold an exclusive lock on the selected rows until your transaction finishes. No other connection can read or write those specific rows until you commit your work.
Implementing Row-Level Locks with Eloquent
Eloquent provides the lockForUpdate method on the query builder. You must wrap this inside a database transaction, or the database driver will throw an exception because there's no active transaction context to hold the lock. Here is how you handle an inventory decrement safely inside a service class without guessing if the state changed halfway through your code execution.
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class InventoryService
{
public function decrementStock(int $productId, int $quantity): void
{
DB::transaction(function () use ($productId, $quantity) {
$product = Product::where('id', $productId)
->lockForUpdate()
->firstOrFail();
if ($product->stock < $quantity) {
throw new RuntimeException('Insufficient stock available.');
}
$product->stock -= $quantity;
$product->save();
});
}
}
That lockForUpdate call translates to a SELECT ... FOR UPDATE statement in MySQL and PostgreSQL. When worker process A hits that line, the database locks the row for product ID 42. When worker process B arrives a millisecond later, its query pauses right there on the database connection, waiting for process A to commit or roll back. Once process A finishes, process B wakes up, reads the freshly updated stock value, and fails immediately because the stock is gone. It's a clean way to ensure consistency.
You shouldn't rely on standard application-level checks alone. If you skip database locks, PHP's execution model allows multiple processes to fetch stale records concurrently. By the time the first process writes its update, the second process is already holding outdated state in memory. Pessimistic locking forces serialization at the database engine level where the storage engine has a global view of concurrent sessions.
Handling Tenant Isolation and Deadlocks
Multi-tenant applications introduce another layer of pain. If your tenants share a database schema with tenant IDs on every table, you need to ensure your locked queries include the tenant scope. Otherwise, you might lock rows incorrectly or run into severe cross-tenant contention that brings your entire SaaS platform to a crawl.
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TenantInventoryService
{
public function reserveStock(int $tenantId, int $productId, int $quantity): void
{
DB::transaction(function () use ($tenantId, $productId, $quantity) {
$product = Product::where('tenant_id', $tenantId)
->where('id', $productId)
->lockForUpdate()
->firstOrFail();
if ($product->stock < $quantity) {
throw new RuntimeException('Stock level too low.');
}
$product->decrement('stock', $quantity);
});
}
}
Deadlocks happen when two transactions acquire locks on multiple rows in opposing orders. Transaction one locks row A then tries to lock row B. Transaction two locks row B then tries to lock row A. The database engine steps in, kills one of the transactions, and throws a deadlock error. Laravel handles transaction retries if you pass an integer as the second argument to DB::transaction, telling it how many times to try again before blowing up in your face.
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
class ResilientInventoryService
{
public function batchUpdate(int $tenantId, array $items): void
{
DB::transaction(function () use ($tenantId, $items) {
foreach ($items as $item) {
Product::where('tenant_id', $tenantId)
->where('id', $item['id'])
->lockForUpdate()
->update([
'stock' => DB::raw('stock - ' . $item['quantity'])
]);
}
}, 5);
}
}
Passing five as the retry count tells Laravel to attempt the entire closure up to five times if a deadlock exception surfaces from the database driver. You'll want to keep your transactions short. Don't make external HTTP requests or heavy file operations inside the closure, because holding database locks while waiting on a third-party API will tank your application throughput fast.
Avoiding Common Traps with Shared States
Performance tuning requires careful consideration of what happens outside the transaction block. If you fetch records, process complex business rules for ten seconds, and only then apply lockForUpdate, you've defeated the purpose of fast serialization. You're holding open database connections and blocking other threads for far too long. Keep the data retrieval and the lock acquisition inside the same tight transaction window.
Another common mistake involves mixing read-only queries with write locks unnecessarily. You don't need lockForUpdate for standard dashboard reads or user profile pages where stale data for a split second won't hurt anything. Save pessimistic locks strictly for atomic operations where precision matters, such as financial transactions, coupon code redemptions, and seat reservations. Your database server will thank you when traffic spikes.













