symfony/lock
Symfony Lock provides a simple, reliable way to create and manage locks, giving exclusive access to shared resources across processes and servers. It supports multiple stores like files, Redis, and databases, with blocking and non-blocking lock modes.
Install the package:
composer require symfony/lock
Pin a patched version. This assessment targets the 8.1.x line; Symfony also backports security fixes to the 5.4 LTS branch. The v5.4.54 release is a security patch (see Gotchas → Redis Lock Tokens). If you are constrained to 5.4, require ^5.4.54 or newer:
composer require "symfony/lock:^5.4.54"
Verify what you actually have before debugging lock behaviour:
composer show symfony/lock
Configure a store (e.g., Redis):
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\LockFactory;
$store = new RedisStore(new \Redis());
$factory = new LockFactory($store);
First use case: Protect a critical section in a job or command:
use Symfony\Component\Lock\LockFactory;
public function handle()
{
$lock = $this->lockFactory->createLock('inventory_update_' . $this->orderId, 30);
$acquired = $lock->acquire(true); // Block until acquired
if ($acquired) {
try {
// Critical section: update inventory
Inventory::decrement($this->productId);
} finally {
$lock->release(); // Release when done
}
}
}
Laravel integration:
Bind the factory in AppServiceProvider:
public function register()
{
$this->app->singleton(LockFactory::class, function ($app) {
$store = new RedisStore($app['redis']);
return new LockFactory($store);
});
}
Use it in controllers/jobs:
$lock = app(LockFactory::class)->createLock('critical_section', 10);
if (! $lock->acquire()) { /* already running */ }
There is no first-party Laravel Lock facade; if you want a facade-like helper, alias the factory yourself:
'aliases' => [
'Lock' => Symfony\Component\Lock\LockFactory::class,
],
public function handle(LockFactory $locks)
{
$lock = $locks->createLock('job_' . $this->data['id'], 60);
if ($lock->acquire()) {
try {
$this->processData();
} finally {
$lock->release();
}
}
}
dispatchSync() with locks for fire-and-forget jobs requiring idempotency.$lock = $locks->createLock('account_' . $accountId, 30);
$lock->acquire(true);
try {
DB::transaction(function () use ($accountId) {
// Transaction logic
});
} finally {
$lock->release();
}
PdoStore with LOCK TABLE for heavy DB workloads (see Gotchas).$store = new RedisStore($redisClient, 'locks_');
$factory = new LockFactory($store);
30 seconds for short jobs).public function handle(LockFactory $locks)
{
$lock = $locks->createLock('command_' . $this->getName(), 3600);
if (!$lock->acquire()) {
throw new \RuntimeException('Command already running.');
}
try {
// command body
} finally {
$lock->release();
}
}
Service Container: Bind stores dynamically:
$this->app->bind(RedisStore::class, function ($app) {
return new RedisStore($app['redis'], 'laravel_locks_');
});
Lock Factory: Extend for custom logic:
class AppLockFactory extends LockFactory
{
public function createNamedLock(string $name, int $ttl = 30): LockInterface
{
return $this->createLock("app_{$name}", $ttl);
}
}
Fallback Chain: Combine stores for resilience (implement a small composite PersistingStoreInterface/StoreInterface that tries each backend in order, or run primary/secondary explicitly):
$primary = new RedisStore($redis);
$fallback = new FlockStore('/tmp/locks');
try {
$lock = (new LockFactory($primary))->createLock('report_' . $id, 60);
$acquired = $lock->acquire();
} catch (LockAcquiringException|LockProviderNotFoundException $e) {
$lock = (new LockFactory($fallback))->createLock('report_' . $id, 60);
$acquired = $lock->acquire();
}
Store-Specific Keys: Prefix keys to avoid collisions:
$store = new RedisStore($redis, 'myapp_');
public function handle(OrderPlaced $event, LockFactory $locks)
{
$lock = $locks->createLock('order_' . $event->order->id, 120);
if ($lock->acquire()) {
try {
// Process once
} finally {
$lock->release();
}
}
}
Lock Leaks
try-finally:
$lock = $locks->createLock('resource', 60);
try {
if ($lock->acquire()) {
// Work
}
} finally {
if ($lock->isAcquired()) {
$lock->release();
}
}
finally, and keep the critical section shorter than the TTL.Redis Lock Tokens — Upgrade Past v5.4.54 / Current Patch Level (security release)
__write__. Prior to the fix (5.4.54 on the LTS branch, with the equivalent fix on the current line), that reserved member could be picked up as if it were a lock token, meaning token parsing could be influenced by the store's own bookkeeping rather than the actual lock owner. The changelog entry is:
Never take the reserved
"__write__"member as a Redis lock token
composer require "symfony/lock:^5.4.54" # LTS branch (backport)
composer show symfony/lock # verify what is installed
Treat this as a minimum-version floor across all environments (local, CI, prod) — a stale composer.lock on one worker is enough to keep the exposure alive.Key/LockInterface and let the store own the format.StoreInterface or InstrumentedStore decorator that enumerates Redis members must skip reserved/internal members rather than assuming every member is a lock name.RedisStore($redis, 'myapp_')) so bookkeeping and lock names cannot collide.Only the owning key can release a lock (clarified in the v8.1.8 docs)
LockKey that acquired it. release() on a different instance/process — or on an instance whose lock already expired and was re-acquired by someone else — will not free the current holder's lock. Stores that track ownership tokens (Redis, PDO, etc.) throw Symfony\Component\Lock\Exception\LockReleasingException rather than deleting a lock they no longer own.Lock object just to release something acquired elsewhere — pass the same LockInterface instance (or persist the LockKey's unique token deliberately).release() after TTL expiry as a no-op/exception, not as "unlocking the other guy".isAcquired() and isExpired() before releasing:
if ($lock->isAcquired() && ! $lock->isExpired()) {
$lock->release();
}
LockReleasingException, log it — it usually means your TTL was too short for the work being done.Version Branch Confusion
^5.4 constraint resolves forward to patched releases automatically; a hard-coded version does not.PostgreSQL Transaction Contention
PdoStore with LOCK TABLE can abort outer transactions (fixed in v8.0.9+).SELECT FOR UPDATE instead:
$store = new PdoStore($pdo, 'locks', 'SELECT * FROM locks WHERE name = :name FOR UPDATE');
Redis Cluster Quirks
evalSha (fixed in v7.2.6+).RedisStore with useEval: false:
$store = new RedisStore($redis, null, null, false);
Key Normalization
createLock():
$name = 'user_' . $user->getKey() . '_' . md5(serialize($payload));
$lock = $locks->createLock($name, 30);
Windows File Locks
FlockStore may fail on network drives.RedisStore or PdoStore for cross-platform reliability.Lock Timeouts
$lock = $locks->createLock('resource', 10); // 10-second TTL
if (!$lock->acquire(true, 5)) { // Wait 5s
throw new \RuntimeException('Lock acquisition timed out.');
}
Redis Errors
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379, 0, null, 5.0, 2000, \Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY);
$store = new RedisStore($redis);
Suspicious Redis Lock Contents
Key and that no stray bookkeeping member (such as __write__) is being mistaken for a lock:
redis-cli --scan --pattern 'laravel_locks_*'
redis-cli HGETALL laravel_locks_my_resource
symfony/lock version alongside the dump; unpatched versions are the first suspect for token anomalies.PostgreSQL Locks
pg_locks:
SELECT * FROM pg_locks WHERE relation::regclass = 'locks';
Ownership/Release Failures
try {
$lock->release();
} catch (\Symfony\Component\Lock\Exception\LockReleasingException $e) {
Log::warning('Lock not released', [
'lock' => $lock->getKey() ?? null,
'expired' => $lock->isExpired(),
'error' => $e->getMessage(),
]);
}
Custom Stores
class DynamoStore implements StoreInterface
{
public function save(Key $key) { /* ... */ }
public function delete(Key $key) { /* ... */ }
public function exists(Key $key): bool { /* ... */ }
public function putOffExpiration(Key $key, $ttl) { /* ... */ }
}
Lock Strategies
LockFactory for custom acquisition logic:
class RetryLockFactory extends LockFactory
{
public function createLock(string $name, int $ttl = 30): LockInterface
{
return new RetryLock($this->store, $name, $ttl, 3); // Retry 3 times
}
}
Observability
symfony/lock does not ship a listener API on the factory; wrap acquisition in a decorator or custom store to emit metrics/logs:
class InstrumentedStore implements StoreInterface
{
public function __construct(private StoreInterface $inner) {}
public function save(Key $key)
{
Log::info('lock.save', ['key' => $key->getKey()]);
$this->inner->save($key);
}
// delegate delete(), exists(), putOffExpiration()
}
__write__ fix tightened. Stay on the Key abstraction.Ownership Tokens
Key/token yourself and rebuild the Key explicitly — and only if the store supports it. Otherwise prefer a dedicated "cancel" flag over cross-process unlocking.LockReleasingException on late release, too long stalls retries after a crash.acquire() + queue retry over long acquire(true, $timeout) calls that tie up workers.putOffExpiration() for genuinely long jobs instead of setting a huge TTL up front.How can I help you explore Laravel packages today?