[ Daryl Legion ]

Race Conditions in Laravel: Pessimistic vs Optimistic Locking

August 17, 2026 • 12 min read

In a previous post we used atomic locks to stop the same user from submitting the same form twice. That solves one problem, but it says nothing about a different one: two different users hitting the same endpoint at the same instant, each reading the same row, each deciding they are allowed to proceed. Both requests are legitimate. Both pass validation. Both write. And we oversell the last unit of stock. In this blog post, we will look at how to close that window with database-level locking.

A race condition happens when the correctness of an operation depends on the timing of other operations running alongside it.

Let’s take a look at an example PurchaseController that looks perfectly reasonable:

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;

class PurchaseController
{
    public function __invoke(Request $request, Product $product)
    {
        /* validate the request */

        if ($product->stock < $request->integer('quantity')) {
            return back()->withErrors(['quantity' => 'Not enough stock available.']);
        }

        $product->decrement('stock', $request->integer('quantity'));

        return $product->orders()->create([
            'user_id' => $request->user()->id,
            'quantity' => $request->integer('quantity'),
        ]);
    }
}

There is one unit of stock left and two requests arrive a millisecond apart:

TimeRequest ARequest Bstock
t1reads stock = 11
t2reads stock = 11
t3passes the check1
t4passes the check1
t5decrements0
t6decrements-1

Both requests read before either wrote, so both saw stock = 1. We have sold two units of a product we had one of, and the stock column is now negative.

Note that decrement itself is not the bug. It compiles to set stock = stock - 1, which is atomic at the database level, so the arithmetic is never lost. The bug is the check at t3 and t4: we read a value, made a decision from it, and by the time we acted the value had changed underneath us. This is a classic check-then-act race.

Whether we actually see that -1 depends on the column type. If stock is an unsignedInteger, MySQL refuses the underflow and the second request dies with a QueryException wrapping ERROR 1690: BIGINT UNSIGNED value is out of range. That is better than silent corruption, but it is not a fix. We have still oversold the product, we have just traded a wrong row for a 500 and a stack trace, and which of the two requests loses is down to timing rather than anything we decided. It is worth knowing that this backstop is MySQL-specific too. Laravel’s Postgres and SQLite schema grammars have no unsigned modifier at all, so unsignedInteger() compiles to a plain integer on those drivers and the column drops to -1 exactly as the table shows.

Why atomic locks are the wrong tool here

The instinct after reading the atomic locks post is to reach for Cache::lock again. It does technically work, but it is the wrong instrument for this job:

  • The lock lives in the cache, not the database, so it protects nothing against a queue worker, an artisan command, or a second application that touches your table without knowing the convention.
  • The lock has a TTL. If the request takes longer than the TTL, the lock expires while the work is still running and a second request walks straight in.
  • It gives us no consistency guarantee with the transaction that actually performs the write. The lock can be released while the transaction is still uncommitted.

Atomic locks are for coordinating application-level actions such as “do not dispatch this job twice.” For protecting an invariant on a row, the guarantee has to live where the row lives, in the database.

Pessimistic locking with lockForUpdate

Pessimistic locking assumes a conflict is likely and takes the lock up front. In Laravel, that is lockForUpdate, which compiles to SELECT ... FOR UPDATE:

// PurchaseController

use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

public function __invoke(Request $request, Product $product)
{
    /* validate the request */

    $quantity = $request->integer('quantity');

    return DB::transaction(function () use ($request, $product, $quantity) {
        $locked = Product::query()
            ->whereKey($product->getKey())
            ->lockForUpdate()
            ->firstOrFail();

        if ($locked->stock < $quantity) {
            throw ValidationException::withMessages([
                'quantity' => 'Not enough stock available.',
            ]);
        }

        $locked->decrement('stock', $quantity);

        return $locked->orders()->create([
            'user_id' => $request->user()->id,
            'quantity' => $quantity,
        ]);
    });
}

Request A acquires the row lock and holds it until its transaction commits. Request B blocks on the SELECT until that happens, and only then reads stock = 0 and correctly fails the check. The window between the read and the write is gone.

Three things about this code are easy to get wrong.

The lock must be inside a transaction. Row locks are held until the transaction ends. Without an explicit transaction, the connection is in autocommit mode, so the statement commits the moment it finishes and the lock is released immediately, which makes the whole thing a silent no-op. The Laravel documentation phrases this as “while not obligatory, it is recommended”, which is fair about the API and misleading about the outcome. For a read we intend to act on, treat it as obligatory.

We re-query the model rather than locking the one we already have. The $product injected by route model binding was fetched before the transaction opened, so its attributes are already stale. It is tempting to reach for $product->lockForUpdate() instead, and that call does not even fail: Model::__call forwards unknown methods to $this->newQuery(), so we silently get a locking query over the entire products table with no where clause on our row at all. We have to issue a fresh, locked read inside the transaction and use that instance for the check.

SQLite ignores it entirely. Laravel’s SQLite query grammar compiles the lock clause to an empty string, so lockForUpdate is silently dropped. If you run your tests on SQLite and production on MySQL or PostgreSQL, a test for this behavior will pass while proving nothing. Your concurrency tests need to run against the real driver.

There is also sharedLock, which compiles to a shared read lock. It prevents other transactions from modifying the rows we have read, but it lets them read the same rows and take their own shared lock, so it does not prevent the check-then-act race described above. For anything followed by a write, lockForUpdate is what you want.

Handling deadlocks

Once we start taking row locks, we have to think about deadlocks. Two transactions each holding a lock the other needs will wait on each other forever, and the database resolves it by killing one of them.

The common way you walk into this is by locking multiple rows in an inconsistent order. Transaction A locks product 1 then product 2; transaction B locks product 2 then product 1. Both are now stuck. The usual mitigation is to always take locks in a deterministic order:

$products = Product::query()
    ->whereIn('id', $productIds)
    ->orderBy('id')
    ->lockForUpdate()
    ->get();

When every transaction walks the rows in ascending ID order, one of them gets both locks first and the other simply waits, so the cycle does not form. Note that this is a convention we are choosing to follow, not something the database enforces. The engine acquires locks in whatever order it actually reaches the rows, so the ordering only helps if every code path that touches these rows agrees to it.

That handles the deadlocks we cause structurally, but not the ones we cannot design away. For those, DB::transaction accepts a second argument:

DB::transaction(function () use ($request, $product, $quantity) {
    /* ... */
}, 3);

Laravel inspects the exception, and when it is a deadlock or serialization failure it rolls back and re-runs the entire closure, up to the given number of attempts. That last part matters. Anything with a side effect outside the database, such as dispatching a job, charging a card, or sending a mail, will happen again on each retry. Keep those out of the closure and fire them after the transaction commits.

Optimistic locking with a version column

Pessimistic locking has a cost: requests queue up behind each other, and a slow transaction holding a hot row becomes a bottleneck for everyone. If you expect conflicts to be rare, it is often better to assume success and detect the collision instead.

Add a version column to the table:

Schema::table('products', function (Blueprint $table) {
    $table->unsignedBigInteger('version')->default(0);
});

Then make the update conditional on the version we read:

// PurchaseController

public function __invoke(Request $request, Product $product)
{
    /* validate the request */

    $quantity = $request->integer('quantity');

    if ($product->stock < $quantity) {
        return back()->withErrors(['quantity' => 'Not enough stock available.']);
    }

    $updated = Product::query()
        ->whereKey($product->getKey())
        ->where('version', $product->version)
        ->where('stock', '>=', $quantity)
        ->decrement('stock', $quantity, ['version' => $product->version + 1]);

    if ($updated === 0) {
        return back()->withErrors(['quantity' => 'This product was just updated. Please try again.']);
    }

    /* create the order */
}

decrement returns the number of affected rows. If another request modified the product between our read and our write, it bumped version, our where clause matches nothing, and we get back 0, so we detect the conflict instead of silently overwriting.

Reaching for decrement rather than update with a DB::raw('stock - '.$quantity) expression is worth doing on purpose. It keeps the subtraction as a bound parameter instead of a string glued into the SQL, and its third argument sets extra columns in the same statement, which is how version gets bumped in the same round trip as the stock change.

The version increment does one more thing beyond conflict detection. On MySQL, Laravel does not enable MYSQL_ATTR_FOUND_ROWS, so an update that matches a row but changes no column values reports zero affected rows. Always bumping version guarantees the row genuinely changes, which keeps the $updated === 0 check meaning “conflict” rather than “nothing needed changing.”

The where('stock', '>=', $quantity) clause is deliberate too. Even if the version check somehow passed, the database itself still refuses to take stock negative.

One gap is left in the snippet above. The conditional update and the order insert are two separate statements, so a failure in between leaves the stock decremented with no order to show for it. Wrap both in a DB::transaction closure the way the pessimistic version does. Optimistic locking removes the need to lock, not the need for the write to be atomic.

Choosing between them

Both approaches solve the race, but they trade off in opposite directions.

  • Pessimistic serializes access. Conflicting requests wait, then succeed. Nobody has to retry, but throughput on the contended row drops to one transaction at a time and you take on deadlock handling. Use it when contention is high, when the work inside the transaction is short, and when failing the user’s request is worse than making them wait. Flash sales, seat reservations, ledger balances.
  • Optimistic never blocks. Conflicting requests fail fast and you have to decide what happens next, either an automatic retry or a message to the user. Use it when contention is genuinely rare and when a retry is cheap. Editing a profile, updating a CMS record, saving a draft.

Let the failure mode decide it. Under load, pessimistic locking shows up as latency and deadlocks. Optimistic locking shows up as retries and “please try again” messages. Ask which of those your application handles better, and use that one.

The row that does not exist yet

There is one thing neither approach covers. We cannot lock a row that has not been created yet, so this code races no matter which locking strategy wraps it:

$subscription = Subscription::query()
    ->whereBelongsTo($user)
    ->first();

if (! $subscription) {
    Subscription::create(['user_id' => $user->id]);
}

Two requests can both find nothing and both insert. The answer here is not a lock but a constraint:

$table->unique('user_id');

With that in place, the database rejects the second insert, and Laravel already knows what to do about it:

$subscription = Subscription::query()->firstOrCreate(['user_id' => $user->id]);

This is worth spelling out, because the hand-written version of it is a common sight. Since Laravel 10, firstOrCreate falls through to createOrFirst, which wraps the insert in a try, catches UniqueConstraintViolationException, and re-reads the row that the winning request just committed. We do not have to write that try ourselves.

Two details in that implementation are easy to miss when hand-rolling it. It re-reads using useWritePdo(), so on a setup with read replicas the lookup does not land on a replica that has not caught up yet and find nothing. It also runs the insert inside a savepoint when a transaction is already open, so the failed insert does not poison the surrounding transaction and force a full rollback.

firstOrCreate being safe does not mean the methods next to it are. The query builder’s updateOrInsert reads like it solves the same problem, and it does not:

$exists = $this->where($attributes)->exists();

if (! $exists) {
    return $this->insert(array_merge($attributes, $values));
}

That is the check-then-act race from the top of this post, sitting in the framework itself with no exception handling wrapped around it. Two requests can both read false and both insert. With a unique index the loser gets an unhandled exception rather than a clean result, and without one we are straight back to duplicate rows.

When we want a single statement that cannot race at all, upsert is the one to reach for. It compiles to insert ... on duplicate key update on MySQL and on conflict do update on PostgreSQL, so the database resolves the collision itself in one round trip rather than us catching anything.

The constraint is still the load-bearing part in all of these. firstOrCreate only survives the race because the unique index is there to reject the second insert, and upsert needs that same index to know what counts as a conflict at all. Drop the index and the exception never fires, so both quietly go back to creating duplicates.

The same principle runs through all three techniques. Validation and cache locks only describe what should be true, while a row lock or a unique index is what actually makes it true. Lock the row, or constrain the column. Anything that has to stay correct under concurrency should be enforced by the database, not by the code that happens to be reading it.