reshadman/laravel-optimistic-locking
Add optimistic locking to your Laravel Eloquent models to prevent lost updates. It tracks version numbers on save, throws exceptions on conflicts, and works with your existing models—no schema changes beyond a version column.
Install the package and give the model a version column:
composer require reshadman/laravel-optimistic-locking
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('version')->default(0)->after('id');
});
Add the trait to every model that needs write-conflict detection:
use Reshadman\OptimisticLocking\OptimisticLocking;
class Order extends Model
{
use OptimisticLocking;
}
The trait hooks the Eloquent update path: when you save, it compares the version stored in the database with the version the model was loaded with, increments it, and throws Reshadman\OptimisticLocking\StaleModelLockingException if another writer got there first.
First use case — an edit screen or webhook handler that must not silently overwrite someone else's change:
use Reshadman\OptimisticLocking\StaleModelLockingException;
$order = Order::findOrFail($id);
$order->status = 'paid';
try {
$order->save();
} catch (StaleModelLockingException $e) {
return response()->json([
'message' => 'This order was changed by someone else. Reload and try again.',
], 409);
}
Look first at vendor/reshadman/laravel-optimistic-locking/src/OptimisticLocking.php and StaleModelLockingException.php. The package is small enough that reading the trait is faster than relying on the README, especially on modern Laravel versions.
Pass the version through the form. The most common mistake is re-fetching the model in the update action. If you do Order::findOrFail($id) at submit time, the model's version is already fresh, so the lock check only protects against simultaneous saves — not against another user editing the record during the ten minutes the form was open. Submit the version as a hidden field and restore it as the model's original value:
<input type="hidden" name="version" value="{{ $order->version }}">
public function update(UpdateOrderRequest $request, int $id)
{
$order = Order::findOrFail($id);
$order->fill($request->validated());
// Treat the submitted version as the version this user started from.
$order->version = $request->integer('version');
$order->syncOriginalAttribute('version');
try {
$order->save();
} catch (StaleModelLockingException $e) {
return response()->json(['message' => 'Order changed since you loaded it.'], 409);
}
return new OrderResource($order);
}
Handle conflicts deliberately. A 409 is fine for interactive forms; for queues or automated flows you usually want a bounded retry:
for ($attempt = 1; $attempt <= 3; $attempt++) {
$order = Order::findOrFail($id);
$order->status = $status;
try {
$order->save();
return $order;
} catch (StaleModelLockingException $e) {
if ($attempt === 3) {
throw $e;
}
usleep(50_000 * $attempt);
}
}
Only retry idempotent changes. If you send an email or charge a card inside the loop, move that side effect outside or guard it with your own idempotency key.
Customize the column if version collides. Override the trait's accessor rather than hard-coding column names throughout the app:
public function getVersionColumn()
{
return 'lock_version';
}
Use it as an API concurrency contract. Return version in your resource and require it on PUT/PATCH, similar to ETag/If-Match. Clients that omit it get a validation error instead of a false sense of safety.
Prefer model saves in jobs and services. The lock only engages when Eloquent performs an update on a model instance. Chunk through records and call save() when you need protection.
performUpdate implementation. If the signature or event flow has drifted, fork it or copy the ~50-line trait into your app. For a package this small, vendoring is a legitimate option.Order::where(...)->update(...), DB::table('orders')->update(...), increment(), upsert(), and raw SQL never trigger model events or the version check. Audit these paths; if they write to locked tables, they can silently clobber a checked update.touch() and relationship writes may bypass the check. Updating updated_at or writing through a pivot often doesn't go through the model's normal save path. Confirm behavior for your use case.saveQuietly() / withoutEvents() can disable the guard. If the trait relies on model events, these helpers skip the check. Avoid them on locked models.->default(0) handles existing rows during migration. A nullable version column makes the comparison ambiguous and will produce confusing failures.$model->version; others don't. If you return the model in an API response, call $model->refresh() or explicitly increment the attribute so clients receive the new version for their next write.lockForUpdate() inside a transaction instead, or combine both.StaleModelLockingException. Add this to your test suite so a future Laravel upgrade that breaks the trait is caught immediately.How can I help you explore Laravel packages today?