Skip to content

Use Optimistic Locking with a Version Column in Laravel

August 18, 2026 • 3 min read

In Prevent Race Conditions with lockForUpdate() in Laravel, two purchases racing for the last unit of stock were fixed by locking the row. That works, but every request for that product waits its turn, and one slow request holds up everyone behind it.

If two requests rarely touch the same row at the same time, you can skip the lock and catch the collision instead. That’s optimistic locking: give the table a version column, and only update the row if the version hasn’t changed since you read it.

Let’s add the column, use it in the purchase, and then look at when to pick this over a lock.

Adding a version column

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

The purchase reads the product as usual, then makes the update conditional on the version it read:

use Illuminate\Support\Facades\DB;

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

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

    $ordered = DB::transaction(function () use ($request, $product, $quantity) {
        $updated = Product::query()
            ->whereKey($product->getKey())
            ->where('version', $product->version)
            ->where('stock', '>=', $quantity)
            ->decrement('stock', $quantity, ['version' => $product->version + 1]);

        if ($updated === 0) {
            return false;
        }

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

        return true;
    });

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

    return to_route('orders.index');
}

I sent five purchases in parallel for a product with one unit left, with the same 200 ms pause between the check and the write as in the lockForUpdate() article. One order went through and the stock ended at 0. The other four got the “try again” message.

How the version check works

decrement() returns the number of rows it changed. If another request bought the product after this one read it, the version has moved on, the where matches nothing, and you get 0 back. With a stale version the update changed 0 rows, and with the current one it changed 1.

The third argument to decrement() sets other columns in the same statement, which is how the version goes up in the same update as the stock. The where('stock', '>=', $quantity) condition means the database won’t take the stock below zero either.

The order insert stays in the same transaction as the update. Otherwise an error between the two statements would leave the stock taken with no order.

Why the version always has to change

On MySQL, Laravel doesn’t set PDO’s MYSQL_ATTR_FOUND_ROWS option, so an update that matches a row but doesn’t change any values reports 0 rows. I ran one to check, and it returned 0.

Because every successful update raises the version, it always changes the row. So 0 only ever means another request got there first.

Choosing between a lock and a version

With lockForUpdate(), conflicting requests wait and then succeed, but only one request at a time can work on that row. That suits rows that many requests hit at once, like a flash sale, as long as the work inside the transaction is short.

With a version column, nobody waits, but the requests that lose get an error and have to try again. That suits rows that rarely see two writes at once, like editing a product description.

So if your users would rather wait than see “try again”, lock the row. If conflicts are rare and a retry is cheap, use a version column.