- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You ship a billing cron. It runs every five minutes. You scale to two app servers. Suddenly invoices get charged twice and the support inbox lights up. Someone says "we need Redis and a Lua script." You don't. You need symfony/lock and about fifty lines of PHP.
The Lock component is the most underrated piece of the Symfony ecosystem. It works in Laravel projects. It works in plain PHP scripts. It speaks Postgres advisory locks, MySQL GET_LOCK, Redis, Memcached, ZooKeeper, MongoDB, and a flat-file backend for single-host setups. One API, five-plus stores, zero new infrastructure if you already have a database.
Why distributed locks exist
The problem is older than your career. Two processes want to do a thing. The thing must happen at most once. Without coordination, both processes do it, and the side effects compound.
A team I worked with last year had a "send weekly digest" cron firing on three workers behind a load balancer. The job was idempotent on paper. In practice, the third email provider call wasn't, and customers got three identical digests on Monday morning. The fix wasn't rewriting the job. The fix was a single line that said "only one of you runs at a time."
That's the contract a distributed lock gives you. Whoever grabs it first runs. Whoever didn't either waits or skips.
The Lock API in 50 lines
Install it:
composer require symfony/lock
That pulls in symfony/lock 7.x as of 2026. No Symfony framework required. The component is standalone.
A minimal example, using a flat-file store so you can run it on your laptop:
<?php
declare(strict_types=1);
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;
require __DIR__.'/vendor/autoload.php';
$store = new FlockStore(sys_get_temp_dir());
$factory = new LockFactory($store);
$lock = $factory->createLock('cron-billing');
if (!$lock->acquire()) {
// someone else owns it, bail
fwrite(STDERR, "another worker is running billing\n");
exit(0);
}
try {
// your at-most-once work
runBillingBatch();
} finally {
$lock->release();
}
That's the entire surface area. createLock($resourceName) returns a LockInterface. acquire() returns bool. release() lets go. The try/finally is non-negotiable. Drop it once and you'll find yourself SSH'd into prod at 2am running DELETE FROM lock_keys WHERE key_id = ....
Swap the store and it becomes distributed. That's the whole pitch.
Five backends, five trade-offs
symfony/lock ships these stores (the names match the actual class names so you can grep):
-
FlockStore:flock()on a local file. Single-host only. Free, zero latency, useless across machines. -
PostgreSqlStore: Postgres advisory locks (pg_advisory_lock). Session-scoped, auto-released when the connection dies. Costs you nothing if you already run Postgres. -
PdoStore: generic SQL row-insert lock. Works on MySQL, MariaDB, SQLite. Slower than advisory locks because it's a real row. -
RedisStore:SET key NX PX <ttl>. Fast. Standard Redlock semantics if you wrap it inCombinedStoreacross multiple Redis nodes. -
MemcachedStore: Memcachedaddop. Fast. No persistence, so a Memcached restart drops every held lock. -
MongoDbStore: TTL index on a collection. Honest about expiry. Slow compared to Redis. -
ZookeeperStore: if you already run ZooKeeper, you know why. If you don't, skip it. -
SemaphoreStore: POSIX semaphores, single host only.
The two that matter for 90% of PHP shops are PostgreSqlStore and RedisStore.
Postgres advisory locks are my default. They're free if your app already talks to Postgres. They release automatically when the connection closes, which means a crashed PHP process can't orphan the lock past its TCP timeout. They're held at the session level, so they don't block other transactions. And they're absurdly fast (single-digit microseconds) because Postgres holds them in memory, not on disk.
Switching the example above to Postgres looks like this:
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\PostgreSqlStore;
$dsn = 'pgsql:host=localhost;dbname=app';
$store = new PostgreSqlStore($dsn, [
'db_username' => 'app',
'db_password' => 'app',
]);
$factory = new LockFactory($store);
$lock = $factory->createLock('cron-billing');
Same surface, different guarantees. The PostgreSqlStore constructor also accepts an existing PDO instance. Pass your already-pooled connection in and you get locking on top of your current DB traffic, no extra connection.
Blocking vs non-blocking acquire
acquire() is non-blocking by default. Returns true if you got the lock, false if someone else has it. Most cron-style workloads want this: "if billing is running, I bail, the next cron tick will pick it up."
Pass true and it blocks until the lock is free:
$lock = $factory->createLock('cron-billing');
// wait until I can have it
$lock->acquire(true);
try {
runBillingBatch();
} finally {
$lock->release();
}
Blocking acquire is for "every request must complete in order" workflows. Payment retries against a single account, for example. Don't use it for crons. A blocked cron is a process holding a database connection doing nothing, and if your job runner is k8s with no resource limits set, you'll find that out the bad way.
There's also acquireRead() for shared locks (multiple readers, one writer) if you back the lock with a store that supports it. Postgres and Redis do.
TTL and the auto-release gotcha that bites cron jobs
This is where most teams trip.
You call $factory->createLock('cron-billing', 300). The second arg is the TTL in seconds. The store records "this lock expires in 5 minutes." Your job runs. Halfway through, the PHP process gets killed (OOM, k8s pod eviction, deploy, whatever). The lock sits there for the remaining 4 minutes. Next cron tick fires, sees the lock is held, bails. Four minutes of billing skipped.
Worse: the TTL is too short, your job is slow, the TTL expires while the job is still running, a second process grabs the lock, and now you're back to double-billing with extra steps.
There are three knobs to actually get this right.
One: pick a TTL longer than your worst-case job duration. Not the p50. The p99 with a safety factor. If billing usually takes 30 seconds but can hit 4 minutes during month-end, your TTL is 10 minutes minimum.
Two: refresh the lock if the job is long. LockInterface::refresh() extends the TTL. Call it periodically from inside the job:
$lock = $factory->createLock('cron-billing', 60); // 60s TTL
if (!$lock->acquire()) {
exit(0);
}
try {
foreach ($invoices as $invoice) {
chargeInvoice($invoice);
// every iteration, push the expiry out 60s
$lock->refresh();
}
} finally {
$lock->release();
}
Three: use a store with native session-scoped locks. PostgreSqlStore is the one that gives this for free. The lock dies with the database session. If your PHP process dies hard, the TCP connection times out (TCP keepalive is usually 2 hours by default; tune it on your DB or pool), Postgres notices, the lock releases. No orphaned-key cleanup script, no Lua TTL math.
Redis, by contrast, only knows about expiry, not about whether you're still alive. So with RedisStore you live and die by the TTL.
A "run this at most once" decorator
Composition over copy-paste. Most cron jobs in your app want the same thing: "wrap me in a lock, bail if someone else owns it." Stop sprinkling acquire() calls across twelve handlers. Write the decorator once.
<?php
declare(strict_types=1);
namespace App\Cron;
use Psr\Log\LoggerInterface;
use Symfony\Component\Lock\LockFactory;
interface CronJob
{
public function name(): string;
public function run(): void;
}
final class AtMostOnce implements CronJob
{
public function __construct(
private readonly CronJob $inner,
private readonly LockFactory $factory,
private readonly LoggerInterface $logger,
private readonly int $ttlSeconds = 300,
) {}
public function name(): string
{
return $this->inner->name();
}
public function run(): void
{
$lock = $this->factory->createLock(
'cron:'.$this->inner->name(),
$this->ttlSeconds,
autoRelease: false, // we'll release explicitly
);
if (!$lock->acquire()) {
$this->logger->info('cron skipped, lock held', [
'job' => $this->inner->name(),
]);
return;
}
try {
$this->inner->run();
} finally {
$lock->release();
}
}
}
Now your billing job is one line of wiring:
$billing = new AtMostOnce(
new BillingCron($invoices),
$factory,
$logger,
ttlSeconds: 600,
);
$billing->run();
That autoRelease: false flag is worth a comment. By default the Lock releases on object destruction. That sounds friendly until the PHP request shutdown order eats your finally, the destructor fires inside a fatal error handler, and you get cryptic warnings about a released-but-still-referenced lock. Disable auto-release, do it explicitly, sleep better.
The decorator is also a nice seam for metrics. Emit a counter every time acquire() returns false and you've got a free "lock contention" dashboard.
Using it from Laravel
symfony/lock is framework-agnostic. There's nothing Symfony-specific about it. Composer-require it into a Laravel app and bind the factory in a service provider:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\PostgreSqlStore;
class LockServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(LockFactory::class, function ($app) {
$pdo = $app['db']->connection()->getPdo();
return new LockFactory(new PostgreSqlStore($pdo));
});
}
}
Pulling the active PDO out of Laravel's connection means you reuse the existing pool. No new connections, no new env vars, no Redis to provision. Inject LockFactory into any Artisan command or queued job and you have distributed locks across every web server, queue worker, and scheduled task.
Yes, Laravel has Cache::lock(). It works. It's also tied to the cache driver, which means switching from Redis to Postgres for locks means switching your cache driver, and that's a refactor nobody wants. symfony/lock decouples the lock store from everything else.
When to reach for something else
A lock is a coordination primitive. It's not a transaction, not a queue, not an outbox. If you find yourself writing five locks to orchestrate a workflow, you wanted a state machine or a saga, not a lock.
And if you're tempted to lock for performance ("we'll lock per-user to avoid race conditions on the cart"), measure first. A row-level database lock or an INSERT ... ON CONFLICT is usually faster and less surprising than a distributed lock with a TTL.
For "this thing must happen at most once across N workers," though, symfony/lock is the answer. Fifty lines of code, no Redis required, and the same API whether your store is a flat file on your laptop or a Postgres cluster handling a thousand crons a minute.
What's your go-to store for distributed locks in PHP, and have you ever been bitten by a TTL that fired mid-job?
If this was useful
The Lock component is one of those Symfony pieces that quietly does the right thing while the framework war rages on around it. If you like how the API stays the same across five backends, that's hexagonal thinking applied to coordination primitives. Decoupled PHP is the architectural layer your codebase reaches for after it outgrows the framework defaults, with the same instincts applied across persistence, messaging, and use cases.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)