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

zaber-dev/laravel-lock

Application-level distributed locking for Laravel with entity-scoped locks, automatic owner detection, and cleanup. Use cache or database storage, attach locks to Eloquent models, and protect routes with middleware to prevent race conditions.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish:

composer require zaber-dev/laravel-lock
php artisan vendor:publish --provider="ZaberDev\Lock\LockServiceProvider"
php artisan migrate   # only needed for the `database` driver

Where to look first:

  1. config/locks.php — decide your default driver up front. The package defaults to cache, which is the right choice for most day-to-day use; switch to database only when you need lock records to survive a cache flush or want to query/audit them.
  2. ZaberDev\Lock\Facades\Lock — this is your entry point. Almost every workflow hangs off Lock::for($action, $target).
  3. ZaberDev\Lock\HasLocks — the trait to add to models you want to scope locks to.
  4. ZaberDev\Lock\Http\Middleware\RequireLock — the smallest possible integration point if you don't want to touch controller code at all.

First use case — protect a duplicate-prone endpoint:

Reach for the middleware before writing any locking logic yourself. It's declarative, releases in a finally, and returns 429 for you:

Route::post('/billing/charge', [BillingController::class, 'charge'])
    ->middleware('lock:billing_charge,30');

Second use case — wrap a critical section in a job or service:

use ZaberDev\Lock\Facades\Lock;

Lock::for('billing_charge', $user)->block(function () use ($user, $stripe) {
    $stripe->charge($user);
});

block() is the highest-leverage call in the package: it acquires, runs, and releases in a try/finally, so it's the one you can adopt without thinking hard about cleanup paths. Use acquire() + finally { release(); } only when you need branching logic on failure.


Implementation Patterns

Scope every lock to an entity, not a string. The package's core value proposition is that Lock::for('billing_charge', $user) derives a stable key from a polymorphic target. Lean into that instead of hand-rolling "billing_charge:{$user->id}" strings — you get consistent key mapping across middleware, models, and jobs for free.

Eloquent-scoped locking as the default in domain code. With HasLocks on your model, lock acquisition reads like a domain operation rather than infrastructure:

if ($user->lock('billing_charge')->ttl(60)->acquire()) {
    try { /* ... */ } finally { $user->lock('billing_charge')->release(); }
}

This is the pattern to standardize on inside services and actions, because the target is implicit and reviewers can see the scope at a glance.

Middleware for edge protection, block() for internal workflows. A useful division of labour:

  • HTTP boundary → lock:action,ttl[,driver] middleware. Zero controller changes, automatic 429, automatic release on 2xx/4xx/5xx.
  • Queue jobs, console commands, service methods → Lock::for(...)->block(...). You control the failure mode instead of returning a response.
  • Cron single-instance enforcement → block() at the top of the command, with a no-op exit path if the lock isn't acquired.

Mix drivers per call site. using() is per-builder, so you can keep the default cache driver globally and opt into persistence where it matters:

Lock::for('account_transfer', $user)->using('database')->ttl(120)->block(...);
Lock::for('api_sync', $ip)->using('cache')->ttl(15)->acquire();

The rule of thumb that works well: cache for ephemeral mutex (throughput, short TTLs), database for locks you may need to reason about after the fact — long-running transfers, anything where "who held it and when" could matter in an incident review.

Use explicit owner() in workers and non-request contexts. Automatic owner detection resolves to user_id, queue job ID, or a request hash. That's excellent for HTTP and queued jobs, but for a custom daemon, a Horizon worker pool with unusual context, or a scheduled command invoked outside the request lifecycle, pass an explicit owner:

Lock::for('payout_run', $account)->owner('payout-worker-'.$pid)->ttl(120)->acquire();

This makes release() deterministic and keeps your logs traceable.

Wire events into observability early. With locks.events.dispatch = true, listen for LockFailed specifically — it's the highest-signal event. A spike in LockFailed is your earliest indicator of a duplicate-submission bug, a hot endpoint, or a lock that's not being released. LockForceReleased deserves an alert too, since it usually means someone ran a manual recovery script.

Prune aggressively if you adopt the database driver. Schedule model:prune scoped to LockModel; without it, the locks table grows monotonically and your isLocked() queries slow down over months:

Schedule::command('model:prune', ['--model' => LockModel::class])->daily();

Reach for Lock::extend() only when a real constraint appears. Custom drivers are a genuinely clean extension point (closure or container binding in AppServiceProvider), but the cache driver already covers Redis and Memcached — don't build a driver speculatively.


Gotchas and Tips

acquire() returns false silently — treat it as a first-class branch, not an afterthought. The README's examples all show the failure path as a comment. In practice, an unhandled false means your "critical section" quietly doesn't run, which can look like a dropped webhook or a skipped job. Make the else-branch explicit (return 429, requeue with backoff, or log). There's no retry/wait helper documented — if you need to wait for the lock, you're building the retry loop yourself.

block() has no documented return contract for contention. The README shows only the success path. Before relying on it in production, verify empirically what happens when the lock can't be acquired (does it throw? return null? run nothing?) and wrap accordingly. Don't assume it queues or waits — nothing in the docs suggests it does.

Re-acquiring the builder is cheap, so don't cache it. The idiomatic pattern in the docs calls Lock::for(...) again inside finally to release. That's intentional — the builder is a fluent request, not a handle. If you store the builder in a variable and reuse it across a long-lived process (queue worker, Octane), double-check that TTL and owner state aren't leaking between calls.

Owner mismatch makes release() a silent no-op. This is a feature — you can't release someone else's lock — but it means a lock that "wasn't released" is usually an owner-detection problem, not a driver problem. Symptoms: locks expiring by TTL instead of releasing promptly, and LockReleased events never firing. First diagnostic step: log the resolved owner at acquire time and compare it at release time. In request contexts behind a load balancer, the request-hash owner can differ between the acquire and release if they land on different app instances — a good argument for explicit owner() on anything non-trivial.

Expiry is not the same as release. isLocked() checks live state, but an expired lock is still "gone" only once the driver notices — cache TTL semantics vs. the database driver's expires_at comparison can differ subtly in edge cases. If you're using LockInfo to drive UI ("this record is being edited by…"), trust isExpired() from the DTO rather than a raw timestamp comparison; the DTOs use CarbonImmutable precisely to avoid mutation bugs in that math.

The locks() polymorphic relationship is database-driver-only. Calling $user->locks() while your default driver is cache will query an empty/unrelated table. If you write admin tooling around $user->locks(), force ->using('database') at the acquisition site so the two paths can't diverge.

Middleware TTL is a ceiling, not a guarantee. The middleware releases in finally, so the TTL only matters if your process dies mid-request (fatal error, timeout, OOM kill, deploy). Size it to your worst-case request duration plus margin — too short and a slow request gets its lock stolen mid-execution; too long and a crashed request blocks the endpoint for minutes. 30s is fine for a payment POST; a webhook handler doing downstream fan-out probably wants 120s.

Use middleware for idempotency-shaped problems, not for throughput control. 429 on contention is correct for "don't process this twice" but is a poor experience for "this endpoint is busy." Those are different problems; the second one wants a queue.

forceRelease() bypasses ownership entirely — gate it. It's the right tool for recovery scripts and admin tooling, and the wrong tool in any request path. Keep it behind authorization and pair it with the LockForceReleased event so it's never silent.

Config quirk worth knowing before you deploy: the cache driver's store is read from LOCK_CACHE_STORE and defaults to null, which resolves to your application's default cache store. If you later change CACHE_STORE (say, to database for a quick fix, or array in a test), your locks silently move stores too — and in-flight locks in the old store become invisible. Pin LOCK_CACHE_STORE explicitly in any environment where correctness matters, and never let it fall back to array outside tests.

Testing: point the config at the array cache store in your test suite so locks are isolated per test, and assert on dispatched events (Event::fake()) rather than on driver internals. For the database driver, RefreshDatabase gives you a clean locks table per test — the HasLocks relationship makes assertions on lock records straightforward.

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.
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk