arvenil/ninja-mutex
arvenil/ninja-mutex is a lightweight PHP mutex library for inter-process locking. It offers a simple API and pluggable adapters such as flock, MySQL, Redis, Memcached, and semaphores to protect critical sections and coordinate concurrent processes.
Requires PHP 5.4+ era code; install with Composer:
composer require arvenil/ninja-mutex
First files to read, in order:
NinjaMutex\Lock\LockInterface — the small contract every backend implements (acquire, release, isLocked).NinjaMutex\Mutex — the thin orchestration layer you will actually call.Lock\*Lock implementations — pick one based on where your locking must hold.The core pattern is always the same: construct a lock backend, wrap it in a Mutex with a stable name, and guard a critical section.
use NinjaMutex\Lock\FlockLock;
use NinjaMutex\Mutex;
$lock = new FlockLock(storage_path('locks/reports.lock'));
$mutex = new Mutex('reports:nightly', $lock);
if (! $mutex->acquireLock(5)) { // wait up to 5 seconds
return; // someone else is running
}
try {
$this->generateReports();
} finally {
$mutex->releaseLock();
}
That cron single-instance guard is the classic first use case: "don't run this job twice concurrently." The first backend to reach for is FlockLock on a single host, or a Redis-backed lock if the job runs on more than one server.
Backend selection is architectural, not stylistic. Choose by blast radius:
| Topology | Backend |
|---|---|
| Single host, local filesystem | FlockLock, DirectoryLock, SemaphoreLock |
| Multiple app servers | PHPRedisLock (phpredis), PredisRedisLock (Predis), MemcacheLock/MemcachedLock, MysqlLock |
| Shared DB, no cache tier | MysqlLock (session-scoped, good crash safety) |
Always try/finally. The library will not release a lock for you. Wrap every critical section; this is non-negotiable in long-running queue workers where an exception mid-job would otherwise wedge the lock.
A runExclusive helper pays for itself immediately. You will repeat this pattern everywhere in Laravel; centralize it:
class LockManager
{
public function __construct(private LockInterface $lock, private LoggerInterface $logger) {}
public function run(string $name, callable $callback, float $timeout = 5.0): mixed
{
$mutex = new Mutex($name, $this->lock, $this->logger);
if (! $mutex->acquireLock($timeout)) {
throw new LockUnavailable("Could not acquire {$name}");
}
try {
return $callback();
} finally {
$mutex->releaseLock();
}
}
}
Bind LockInterface in a service provider so jobs/controllers resolve it from the container, and make the backend configurable per environment (flock locally, Redis in production).
Cache stampede protection. The classic second use case: read-through cache regeneration under load.
$value = Cache::get($key);
if ($value === null) {
$mutex = new Mutex("cache:{$key}", $redisLock);
if ($mutex->acquireLock(2)) {
try {
$value = Cache::get($key) ?? $this->regenerate($key);
} finally {
$mutex->releaseLock();
}
}
}
Note the re-check of the cache after acquiring — otherwise every waiter regenerates once the lock frees.
Namespace every lock name. Prefix with environment and resource (prod:invoice:1234:recalc). Lock names are global per backend; a staging worker and a production worker sharing one Redis DB will fight over invoice:1234.
Inject a PSR-3 logger as the third constructor argument. It is the only built-in observability hook and makes "who is holding this lock" questions answerable in logs.
Polling is built in. acquireLock($timeout) blocks and retries with a short sleep. Don't add your own retry loop around it; do choose a timeout that matches your request/latency budget and let the failure path be explicit (dispatch a job, return 503, etc.).
acquireLock and releaseLock leaves the key behind, and the next run blocks until someone deletes it. For crash-prone workloads prefer MysqlLock (MySQL GET_LOCK is connection-scoped and auto-releases on disconnect) or FlockLock (the OS releases on process death). Add external TTL handling if you must use Redis.flock is per-host and NFS-hostile. It coordinates processes on one machine against one file. It does not work across containers, autoscaled hosts, or unreliable network filesystems.Mutex instance. It throws; the mutex is not reentrant. For nested/recursive work, either pass the already-held lock down or use separate, distinctly named mutexes. Guard against reentry in your own helper if your code paths can nest.acquireLock($t) waits up to $t seconds, polling. In an HTTP request keep it in the low single digits; a lock wait is user-facing latency. In CLI workers a long or null timeout is fine.isLocked() is a snapshot, not a reservation. It's fine for dashboards and "is a job stuck?" checks; never use it as a check-then-act guard — the race is exactly what the mutex is for.DirectoryLock/FlockLock-style backends may map names onto paths. Hash long or user-derived names (sha1($name)) rather than interpolating raw input.PHPRedisLock needs the redis extension and takes a connected client; PredisRedisLock takes a Predis\Client; SemaphoreLock needs sysvsem; MysqlLock needs a live PDO connection — and that connection must stay open for the duration of the lock.composer.json, and be aware there are no fixes coming. For new greenfield work, weigh symfony/lock (active, TTLs, store abstraction) or malkusch/lock. Keep ninja-mutex where it already works and the semantics are understood.FlockLock/DirectoryLock in tests and point them at a per-test temp path; clean up in tearDown. Avoid exercising a real Redis lock in unit tests unless the CI environment provides one — the failure mode (tests hanging on a timeout) is painful to debug.isLocked($name) first, then inspect the backend directly (GET lockname in Redis, IS_USED_LOCK in MySQL, lsof the lock file). If isLocked says free but your code still blocks, you are almost certainly using a different backend instance or a different lock name than you think.How can I help you explore Laravel packages today?