In high-concurrency applications, race conditions are the silent killers of data integrity. Whether it’s preventing double-booking in a reservation system, ensuring a cron job runs on only one server, or throttling API usage, the Symfony Lock Component is your first line of defense.
With the release of Symfony 7.4, the ecosystem has matured, offering cleaner attributes, better integration with cloud-native stores (like DynamoDB) and PHP 8.4 support. This article covers the battle-tested best practices I use in production, ensuring your application remains robust and deadlock-free.
Installation and Configuration
Start by installing the component. We will use the standard symfony/lock package. If you plan to use Redis (recommended for distributed systems), ensure you have a client like predis/predis or the ext-redis extension.
composer require symfony/lock
# If using Redis
composer require predis/predis
# If using the new DynamoDB store (Symfony 7.4+)
composer require symfony/amazon-dynamo-db-lock
Configuration
In config/packages/lock.yaml, define your “lockers.” A common mistake is using a single default store for everything. I recommend defining named lockers for different business domains to avoid collisions and allow different storage strategies (e.g., local files for cron jobs vs. Redis for user actions).
framework:
lock:
# Default store (good for single-server setups)
enabled: true
# Named lockers
resources:
# Critical business locks (Distributed)
order_processing: '%env(REDIS_DSN)%'
# CLI command locks (Local is usually fine)
cron_jobs:
- 'flock'
# New in 7.4: DynamoDB for serverless architectures
# This is commented out as it requires AWS credentials and the symfony/amazon-dynamo-db-lock package.
# To use it, install the package and configure your AWS credentials.
# invoice_generation:
# - 'dynamodb://default/lock_table'
# For the attribute example, we'll just use redis.
invoice_generation: '%env(REDIS_DSN)%'
The Golden Rule: The Try-Finally Pattern
The single most important rule when working with locks is ensuring they are released, even if your code crashes. While Symfony attempts to auto-release locks on object destruction, you should never rely on implicit behavior for critical resources.
The Pattern
Always wrap your critical section in a try block and release in finally.
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Lock\LockFactory;
readonly class OrderProcessor
{
public function __construct(
#[Target('order_processing')]
private LockFactory $lockFactory,
private LoggerInterface $logger,
) {}
public function processOrder(int $orderId, bool $crash = false): void
{
// The resource name should be unique for each order.
$lock = $this->lockFactory->createLock('order_' . $orderId, 30);
$this->logger->info(sprintf('Attempting to acquire lock for order %d.', $orderId));
if (!$lock->acquire()) {
// Fail fast if another process is already handling this order.
$this->logger->warning(sprintf('Order %d is already being processed.', $orderId));
throw new \RuntimeException(sprintf('Order %d is already being processed.', $orderId));
}
$this->logger->info(sprintf('Lock acquired for order %d.', $orderId));
try {
// CRITICAL SECTION
// This is where you would perform payment capture, inventory updates, etc.
$this->logger->info(sprintf('Processing order %d. This will take a few seconds.', $orderId));
sleep(5); // Simulate work
if ($crash) {
$this->logger->error(sprintf('Simulating a crash while processing order %d.', $orderId));
throw new \Exception('Something went wrong! The payment gateway is down.');
}
$this->chargeUser($orderId);
$this->logger->info(sprintf('Finished processing order %d.', $orderId));
} finally {
$this->logger->info(sprintf('Releasing lock for order %d.', $orderId));
$lock->release();
}
}
private function chargeUser(int $id): void
{
// In a real application, this would interact with a payment service.
$this->logger->info(sprintf('Charging user for order %d.', $id));
// ... payment logic
}
}
To verify this works, throw an exception inside the try block during development.
- Acquire the lock.
- Throw new \Exception(‘Crash!’).
- Check your storage (e.g., Redis KEYS *). The lock key should be gone immediately after the exception is caught by the kernel or bubbles up.
Selecting the Right Store
Choosing the wrong store is a common architectural flaw.
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
| Store | Use Case | Pros | Cons |
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
| Flock | Single-server cron jobs, local dev. | Zero dependency, persistent on disk. | Fails in Kubernetes/Docker Swarm (filesystems aren't shared). |
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
| Redis | Distributed apps, user requests, | Extremely fast, supports TTL. | Requires Redis. Volatile (locks lost if Redis |
| | API limits. | | crashes without AOF). |
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
| Semaphore| Local high-performance IPC. | Fastest for local processes. | OS-dependent constraints. Hard to debug. |
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
| DynamoDB | Serverless / AWS Lambda environments.| Highly available, no server management.| Higher latency than Redis (network roundtrip). |
+----------+--------------------------------------+---------------------------------------+-----------------------------------------------------------+
- Go Local with Flock or Semaphore if you are running on a single machine and need maximum speed with minimum overhead.
- Go Distributed with Redis for most modern web applications where multiple nodes need to coordinate quickly.
- Go Cloud-Native with DynamoDB if you are building in a serverless environment where maintaining a persistent connection to a cache like Redis is inefficient.
If you are running on Kubernetes, never use Flock or Semaphore for application-level locks. Always use Redis, Memcached, or Database stores (PDO/DynamoDB).
Declarative Locking with Attributes
In Symfony 7.4 + PHP 8.x, we can clean up our controllers significantly. Instead of injecting LockFactory into every controller, we can create a custom #[Lock] attribute. This is a “Senior Developer” pattern that keeps your domain logic clean.
Create the Attribute
namespace App\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
final class Lock
{
public function __construct(
public string $resourceName,
public int $ttl = 30,
public bool $blocking = false
) {}
}
Create the Event Listener
We use the kernel events to acquire the lock before the controller executes and release it afterwards.
namespace App\EventListener;
use App\Attribute\Lock;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
class LockAttributeListener
{
private \WeakMap $locks;
public function __construct(
#[Target('invoice_generation')]
private readonly LockFactory $lockFactory,
private readonly LoggerInterface $logger,
) {
$this->locks = new \WeakMap();
}
#[AsEventListener(event: KernelEvents::CONTROLLER)]
public function onKernelController(ControllerEvent $event): void
{
$attributes = $event->getAttributes();
if (!isset($attributes[Lock::class])) {
return;
}
/** @var Lock $lockAttr */
$lockAttr = $attributes[Lock::class][0];
$request = $event->getRequest();
$resource = $lockAttr->resourceName;
// Simple interpolation for request attributes (e.g., 'invoice_{id}')
foreach ($request->attributes->all() as $key => $value) {
if (is_scalar($value)) {
$resource = str_replace("{{$key}}", (string) $value, $resource);
}
}
$this->logger->info(sprintf('Attempting to acquire lock for resource "%s".', $resource));
$lock = $this->lockFactory->createLock($resource, $lockAttr->ttl);
if (!$lock->acquire($lockAttr->blocking)) {
$this->logger->warning(sprintf('Resource "%s" is currently locked.', $resource));
throw new TooManyRequestsHttpException(null, 'Resource is currently locked.');
}
$this->logger->info(sprintf('Lock acquired for resource "%s".', $resource));
// Store lock to release it later
$this->locks[$event->getRequest()] = $lock;
}
#[AsEventListener(event: KernelEvents::TERMINATE)]
public function onKernelTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
if (isset($this->locks[$request])) {
/** @var LockInterface $lock */
$lock = $this->locks[$request];
$resource = 'unknown'; // Can't easily get the resource name back from the lock object
$this->logger->info(sprintf('Releasing lock for request to "%s".', $request->getPathInfo()));
$lock->release();
unset($this->locks[$request]);
}
}
}
Use it in your Controller
Now your controller is clean, readable and safe.
namespace App\Controller;
use App\Attribute\Lock;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class InvoiceController extends AbstractController
{
#[Route('/invoice/{id}/generate', name: 'invoice_generate')]
#[Lock(resourceName: 'invoice_{id}', ttl: 60)]
public function generate(int $id): Response
{
// This code executes ONLY if the lock is acquired
// ... heavy generation logic ...
return new Response('Invoice generated');
}
Handling Long-Running Tasks: The refresh() Method
A common pitfall is setting a TTL (Time To Live) that is too short for the task. If your task takes 31 seconds but your lock TTL is 30 seconds, the lock will expire, allowing another process to start, potentially corrupting data.
Instead of setting a massive TTL (e.g., 1 hour) which blocks the system if a crash occurs, use a shorter TTL and refresh it.
$lock = $factory->createLock('import_job', ttl: 30);
$lock->acquire(blocking: true);
try {
foreach ($largeDataSet as $row) {
$this->processRow($row);
// Extend the lock by another 30 seconds
$lock->refresh();
}
} finally {
$lock->release();
}
Verification:
- Set a TTL of 5 seconds.
- Run a loop that sleeps for 2 seconds and calls refresh().
- Monitor the expiration time in your store (e.g., Redis TTL lock_key). You should see it resetting to 30s repeatedly, never dropping to 0.
Blocking vs. Non-Blocking
By default, acquire() is non-blocking. It returns false immediately if the resource is busy. Pass true to wait indefinitely:
// Wait forever until lock is free
$lock->acquire(true);
Best Practice: Avoid indefinite blocking in HTTP requests. It ties up your PHP-FPM workers and can lead to a 504 Gateway Timeout. Use a loop with a timeout for better control:
$maxRetries = 5;
$retryCount = 0;
while (!$lock->acquire()) {
if ($retryCount++ >= $maxRetries) {
throw new \Exception('Could not acquire lock after 5 attempts');
}
sleep(1); // Wait 1 second before retrying
}
Conclusions
Using the symfony/lock component is not just about “locking” files; it’s about architectural intent. In Symfony 7.4:
- Always use Named Lockers: Separate your lock stores by domain (order, cron, user).
- Prefer Remote Stores: Use Redis or DynamoDB for any application running on more than one server.
- Clean Code: Adopt the Attribute pattern to remove boilerplate from your controllers.
- Safety First: The try-finally block is non-negotiable.
Concurrency bugs are notoriously hard to reproduce. Implementing these patterns today will save you hours of debugging tomorrow.
Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/SymfonyLockSample]
Let’s Connect!
If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:
- LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]
- X (Twitter): [https://x.com/MattLeads]
- Telegram: [https://t.me/MattLeads]
- GitHub: [https://github.com/mattleads]
Top comments (0)