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 Locking Laravel Package

quarks/laravel-locking

quarks/laravel-locking brings simple, expressive pessimistic locking to Laravel's Eloquent models, with helpers for exclusive and shared locks so you can safely coordinate concurrent writes and prevent race conditions inside database transactions.

View on GitHub
Deep Wiki
Context7

Getting Started

Optimistic locking is a compare-and-swap on a version column: every row carries a version (or lock_version) integer, reads remember it, and the UPDATE only succeeds if the version still matches. This package is the Eloquent sugar for that pattern — a trait plus a migration stub and a conflict exception.

Minimal setup:

composer require quarks/laravel-locking
  1. Add an unsigned integer version column to the tables that need protection (the package ships a migration stub — publish it if the service provider exposes one, otherwise copy the column definition by hand).
  2. use the package trait on each model you want protected.
  3. Read a record, mutate it, save(). If another writer bumped the version in between, the save throws instead of silently overwriting.

Your first real use case is the classic admin-panel edit form: two people open the same order, both hit Save, and the second one gets a conflict error instead of clobbering the first one's changes. That's the whole value proposition — it costs you one column and one trait, and it never holds a database lock while a human stares at a form.

Where to look first: the package is small (3 stars, single maintainer, last release July 2022), so read the source rather than the README. vendor/quarks/laravel-locking/src — find the trait, note exactly which method it hooks (save, performUpdate, or a booted model event), find the exception class, and check whether the version increment happens inside the same UPDATE statement or in a separate query. That last detail decides whether the locking is actually race-free. Fifteen minutes of reading is worth more than any docs here.

Implementation Patterns

Thread the version through the UI. Add a hidden version input to your edit form, validate it as an integer in the FormRequest, and pass it to the model before saving. Without this, the model re-reads a fresh version at save time and the check becomes a no-op.

$order = Order::findOrFail($request->order_id);
$order->version = $request->integer('version');
$order->fill($request->validated())->save();

Translate the conflict exception at the boundary. Catch it in the controller (or in app/Exceptions/Handler) and return 409 Conflict for JSON or redirect back with the user's input flashed for HTML. Don't let it bubble into a 500 — a conflict is a normal outcome, not a bug.

try {
    $order->save();
} catch (LockingConflictException $e) {
    return response()->json(['message' => 'Record changed, reload and retry'], 409);
}

For APIs, use HTTP semantics you already have. Send the version as an ETag on GET and require If-Match on PUT/PATCH. It maps 1:1 onto this package and gives you caching for free.

Use bounded retry loops only for machine writers. A job that increments a counter can safely catch the conflict, refresh() the model, and retry two or three times. Never retry a request that has already fired a side effect (mail, queued job, payment) inside the loop — move side effects outside, or use DB::transaction so the whole thing rolls back.

Pair it with a transaction for multi-row invariants. Optimistic locking protects one row. If a business rule spans two models, wrap both saves in DB::transaction() and let the conflict exception roll the whole thing back.

Know when not to use it. Optimistic locking is wrong for hot rows. If twenty processes fight over the same counter, you get a retry storm; use increment(), a lockForUpdate() inside a short transaction, or Redis instead. It's also useless for long batch jobs — use chunked reads with where('version', $v)->update(...) and check the affected-row count.

Gotchas and Tips

The package is stale — pin it and audit it. Last release mid-2022, zero dependents, three stars. It predates Laravel 10/11. Constrain the version in composer.json, and check that the trait's model-event hook still fires on your Laravel version. The entire library is a few hundred lines; if it breaks, forking the trait into app/Concerns/OptimisticLocking.php is a legitimate 30-line move.

Any write path that bypasses the trait is a silent lost update. Model::where(...)->update(), the query builder, raw SQL, seeders, upsert, and truncate all skip the check. Grep your codebase for these on locked models and either convert them or add an explicit ->where('version', $version) guard and assert on the returned row count.

Guard the version attribute. If it's in $fillable, a client can POST their own version and defeat the entire check. Keep it out of $fillable and set it explicitly, or validate it from a trusted source.

Verify the increment is atomic. If the package does SELECT → compare in PHP → UPDATE ... SET version = version + 1 as two statements, there's a window between them. Check for where('version', $oldVersion) in the generated UPDATE. Debug with DB::listen(fn ($q) => logger($q->sql, $q->bindings)) and confirm the version predicate is there.

Adding the column on a large table. In MySQL 8 / Postgres 11+, a NOT NULL DEFAULT 1 integer add is instant. On older MySQL it rewrites the table. Backfill in batches if you're on anything big.

Trait boot collisions. If the package boots via a boot{TraitName} method, confirm ordering against your other traits (SoftDeletes, HasUuids, auditing packages). Two traits hooking updating can produce surprising results.

Observers and events can re-save. An updated observer that calls $model->save() again will bump the version a second time and break clients holding the first value. Audit observers on locked models.

Version drift in tests. Use Model::factory()->create(['version' => 1]) explicitly, and write one test that simulates the conflict: create, refresh() a second instance, save both, assert the exception. That test is your proof the package is doing anything at all.

Extension points are thin by design. Expect a configurable column name and a swappable exception class at most. If you need soft-delete-aware locking, composite versions, or conflict merging, you're writing it yourself — which is fine, the pattern is trivial and you'll understand your own version far better than a 2022 package with three stars.

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.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console