Distributed Locks with Redis
Race conditions in distributed PHP applications lead to double bookings, corrupted data and bugs that are hard to reproduce. Symfony Lock with Redis solves this problem with a clean API for distributed locks, no manual SETNX scripts, no fragile database row locks, but a tested lock that expires automatically.
Table of Contents
- 1. The race condition problem in distributed PHP apps
- 2. Installation and store selection
- 3. Configuring RedisStore and using LockFactory
- 4. TTL strategy: locks that unlock themselves
- 5. Blocking locks and waiting strategies
- 6. Protecting cron jobs against duplicate execution
- 7. Queue workers and resource locks
- 8. Testing locks with InMemoryStore
- 9. Comparing lock store options
- 10. Summary
- 11. FAQ
1. The race condition problem in distributed PHP apps
Race conditions occur when two or more processes read the same state at the same time, independently make a decision, and then write the same state, without knowing that the other process is doing the same thing. The classic example in e-commerce: two orders for the same product arrive milliseconds apart. Both processes check the stock level, both see 1 unit still available, both confirm the order, both write the stock level to 0. Result: stock level at -1, customer has a confirmation without goods. This bug is not reproducible in single-instance tests and only shows up under load with multiple workers or container instances.
The naive solution, database row locks with SELECT ... FOR UPDATE, works in a single database but scales poorly and unnecessarily lengthens transactions. Redis-based distributed locks solve the problem at the infrastructure level: Redis is fast, atomic (SETNX is single-threaded) and supports TTL-based automatic expiry. Symfony Lock abstracts Redis operations, Lua scripts and retry logic into a simple PHP API, one LockFactory, one lock object, two methods: acquire() and release().
2. Installation and store selection
The symfony/lock package is installed via Composer and additionally requires predis/predis or the PHP extension ext-redis for Redis operation. Symfony Flex automatically creates the basic configuration in config/packages/lock.yaml. The most important decision during installation is the choice of lock store: the store determines where the lock information is stored and whether the lock is actually distributed. For single-instance applications without scaling requirements, FlockStore or SemaphoreStore on the local file system is sufficient. For multiple containers, multiple servers or horizontal scaling, RedisStore or PostgreSqlStore is the right choice.
The configuration in lock.yaml is minimal: a DSN string or a service reference to a Redis connection is enough. Symfony Lock then automatically registers the LockFactory as a service, which can be injected into any service via constructor injection. Multiple lock stores for different use cases are possible: a fast RedisStore for short-lived locks, a PostgreSqlStore for long-lived locks that should also survive a database outage. The LockFactory instances are registered in the container under their own service IDs and injected selectively via #[Autowire].
<?php
// config/packages/lock.yaml configuration equivalent:
//
// framework:
// lock: '%env(REDIS_URL)%'
//
// Or with named stores for different use cases:
// framework:
// lock:
// default: '%env(REDIS_URL)%'
// postgres_lock: 'postgresql://user:pass@localhost/mydb'
// Installation commands:
// composer require symfony/lock
// composer require predis/predis # OR ensure ext-redis is installed
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Lock\LockFactory;
/**
* Example service using LockFactory via constructor injection.
* LockFactory is automatically available after installing symfony/lock.
*/
final readonly class OrderProcessingService
{
public function __construct(
private LockFactory $lockFactory,
private OrderRepository $orderRepository,
private InventoryService $inventoryService,
) {}
/**
* Process an order exclusively - no concurrent processing for the same order.
*
* @throws \Symfony\Component\Lock\Exception\LockConflictedException
*/
public function processOrder(string $orderId): void
{
// Create a named lock - only one process can hold this lock at a time
$lock = $this->lockFactory->createLock(
resource: 'order_processing_' . $orderId,
ttl: 30.0, // Auto-release after 30 seconds if process crashes
);
if (!$lock->acquire()) {
// Another worker is already processing this order
return;
}
try {
$this->doProcessOrder($orderId);
} finally {
// Always release, even on exception
$lock->release();
}
}
private function doProcessOrder(string $orderId): void
{
// Critical section - only one process executes this at a time
$order = $this->orderRepository->find($orderId);
$this->inventoryService->deductStock($order);
// ...
}
}
3. Configuring RedisStore and using LockFactory
The RedisStore in Symfony Lock internally uses a Lua script that atomically checks whether a key exists, sets it and activates a TTL, all in a single Redis command. This is important because individual Redis commands are atomic, but a sequence of commands is not. Without the Lua script, another process could set the same key between the check (SETNX) and setting the TTL (EXPIRE). With the Lua script, this combination is atomic and therefore thread-safe, even with parallel PHP workers on different servers.
The LockFactory is the central service for all lock operations. It creates lock objects with the method createLock(resource, ttl). The resource string is the lock name, it uniquely identifies what is being locked. The convention is a descriptive, resource-specific string: inventory_update_sku_12345, payment_capture_order_abc or cron_daily_report. The TTL is the maximum runtime of the lock in seconds, important as a safety net in case a process ends without release(): crash, OOM, SIGKILL. The lock then automatically expires and releases the resource without requiring manual intervention.
4. TTL strategy: locks that unlock themselves
The choice of TTL is one of the critical decisions when using Symfony Lock with Redis. A TTL that is too short causes the lock to expire while the process is still running, so another process can then step in even though the first one is not yet finished. A TTL that is too long means that after a process crash, the resource stays blocked for an unnecessarily long time. As a rule of thumb: set the TTL to two to three times the expected maximum runtime. For a cron job that normally runs for 5 seconds and, in exceptional cases, up to 30 seconds, a TTL of 60 to 90 seconds is appropriate.
Symfony Lock supports automatic refresh of the TTL while the process is running: $lock->refresh() extends the TTL by the configured value. This allows locks for long runners without the risk of premature expiry. A background thread or a regular refresh() call in the process keeps the lock alive. This principle is called a "heartbeat" and is essential for batch jobs and queue workers that do not have a fixed runtime ceiling. Alternatively, a Symfony Messenger middleware can keep the lock and the refresh in a shared context, an elegant solution for queue-based workloads.
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\SharedLockInterface;
/**
* Demonstrates TTL strategy and lock refresh for long-running processes.
*/
final readonly class InventoryBatchProcessor
{
// Expected max runtime: 5 minutes - TTL set to 10 minutes as safety net
private const LOCK_TTL = 600.0;
// Refresh every 2 minutes to keep the lock alive
private const REFRESH_INTERVAL = 120;
public function __construct(
private LockFactory $lockFactory,
) {}
/**
* Process inventory update exclusively with automatic TTL refresh.
*/
public function processBatch(string $batchId, iterable $items): void
{
$lock = $this->lockFactory->createLock(
resource: 'inventory_batch_' . $batchId,
ttl: self::LOCK_TTL,
autoRelease: true, // Automatically released when $lock goes out of scope
);
if (!$lock->acquire(blocking: false)) {
throw new \RuntimeException("Batch {$batchId} is already being processed.");
}
$lastRefresh = time();
try {
foreach ($items as $item) {
// Refresh TTL every REFRESH_INTERVAL seconds
if (time() - $lastRefresh >= self::REFRESH_INTERVAL) {
$lock->refresh(self::LOCK_TTL);
$lastRefresh = time();
}
$this->processItem($item);
}
} finally {
$lock->release();
}
}
private function processItem(mixed $item): void
{
// Item processing logic here
}
}
5. Blocking locks and waiting strategies
By default, $lock->acquire() tries once to acquire the lock and returns false if it is not available. This is the non-blocking behavior, useful for processes that should simply skip when there is contention (cron jobs, worker deduplication). For processes that need to wait until a resource is released, there are blocking locks: $lock->acquire(blocking: true) blocks the PHP process until the lock can be acquired. This is intended for queue consumers that need to wait for the processing of a particular resource.
Between non-blocking and unlimited blocking there is a middle ground: retrying with sleep. Symfony Lock provides the RetryTillSaveStore decorator for this, which retries acquiring the lock several times with a configurable wait time if it is not available. The configuration includes maximum wait time, interval between attempts and jitter (random variation of the interval) to avoid thundering-herd problems, when all waiting processes try to acquire the same lock at the same time as soon as it is released. For highly time-critical scenarios, the ExpirationStore approach with Lua scripts on Redis can be used to react directly to the lock expiry event instead of polling.
6. Protecting cron jobs against duplicate execution
The most common use case for Symfony Lock in PHP applications is protecting cron jobs against duplicate execution. If a cron job runs every five minutes but, in rare cases, takes longer than five minutes, two instances of the same job start at the same time. With a distributed lock, this is ruled out: the second instance checks the lock, finds it occupied and terminates immediately. A Symfony console command integrates locks elegantly: LockableCommandTrait provides the method $this->lock(), which automatically creates a lock with the command name and releases it again when the command finishes.
For commands without the trait, the same pattern applies: acquire the lock before the actual work begins, release the lock again in a finally block at the end. The combination of a quick check and immediate return when the lock is occupied makes cron job locking with Symfony Lock a one-line addition to existing commands. No database access, no PID files, no systemd mechanisms, the lock lives in Redis with a TTL as a safety net and releases itself when the command ends normally or through a crash.
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
/**
* Daily report command protected against concurrent execution via Symfony Lock.
*/
#[AsCommand(name: 'app:daily-report', description: 'Generate daily sales report')]
final class DailyReportCommand extends Command
{
// LockableTrait provides $this->lock() and $this->release()
use LockableTrait;
public function __construct(
private readonly LockFactory $lockFactory,
private readonly ReportGenerator $reportGenerator,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Attempt to acquire lock - non-blocking, fails immediately if locked
if (!$this->lock()) {
$output->writeln('<comment>Command is already running. Exiting.</comment>');
return Command::SUCCESS;
}
try {
$output->writeln('Generating daily report...');
$this->reportGenerator->generate();
$output->writeln('<info>Report generated successfully.</info>');
return Command::SUCCESS;
} finally {
// Always release lock, even on exception or early return
$this->release();
}
}
}
7. Queue workers and resource locks
Message queue workers are another important use case for Symfony Lock. When several worker processes run in parallel and process messages from a queue, certain messages need to be processed exclusively, for example all messages relating to the same customer, or all messages for the same external API account. A Symfony Messenger middleware implements this locking pattern transparently: before the message is handed over to the handler, the middleware acquires the lock; after the handler runs, it releases it again.
The design of the lock key is crucial here. A global lock for all workers completely blocks parallel processing, which is rarely the goal. A resource-specific lock, for example payment_gateway_stripe_account_acct_123, allows parallel processing for different Stripe accounts, but exclusive processing for the same account. This granular approach maximizes throughput without risking race conditions between messages for the same resource. With Symfony Lock and Redis, this granular lock can be implemented without extra effort: the key is the only difference.
8. Testing locks with InMemoryStore
Tests that need real Redis connections are slow and dependent on external infrastructure. Symfony Lock offers the InMemoryStore for tests, which simulates the full lock behavior in memory, including TTL, blocking semantics and concurrent locks on different resources. The InMemoryStore implements the same LockStoreInterface as the RedisStore, so no production code needs to be changed. The LockFactory is configured with the InMemoryStore in tests and passed into the service under test via constructor injection.
For concurrent lock tests, two LockFactory instances can be created with the same InMemoryStore to test race condition scenarios: first lock acquires successfully, second lock acquire returns false. After the first lock is released, the second one can acquire it. These tests fully verify the locking behavior of the service without a real Redis server, without network latency and without state pollution between tests. This makes the lock logic testable, deterministic and runnable in CI pipelines without a Redis sidecar.
9. Comparing lock store options
Choosing the right lock store for Symfony Lock depends on the infrastructure, the scalability requirements and the acceptance of constraints. Not every store is suitable for every use case.
| Store | Distributed | TTL | Use case |
|---|---|---|---|
| RedisStore | Yes | Yes (automatic) | Multi-server, Kubernetes, scaling |
| PostgreSqlStore | Yes | No (advisory locks) | When Redis is not available |
| FlockStore | No | Only via process end | Single server, simple cron |
| InMemoryStore | No | Yes (simulated) | Tests, no real infrastructure |
| CombinedStore | Yes (multiple) | Yes | High availability with multiple Redis instances |
The CombinedStore is the equivalent of RedLock, the distributed lock algorithm from Redis creator Salvatore Sanfilippo. It requires that a lock be acquired on a majority of the configured Redis instances. This protects against the failure of a single Redis instance but increases the latency when acquiring the lock. For most Symfony applications, a single RedisStore with Sentinel or cluster configuration is sufficient, CombinedStore is for scenarios with very high availability requirements.
Mironsoft
Symfony architecture, distributed systems and Redis integration
Solving race conditions and concurrent processing problems?
We implement distributed locks with Symfony Lock and Redis for scalable PHP applications, from cron job protection to queue worker coordination to a complete concurrent processing architecture.
Lock architecture
Store selection, TTL strategy and granular resource locks for your stack
Queue & cron
Messenger middleware and command locks for reliable background processes
Testing
InMemoryStore tests for complete lock coverage without Redis infrastructure
10. Summary
Symfony Lock with Redis solves race conditions in distributed PHP applications with a clear, testable API. The LockFactory creates named locks with a TTL that expire automatically if a process crashes, no manual cleanup, no orphaned locks, no stored state. The RedisStore uses atomic Lua scripts for safe distributed locks across any number of servers and container instances. Cron jobs are protected against duplicate execution in one line with LockableTrait. Queue workers get granular resource locks without a global blockage.
The decisive advantage over manual Redis SETNX implementations or database row locks: Symfony Lock is tested, documented and can be fully reproduced in unit tests with the InMemoryStore. New developers on the team do not need to bring Redis locking know-how, they use the abstracted API and still get all the safety and resilience properties of a well-implemented distributed lock.
Symfony Lock with Redis, the essentials at a glance
LockFactory
createLock(resource, ttl) creates named locks. acquire() non-blocking, acquire(true) blocking. Always release with release() in finally.
TTL strategy
Set TTL to 2 to 3 times the expected max runtime. refresh() for long runners. On crash the lock expires automatically, no manual cleanup.
Cron protection
LockableTrait in console commands: $this->lock() prevents duplicate execution with one line. Automatic release at command end.
Testing
InMemoryStore simulates full lock behavior in memory, concurrent lock tests without Redis, without infrastructure, deterministic.