Skip to content

The publication for web craftspeople Sunday, 18 October 2026

Back-end

Symfony 8.2 Streamlines Controller Locking With the #[Lock] Attribute

Symfony 8.2 adds the #[Lock] attribute, which places an application-level lock directly on a controller method without touching the Lock component's low-level API. Announced on October 16 on the framework's official blog, the feature targets catalog imports, payments, and limited-stock ticket sales,…

Symfony 8.2 adds a PHP attribute, #[Lock], that places an application lock directly on a controller method. The announcement, published on October 16 on the framework’s official blog by Javier Eguiluz, puts an end to a well-known chore for back-end teams: until now, protecting a controller against concurrent execution meant injecting a lock factory, building a key, acquiring the lock, handling the case where it is already held, then releasing it. A single attribute now replaces that plumbing.

The problem the attribute solves

Some actions must never run in parallel: importing a product catalog, charging an order, selling the last seats of a conference, or simply a user double-clicking a submit button. Without a lock, two concurrent requests can duplicate a payment or corrupt an import in progress. The Lock component already existed for this, but its low-level API slowed its adoption in the most exposed controllers.

The #[Lock] syntax

The attribute is placed on a controller method and accepts several parameters to adjust its behavior for each use case.

ParameterRoleDefault value
keyLock key (string, {name} placeholders, or an ExpressionLanguage expression)required
methodsHTTP method(s) covered by the lockall
blockingWait for release instead of rejecting the requestfalse
readShared lock via SharedLockInterfacefalse
ttlLock lifetime, in seconds30
factoryName of a specific lock factorydefault factory

By default, a request that hits an already-held lock receives a 409 Conflict response. A LockInterface or SharedLockInterface argument, declared as nullable in the method, lets you retrieve the lock acquired by the attribute to manipulate it, for example releasing it earlier than the end of the request.

A concrete example

The official post illustrates several scenarios: an exclusive import, a download that tolerates concurrent reads, a ticket sale that waits rather than rejecting, and a per-user lock built from an expression.

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpKernel\Attribute\Lock;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Routing\Attribute\Route;

class CatalogController extends AbstractController
{
    #[Route('/catalog/import', methods: ['GET', 'POST'])]
    #[Lock('catalog', methods: 'POST')]
    public function import(?LockInterface $lock): Response
    {
        // ... mise à jour du catalogue
        $lock?->release(); // libération anticipée, avant les notifications
        // ... notifications sans verrou
        return new Response();
    }

    #[Route('/catalog/download')]
    #[Lock('catalog', read: true)]
    public function download(): Response { /* téléchargements simultanés autorisés */ }

    #[Route('/conference/{conference}/tickets', methods: 'POST')]
    #[Lock('ticket-sale-{conference}', blocking: true, ttl: 10)]
    public function buyTicket(Conference $conference): Response { /* attend plutôt que de rejeter */ }
}

The last method in the official post combines an ExpressionLanguage expression and a named factory to lock per logged-in user rather than per global resource, which prevents a report launched by one account from blocking another’s.

The lock is acquired before the method’s arguments are resolved and before #[IsGranted] is evaluated: an unauthorized user can receive a 409 before even being denied with a 403.

Limitations worth knowing before adopting it

Three behaviors deserve a careful read before going to production. First, the order in which attributes are evaluated: #[Lock] runs before #[IsGranted] and before #[RateLimit], which can make a request wait or get rejected when it should have been denied for an authorization reason. Second, blocking: true has no timeout of its own: a request can stay blocked for as long as the lock is held elsewhere. Finally, an internal sub-request can reuse the main request’s lock, which is why the LockInterface argument should always be declared nullable.

About the TTL. Its default value of 30 seconds is not renewed automatically. For a longer-running task, the lock must be refreshed manually via the injected LockInterface instance, or another process may acquire it before the first task finishes.

This simplification fits a broader trend among back-end frameworks: reducing boilerplate around existing primitives rather than adding new ones. Node.js followed a similar logic by shipping the Temporal API by default in version 26, where Symfony instead chooses to hide the complexity of an already mature component behind a declarative attribute.

Key takeaways

The #[Lock] attribute does not replace the Lock component: it exposes its most useful part in a declarative form, directly on the controller. For catalog imports, payments, and limited-stock sales, adopting it costs almost nothing once the component is installed. Teams that already combine #[IsGranted] and #[RateLimit] will need to check the execution order of the three attributes before stacking them on the same method.

On a recent client project, I had to hand-write a good chunk of this plumbing to protect an order form against double clicks — about twenty lines to acquire, check, and release a lock properly. Seeing that logic condensed into a single attribute is the kind of simplification that actually matters day to day, far more than a big feature nobody ends up using. What remains to be checked in production is whether the evaluation order against #[IsGranted] creates unpleasant surprises on routes that are already protected. — Simon Janvier

Further reading: the detailed official post on symfony.com/blog.

Read next