<?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');
});
}
};
<?php
namespace App\Concerns;
use App\Exceptions\StaleObjectException;
trait OptimisticLocking
{
public static function bootOptimisticLocking(): void
{
static::updating(function ($model) {
return $model->performVersionedUpdate();
});
}
protected function performVersionedUpdate(): bool
{
if (! $this->isDirty()) {
return true;
}
$expectedVersion = (int) $this->getOriginal('version');
$newVersion = $expectedVersion + 1;
$attributes = $this->getDirty();
$attributes['version'] = $newVersion;
unset($attributes[$this->getKeyName()]);
$affected = $this->newQueryWithoutScopes()
->where($this->getKeyName(), $this->getKey())
->where('version', $expectedVersion)
->update($attributes);
if ($affected === 0) {
throw new StaleObjectException(static::class, $this->getKey(), $expectedVersion);
}
$this->version = $newVersion;
$this->syncOriginal();
$this->syncChanges();
return false; // cancel Eloquent's own UPDATE, we already wrote the row
}
}
<?php
namespace App\Models;
use App\Concerns\OptimisticLocking;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use OptimisticLocking;
protected $fillable = [
'name',
'price_cents',
'stock',
];
protected $casts = [
'price_cents' => 'integer',
'stock' => 'integer',
'version' => 'integer',
];
}
<?php
namespace App\Http\Controllers;
use App\Exceptions\StaleObjectException;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function update(Request $request, Product $product): JsonResponse
{
$data = $request->validate([
'name' => 'sometimes|string|max:255',
'price_cents' => 'sometimes|integer|min:0',
'stock' => 'sometimes|integer|min:0',
'version' => 'required|integer',
]);
// Pin the version the client last saw so the trait can detect a race.
$product->forceFill(['version' => $data['version']])->syncOriginal();
$product->fill(collect($data)->except('version')->all());
try {
$product->save();
} catch (StaleObjectException $e) {
return response()->json([
'message' => 'This product was modified by someone else. Reload and try again.',
'current' => $product->fresh(),
], 409);
}
return response()->json($product);
}
}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
Share this code
Here's the card — post it anywhere.