Skip to content

Prevent Race Conditions with lockForUpdate() in Laravel

August 17, 2026 • 4 min read

If your app sells anything with limited stock, the purchase code probably looks something like this. It checks that there’s enough stock, then takes it:

public function __invoke(Request $request, Product $product): RedirectResponse
{
    $quantity = $request->integer('quantity');

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

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

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

    return to_route('orders.index');
}

When two customers buy the last unit at the same moment, both requests read a stock of 1, both pass the check, and both take it.

I sent five purchase requests in parallel for a product with one unit left. To make the overlap easy to reproduce, I added a 200 ms pause between the check and the write, the kind of gap a payment call would leave. All five orders went through, and the stock ended at -4.

Let’s lock the row so only one of them gets through, and then look at the ways the lock can quietly stop working.

Locking the row with lockForUpdate()

lockForUpdate() adds FOR UPDATE to the select, which locks the row until the transaction ends. Read the product with it inside a transaction, and check the stock on that locked copy:

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

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);

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

The same five requests now create one order and leave the stock at 0. The other four wait for the lock, then read a stock of 0 and fail the check.

You might reach for Cache::lock() instead, but a cache lock only protects code that remembers to take it, and it expires on a timer. A row lock lives in the database, so every locking read and update of that row has to wait for it.

Keeping the lock inside a transaction

The lock is released when the transaction ends. Without DB::transaction(), each statement commits on its own, so the lock is gone as soon as the select finishes.

I had one request hold a locked read for two seconds and started a second request half a second later. Inside a transaction, the second request waited 1,534 ms. Without one, it waited 28 ms.

Not locking the model you already have

Route model binding loads $product before the transaction starts, so it’s tempting to lock that instance:

$locked = $product->lockForUpdate()->first();

That doesn’t lock $product. Calling a query method on a model starts a new query on the whole table, with no where for the model’s id:

select * from `products` for update

Add first() and you get the first product in the table. When I ran it for product 2, it returned product 1. Start from Product::query()->whereKey(...) as above instead.

SQLite ignores the lock

Laravel’s SQLite grammar leaves the lock out, so the locked query compiles to:

select * from "products" where "id" = ?

A test for this on an in-memory SQLite database will pass without testing anything. Run concurrency tests against the database you use in production.

Locking more than one row

If a transaction locks several rows, like every product in a cart, two transactions that lock the same rows in a different order can deadlock. I had one request lock product 1 and then product 2, and another lock product 2 and then product 1. MySQL stopped the second one with a 1213 deadlock error. When both locked in id order, both went through.

So lock rows in the same order everywhere, for example by id:

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

DB::transaction() can also retry after a deadlock. Pass the number of attempts as the second argument:

DB::transaction(function () {
    // ...
}, 3);

With 3 attempts, the transaction that hit the deadlock ran its closure a second time and committed. Everything in the closure runs again on a retry, so keep emails, jobs and payment calls outside it.

So whenever a request checks a value and then changes it, read the row with lockForUpdate() inside a transaction, starting from a fresh query.

If you’d rather not make requests wait, optimistic locking with a version column catches the conflict instead.