Tech Verse Logo
Enable dark mode
Handling Concurrent Writes with Laravel Pessimistic Locking

Handling Concurrent Writes with Laravel Pessimistic Locking

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

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.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Hardening a Linux VPS with Custom Port and Keys

    Hardening a Linux VPS with Custom Port and Keys

    Next.js Server Actions Concurrency and Stale Data

    Next.js Server Actions Concurrency and Stale Data

    FastAPI Dependency Injection Scope Leaks in Production

    FastAPI Dependency Injection Scope Leaks in Production

    Mastering Next.js Streaming and Suspense

    Mastering Next.js Streaming and Suspense

    Preventing Race Conditions in Laravel Seeders

    Preventing Race Conditions in Laravel Seeders

    Linux Sysctl Tuning for High-Throughput Web Servers

    Linux Sysctl Tuning for High-Throughput Web Servers

    Local Embedding Models in Production RAG

    Local Embedding Models in Production RAG

    Preventing nextjs middleware cache poisoning

    Preventing nextjs middleware cache poisoning

    Fixing Async Python Memory Leaks from Unclosed Sessions

    Fixing Async Python Memory Leaks from Unclosed Sessions