php 125 lines · 4 tabs

Optimistic Locking With a Version Column in Laravel Eloquent

Shared by codesnips Sep 2026
4 tabs
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->unsignedBigInteger('version')->default(1)->after('id');
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('version');
        });
    }
};
4 files · php Explain with highlit

This snippet shows how to prevent the classic lost-update problem in a Laravel application using optimistic locking backed by a plain integer version column. The lost-update problem happens when two requests read the same row, both make edits based on that stale copy, and both write back — the second write silently clobbers the first. Pessimistic locking (SELECT ... FOR UPDATE) solves it by holding a database lock for the whole transaction, but that hurts throughput and doesn't map well to stateless HTTP where a user might sit on an edit form for minutes. Optimistic locking instead assumes conflicts are rare and only checks for them at write time.

The add_version_to_products migration adds the version column with a default of 1, giving every row a monotonically increasing counter that changes on every successful update.

The core logic lives in OptimisticLocking trait, which any Eloquent model can use. It hooks the model's updating event via bootOptimisticLocking. On each update it reads the version the record was loaded with from getOriginal('version'), then issues a manual UPDATE ... WHERE id = ? AND version = ? that also bumps version by one. Because the WHERE clause pins the expected version, the write only succeeds if nobody else touched the row in the meantime. If the affected row count is zero, another writer won the race, so it throws a StaleObjectException. Returning false from the updating hook cancels Eloquent's own default UPDATE, avoiding a double write, while syncOriginal and syncChanges keep the in-memory model consistent.

The Product model simply mixes in the trait, so its version handling is entirely transparent to the rest of the app.

Finally, ProductController demonstrates the HTTP contract: the client submits the version it originally fetched, the controller sets it on the model with forceFill, and any StaleObjectException is translated into an HTTP 409 Conflict so the front end can prompt the user to reload and re-apply their changes. A key pitfall is forgetting to send that version from the client, or bulk-updating via the query builder, both of which bypass the model events and the check entirely.


Related snips

Share this code

Here's the card — post it anywhere.

Optimistic Locking With a Version Column in Laravel Eloquent — share card
Link copied