Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Optimistic Locking Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • The package was last released in 2019. Before adopting it, run the test suite on your Laravel version and inspect the trait's 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.
  • Refetching before save defeats form-level conflict detection. See the pattern above: set and sync the submitted version, or keep the model instance from the edit page (rarely practical in stateless HTTP).
  • Bulk queries bypass the lock entirely. 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.
  • Use a non-null integer with a default. ->default(0) handles existing rows during migration. A nullable version column makes the comparison ambiguous and will produce confusing failures.
  • Verify the in-memory version after save. Some implementations update $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.
  • Deletes are generally not versioned. A concurrent delete will make the update affect zero rows and surface as a stale-lock exception; treat that as "record gone or changed" and return 404/409 accordingly.
  • Don't confuse this with pessimistic locking. It doesn't hold database locks and doesn't prevent conflicts; it detects them. For critical sections (e.g., wallet balance transfers), use lockForUpdate() inside a transaction instead, or combine both.
  • Transactions plus retries need care. The version check works inside a transaction, but retrying after an exception inside an open transaction may leave you with a rolled-back or aborted transaction. Retry at the outermost level.
  • Testing is straightforward: load two instances of the same row, save one, then save the other and assert StaleModelLockingException. Add this to your test suite so a future Laravel upgrade that breaks the trait is caught immediately.
  • Low maintenance signal. With 96 stars and a 2019 release, expect to own the code. That's acceptable for a tiny trait, but don't build a critical domain invariant on it without tests and a fork strategy.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle