ginnerpeace/laravel-redis-lock
A Laravel package that adds Redis-backed locks for coordinating concurrent work across processes and servers. A simple API lets you acquire, release, and manage locks with TTLs and blocking waits, helping prevent race conditions in queued jobs, artisan commands, and scheduled tasks.
Install and confirm what you actually got. The package is small (15 stars, last release 2024-11-18), so before building on it, spend ten minutes reading the source — it is likely a thin wrapper around SET key value NX PX ttl plus a Lua release script.
composer require ginnerpeace/laravel-redis-lock
Then check three things:
extra.laravel.providers in the package's composer.json. If absent, register the provider manually in bootstrap/providers.php (Laravel 11+) or config/app.php.php artisan vendor:publish --provider="Ginnerpeace\RedisLock\...") and confirm which Redis connection it uses. It should be a dedicated connection, not your cache connection (see Gotchas).acquire() / release() as illustrative names; adjust to the actual signature before copying.Redis must be reachable and configured. Verify config/database.php has your redis connection and that REDIS_CLIENT (phpredis or predis) is installed:
php artisan tinker
>>> Redis::connection()->ping();
First use case — stop duplicate work. The highest-value first use is guarding a non-idempotent action that can be triggered twice: a webhook handler, a payment capture, or a scheduled command that must not overlap across servers.
use Illuminate\Support\Facades\Log;
// use Ginnerpeace\RedisLock\Facades\RedisLock; // confirm facade name
$lock = RedisLock::acquire('lock:invoice:'.$invoice->id, ttl: 30);
if (! $lock) {
Log::info('invoice.capture.skipped', ['id' => $invoice->id]);
return; // someone else owns it
}
try {
$invoice->capture();
} finally {
$lock->release(); // must be owner-checked; see Gotchas
}
Get this pattern working end-to-end on one endpoint before rolling it out. If it behaves correctly under a deliberate double-click, you understand the package's semantics well enough to use it elsewhere.
Important baseline: Laravel has shipped atomic locks since 5.8 — Cache::lock('invoice:'.$id, 30)->get() / ->block(5) — backed by Redis with an owner token and Lua-based release. Evaluate this package against that before adopting it broadly. Adopt it only if it offers something the built-in does not (simpler API, auto-refresh, a specific TTL/retry policy, or an opinionated key prefix).
1. Skip-if-busy (non-blocking). Best for "only one run at a time" semantics: cron commands, cache warmers, sync jobs. Fail fast, log, exit 0 — do not treat a skipped run as an error.
if (! $lock = RedisLock::acquire($key, ttl: 300)) {
$this->info('Already running elsewhere; skipping.');
return self::SUCCESS;
}
try { $this->doWork(); } finally { $lock->release(); }
2. Block-with-timeout (serialize). Best for read-modify-write on a shared row (inventory decrement, sequence numbers). Wait briefly, then bail with a domain error rather than blocking a PHP-FPM worker indefinitely.
$lock = RedisLock::acquire("lock:stock:{$sku}", ttl: 10, wait: 3);
abort_if(! $lock, 409, 'Busy, retry.');
try { $stock->decrement('qty', $n); } finally { $lock->release(); }
3. Always try/finally. The single most common bug is an early return or thrown exception between acquire and release, leaving the resource locked until TTL expiry. Wrap every acquisition in try/finally, no exceptions.
4. Key design. Namespace by environment, tenant, and resource, and include a version if the payload shape changes:
lock:{env}:{tenant}:{resource}:{id}
lock:prod:acme:invoice:8842
Keep keys stable and greppable — you will be scanning for them in redis-cli during incidents.
5. Long-running work needs a heartbeat. A lock TTL is a lease, not a mutex. If work can exceed the TTL (large exports, external API calls with retries), either (a) size the TTL generously (2–3× p99 runtime), (b) refresh the lock periodically if the package exposes refresh/extend, or (c) restructure so the lock covers only a short critical section and the long work is queued outside it.
6. Queue jobs. Laravel's WithoutOverlapping middleware already solves per-job uniqueness and is usually the better tool for jobs. Reserve this package for cases where the critical section spans different job classes or a job + a controller (e.g., "only one of these three entry points may mutate this order").
7. Wrap it, don't scatter it. Introduce one small service so the rest of the app isn't coupled to a 15-star dependency:
final class LockManager
{
public function run(string $key, int $ttl, Closure $fn, int $wait = 0): mixed
{
$lock = RedisLock::acquire($key, $ttl, $wait);
if (! $lock) throw new LockUnavailable($key);
try { return $fn(); } finally { $lock->release(); }
}
}
This gives you one place to swap in Cache::lock(), add metrics, or fix a key prefix later.
8. Integration with HTTP requests. For user-triggered mutations (double submit, double click, retry storms), acquire a short-TTL lock keyed on {user}:{action}:{resource} and return 409/429 when it is held. Pair with an idempotency key column for true safety — the lock prevents concurrency, the idempotency key prevents duplication after the lock is released.
9. Testing. Run a real Redis in CI (or a redis service container) — lock semantics cannot be faked meaningfully. Use a separate DB index, and flushdb between tests. Write at least one test asserting contention: two acquisitions, second fails; then release, second succeeds.
Cache::lock() first. Laravel's built-in Redis lock is battle-tested, has an owner token, and releases via Lua. If this package is a thin wrapper, you may be adding a dependency for no gain. Check whether the package's release path is ownership-safe (GET-then-DEL is a race; a Lua compare-and-delete is correct).release() deletes by key only, worker A can delete worker B's lock after A's TTL expired. Confirm the package compares a token. If it doesn't, wrap Cache::lock() instead.allkeys-lru — Redis can evict your lock under memory pressure and your mutex vanishes. Use noeviction for the lock DB, or at minimum a separate database index.wait of 30s under contention ties up PHP-FPM processes and can cascade into a full-app outage. Prefer non-blocking + client retry, and keep waits ≤ a few seconds.SET NX). For anything financially material, design for idempotency at the DB layer rather than trusting the lock alone.redis-cli -n <db> --scan --pattern 'lock:*' and TTL <key> are your first stop. MONITOR in staging shows acquisition/release in real time — use it once to confirm the exact commands and expiry flags the package issues.composer.json ("^1.0", not "*"), keep the wrapper service described above, and be ready to migrate to Cache::lock(). Read the CHANGELOG before bumping; verify Laravel 11/12 compatibility explicitly against your app version.How can I help you explore Laravel packages today?