yriveiro/php-fslock
A lightweight PHP library for managing file locks. It wraps flock() to acquire shared or exclusive locks, supports blocking and non-blocking modes, and provides simple lock release, helping coordinate concurrent processes and prevent race conditions.
composer require yriveiro/php-fslock:^3.0
Read src/ before the README — the package is still only one or two classes, and the 3.0.0 notes describe a constructor change without showing the signature. Confirm three things in the source: the argument order of the constructor (the new second parameter selects where locks are stored, so it is likely new Lock($name, $storageDir) rather than the old path-only form), that the public methods are still acquire()/release(), and that destroy() is gone (it is — calling it now fatals).
Check composer.json for the platform constraint before you plan anything. "Bump minimal PHP version to 7.x" usually lands as "php": "^7.0", which will refuse to install on PHP 8 even though the code likely runs fine there. If your app is on 8.1+, verify the constraint first; if it blocks you, a composer.json patch via a fork or a "replace" entry is a two-minute fix given the size of the package.
Baseline requirements are unchanged: core flock() (Linux/Unix), a writable directory for lock files, no extensions or daemons. The difference is that the directory is now something you pass in — point it at storage_path('locks') and create it at deploy time.
First use case — guard a cron job or artisan command against overlapping runs:
use FSLock\Lock;
$lock = new Lock('report-cron', storage_path('locks')); // verify arg order in src/
if (! $lock->acquire()) {
fwrite(STDERR, "already running\n");
exit(1);
}
try {
generateReports();
} finally {
$lock->release();
}
acquire() returns a boolean; treat false as "someone else holds it", not as an error. The lock file is created lazily on first acquire, so the storage directory must already exist and be writable by whichever user runs the process.
Non-blocking guard (the default and best fit). For cron jobs, queue workers, and "only one of these at a time" endpoints, always call the non-blocking form and bail loudly. Exiting with a distinct status code makes monitoring easy to wire up.
Blocking with a timeout. flock() has no timeout of its own — a blocking acquire can wait forever. Build a retry loop in your own wrapper rather than passing blocking: true from a request path:
$deadline = microtime(true) + 5.0;
while (! $lock->acquire()) {
if (microtime(true) > $deadline) {
throw new RuntimeException('could not acquire lock');
}
usleep(100_000);
}
Centralize name and directory together. Now that the storage location is a constructor argument, make it a config value and never let call sites invent either half:
function lockFor(string $name): Lock
{
$safe = preg_replace('/[^a-z0-9_-]/i', '_', $name);
return new Lock($safe, config('locks.path', storage_path('locks')));
}
Hash the name if it can be long (md5($name)) — filenames are length-limited and case-sensitivity varies.
Always release in finally, and rely on process exit as the backstop. PHP drops the underlying file handle when the Lock object is garbage-collected or the process exits, which releases the flock. A fatal error or kill -9 does not leave a stuck lock — a genuine advantage over DB-row or file-marker locking. Keep the finally anyway for clarity and for long-lived processes.
No more destroy(). 3.0.0 removed it, and the release notes admit it left the instance unusable. Good riddance: it was almost certainly an unlink(), which is exactly the thing that breaks flock semantics (see Gotchas). Replace any $lock->destroy() call with $lock->release() and let the file stay on disk.
Laravel integration. Bind the factory above as a singleton in a service provider, or inject a LockFactory interface you own so tests can substitute a no-op. In commands, guard at the top of handle(). For HTTP routes, a small middleware keyed on route + user ID is a clean way to reject double-submits. Note that Laravel's Cache::lock() with the file store is not flock-based across processes in the way you might expect — if you specifically need cross-process serialization on one host, this package is the more honest primitive.
Pair with atomic filesystem work. Use the lock to serialize the decision, then write via tempnam() + rename() so readers never observe partial content. The lock protects cooperating writers, not readers.
Idempotency keys. Derive the lock name from the job's idempotency key so retries collide with the original attempt, not with an unrelated run.
destroy() no longer exists (fatal error), and the constructor gained a parameter that changes where files land. If the old default directory was something like the system temp dir, existing lock files there become invisible to 3.0 instances — during a rolling deploy, old and new code can hold different locks for the same job. Drain or restart workers during the deploy rather than running both shapes side by side.unlink() a held lock file, the next process creates a fresh inode at the same path and both proceed. Leave them in place; a leftover file is harmless because lock state dies with the process. No reaper needed, ever — and note the removal of destroy() removes the footgun that made this easy to do by accident."php": "^7.0" constraint still gates installation on 8.x. Check the constraint, then run the suite on 8.1/8.2. Worst case, the package is small enough to fork or vendor into app/Support in an afternoon.flock() over NFS is unreliable-to-broken depending on kernel and mount options. If your app runs on more than one host, use Redis or a DB advisory lock instead.fopen() handles to the same file in the same process are separate file descriptions, so a second non-blocking acquire may fail against yourself. Guard against re-entrancy in the wrapper.src/ for LOCK_SH support; minimal wrappers usually only do exclusive. If you need reader/writer semantics, add it in your own decorator.0664, or pre-created files with correct ownership, avoids the "one user can't open the other's lock" mystery.flock occupies a worker indefinitely. On any request path, use the timeout loop above; never a bare blocking call.lsof path/to.lock or fuser -v path/to.lock. For self-service diagnosis, write the PID and a timestamp into the lock file immediately after acquiring (truncate + write on the already-locked handle is safe) and log it on the failure path.flock() ignores your lock entirely. Use it for coordination between your own code paths, not as a security boundary.pcntl_fork() in a CLI test, or shell out to a second PHP process; a single-process PHPUnit test can never observe a real conflict because flock is per file description. Point the constructor at storage_path('framework/testing/locks') in tests so you don't collide with real cron locks.TimeoutLock and NullLock around the package gives you testability, retries, and shared-lock support without forking — and makes swapping to Redis trivial later.composer require yriveiro/php-fslock:^3.0 plus a committed composer.lock is the safe posture; expect the constructor and return types to settle over the next couple of releases.How can I help you explore Laravel packages today?