Symfony Lock: Distributed Locks with Redis
AI generated
SF
{ }
Symfony · Lock · Redis · Distributed Systems · PHP 8.4
Symfony Lock:
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.

15 min read LockFactory · RedisStore · TTL · Blocking · Cron · Queue Symfony 7.x · PHP 8.4 · Redis 7

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.

11. FAQ: Symfony Lock and Distributed Locks with Redis

1What is Symfony Lock?
Component for mutual exclusion locks in PHP. Abstracts Redis, PostgreSQL and the file system as lock stores with a unified API, TTL and blocking support.
2What is a distributed lock?
A lock across multiple processes, servers or containers, coordinates exclusive resource access in distributed systems, not just within a single process.
3Why Redis for locks?
Atomic Lua scripts, automatic TTL expiry and high performance. The RedisStore uses these for safe distributed locks with automatic release on process crash.
4Crash: what happens to the lock?
Lock expires automatically after the TTL. Redis deletes the key, no manual cleanup, no orphaned locks. That is why the TTL should be set to 2 to 3 times the max runtime.
5acquire(true) vs acquire(false)?
false = non-blocking, returns false immediately. true = blocking, waits for the lock. Cron/deduplication: non-blocking. Queue consumer: blocking.
6Protecting cron jobs?
LockableTrait in console commands: $this->lock() acquires a lock with the command name. Not available, terminate immediately. $this->release() releases it in finally.
7Testing without Redis?
InMemoryStore: new LockFactory(new InMemoryStore()). Simulates full lock behavior in memory, deterministic, fast, without infrastructure.
8What is the CombinedStore?
RedLock equivalent: lock is considered acquired with a majority of the configured stores. High availability when a Redis instance fails, for critical production scenarios.
9TTL refresh for long runners?
$lock->refresh(ttl) extends the held lock. Heartbeat pattern: call refresh() regularly so the lock does not expire before the process ends.
10Usable without the Symfony framework?
Yes. symfony/lock is standalone and works in any PHP project. Service registration in the container is optional, instantiate LockFactory directly.