alt-three/locker
Alt Three Locker is a simple feature flag package for Laravel, letting you define toggles in config and check them anywhere in your app. Enable or disable features globally or per user without redeploying, keeping risky changes behind safe, manageable switches.
Install and wire it up
composer require alt-three/locker
AltThree\Locker\LockerServiceProvider. On older apps add the provider to config/app.php and register the Locker facade alias.php artisan vendor:publish --provider="AltThree\Locker\LockerServiceProvider"
Read config/locker.php before writing any code. This is the whole package. It is a thin config-driven factory over symfony/lock, so the shape is a default store name plus a stores map, where each entry has a driver and driver-specific options:
return [
'default' => env('LOCKER_STORE', 'flock'),
'stores' => [
'flock' => [
'driver' => 'flock',
'path' => storage_path('framework/locks'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
],
];
The driver value maps onto a Symfony Lock store (flock, semaphore, redis, memcached, a PDO/mysql/pgsql/sqlite store, zookeeper, and typically a combined store for redundancy). Anything you can't express in the array is a sign you should be using symfony/lock directly.
First use case — stop overlapping runs. By far the most common reason to reach for this package in a Laravel app is "this scheduled command / queue job must not run twice concurrently":
use Illuminate\Console\Command;
use AltThree\Locker\Locker; // facade
public function handle()
{
$lock = Locker::lock('reports:nightly'); // named lock from the default store
if (! $lock->acquire()) {
$this->warn('Already running, skipping.');
return;
}
try {
$this->doTheWork();
} finally {
$lock->release();
}
}
Get that working on one box with flock before you touch Redis. Everything else is a variation on it.
Pick the store per workload, not globally. The default store is a convenience, not a strategy. Local single-server work (artisan commands, cron) belongs on flock or semaphore; anything running on more than one host (web requests, queue workers on multiple machines) must use a shared store — Redis, Memcached, or a PDO store. Configure named stores and select explicitly rather than relying on default for everything:
$lock = Locker::store('redis')->lock('invoice:'.$invoice->id);
Non-blocking vs blocking. acquire() returning false is a feature, not an error — it's how you say "someone else has it, skip / return 409 / show a spinner". For genuine serialisation (two requests updating the same aggregate), block with a bounded wait so you fail loudly instead of spinning forever:
if (! $lock->acquire(true)) { /* waited, still didn't get it */ }
// or: $lock->acquire() inside a retry loop with usleep()
Keep the wait short relative to the request timeout, and keep the critical section short relative to the lock TTL.
Always TTL, always finally. Set an expiring lock so a killed worker doesn't wedge the resource permanently, and release in finally so an exception doesn't leave it held until expiry. For long-running jobs whose duration can exceed the TTL, use a refreshable/expiring lock and call refresh() on a heartbeat (or inside the loop) so ownership extends while you're alive:
$lock = Locker::lock('import:'.$feed->id, 300); // seconds
$lock->acquire();
try {
foreach ($rows as $row) {
$lock->refresh(); // keep ownership during a long import
$this->process($row);
}
} finally {
$lock->release();
}
Queue jobs — a poor man's ShouldBeUnique. This package predates Laravel's ShouldBeUnique / WithoutOverlapping middleware, but the pattern is the same: acquire at the top of handle(), return early if you lose, release in finally. Prefer per-entity keys (import:42) so you serialise the resource rather than the job class, and remember $tries/retryUntil don't know anything about your lock.
Don't litter the codebase with the facade. Wrap it in a small app-level service (LockService::run('key', $ttl, fn () => ...)) that does acquire/try/finally/release and throws a domain exception on contention. That gives you one place to swap the implementation and one place to fake in tests. The manager is resolvable from the container, so constructor-inject it where you need store selection.
Testing. Don't mock the facade — point the config at a flock store in a temp directory under tests/, or bind a fake store into the container in setUp(). Flock stores are cheap and real, and they'll catch the release-in-finally bugs that mocks won't. Run lock tests serially (--parallel will make a local flock store race with itself).
This package is archived (last release 2020-07-25, zero dependents). Treat it as frozen. Its composer constraints will fight modern Laravel/Symfony versions, and nothing gets patched. Before adopting, seriously evaluate the two live alternatives: symfony/lock directly (same API, maintained, one dependency) or Laravel's built-in atomic locks — Cache::lock('key', $seconds)->block($wait, fn () => ...), backed by the redis/database/dynamodb cache stores. For 95% of "prevent overlap" needs in a current Laravel app, Cache::lock() is the answer and Locker is a historical footnote.
A lock is only as shared as its store. flock is local to one machine and to one filesystem — it does not work over NFS and does nothing for a second web server. semaphore is likewise per-host. If you're on more than one node, the store must be Redis/Memcached/PDO, and every node must be configured identically. This is the #1 way this package "silently stops working" after a scale-out.
Never let Redis evict your lock keys. A maxmemory-policy of allkeys-lru will happily delete a lock key mid-critical-section and hand you two owners. Use noeviction or a dedicated database/instance for locks. Same class of bug with Memcached: eviction and expiry are not under your control.
TTL is a correctness parameter, not a convenience. If the TTL is shorter than your worst-case job duration, the lock expires and a second worker enters. Either size the TTL above your p99 and keep it non-refreshing (simpler), or refresh deliberately. Don't set TTLs in the tens of seconds for jobs that take minutes.
Driver typos fail at first use, not at boot. The store is resolved lazily, so a bad driver string or a missed ext-redis/ext-memcached/pdo_* shows up as an exception inside a request, not during deployment. Smoke-test each configured store in your deploy script or a health check.
Config caching hides your edits. php artisan config:cache bakes locker.php in; after changing stores run config:clear. When debugging "why is it using the wrong store", log the resolved store name first — nine times out of ten it's a cached config or a stale env() value.
flock needs a writable path. The configured directory must exist and be writable by the php-fpm/queue user; a storage_path() subdirectory created by your deploy but owned by root is a classic 500.
Don't nest re-acquires of the same key in one process. Re-acquiring on the same lock instance or the same key in the same process interacts badly with reference-count semantics and store-specific behaviour. Use distinct instances with distinct keys, or serialise the work upstream. Similarly, guard against a lock released by the wrong owner by never sharing a lock object across processes.
Extension points are minimal. If you need a custom store or different DSN handling, don't fork an archived repo — register your own symfony/lock store in the container and wrap it. That's the honest escape hatch, and it's also the migration path: the acquire/release/TTL/refresh semantics are identical, so porting code off Locker to symfony/lock or Cache::lock() is mostly mechanical.
How can I help you explore Laravel packages today?