Symfony 8.2 ajoute un attribut PHP, #[Lock], qui pose un verrou applicatif directement sur une méthode de contrôleur. L’annonce, publiée le 16 octobre sur le blog officiel du framework par Javier Eguiluz, met fin à une corvée connue des équipes back-end : jusqu’ici, protéger un contrôleur contre l’exécution concurrente supposait d’injecter une lock factory, de construire une clé, d’acquérir le verrou, de gérer le cas où il est déjà pris, puis de le libérer. Un seul attribut remplace désormais cette mécanique.
Le problème que l’attribut résout
Certaines actions ne doivent jamais tourner en parallèle : import d’un catalogue produits, encaissement d’une commande, vente des dernières places d’une conférence, ou simple double clic d’un utilisateur sur un bouton de validation. Sans verrou, deux requêtes concurrentes peuvent dupliquer un paiement ou corrompre un import en cours. Le composant Lock existait déjà pour cela, mais son API bas niveau freinait son adoption dans les contrôleurs les plus exposés.
La syntaxe de #[Lock]
L’attribut se pose sur une méthode de contrôleur et accepte plusieurs paramètres pour ajuster son comportement selon le cas d’usage.
| Paramètre | Rôle | Valeur par défaut |
|---|---|---|
key | Clé du verrou (chaîne, placeholders {nom}, ou expression ExpressionLanguage) | obligatoire |
methods | Méthode(s) HTTP concernées par le verrou | toutes |
blocking | Attendre la libération au lieu de rejeter la requête | false |
read | Verrou partagé via SharedLockInterface | false |
ttl | Durée de vie du verrou, en secondes | 30 |
factory | Nom d’une lock factory spécifique | factory par défaut |
Par défaut, une requête qui se heurte à un verrou déjà posé reçoit une réponse 409 Conflict. Un argument LockInterface ou SharedLockInterface, déclaré en nullable dans la méthode, permet de récupérer le verrou acquis par l’attribut pour le manipuler, par exemple le libérer plus tôt que la fin de la requête.
Un exemple concret
Le billet officiel illustre plusieurs cas de figure : un import exclusif, un téléchargement qui tolère la lecture concurrente, une vente de billets qui patiente plutôt que de rejeter, et un verrou par utilisateur construit via une 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 */ }
}La dernière méthode du billet officiel combine une expression ExpressionLanguage et une factory nommée pour verrouiller par utilisateur connecté plutôt que par ressource globale, ce qui évite qu’un rapport lancé par un compte ne bloque celui d’un autre.
Le verrou est acquis avant la résolution des arguments de la méthode et avant l’évaluation de #[IsGranted] : un utilisateur non autorisé peut recevoir un 409 avant même d’être refusé par un 403.
Les limites à connaître avant de l’adopter
Trois comportements méritent une lecture attentive avant la mise en production. D’abord, l’ordre d’évaluation des attributs : #[Lock] s’exécute avant #[IsGranted] et avant #[RateLimit], ce qui peut faire attendre ou rejeter une requête qui aurait dû être refusée pour un motif d’autorisation. Ensuite, le mode blocking: true n’a pas de délai d’attente propre : une requête peut rester bloquée tant que le verrou est détenu ailleurs. Enfin, une sous-requête interne peut réutiliser le verrou de la requête principale, ce qui justifie de déclarer systématiquement l’argument LockInterface en nullable.
À retenir sur le TTL. Sa valeur par défaut de 30 secondes n’est pas renouvelée automatiquement. Pour un traitement plus long, il faut rafraîchir le verrou soi-même via l’instance LockInterface injectée, sous peine de voir un autre processus l’acquérir avant la fin du premier traitement.
Cette simplification s’inscrit dans une tendance plus large des frameworks back-end : réduire le code de plomberie autour de primitives déjà existantes plutôt que d’en ajouter de nouvelles. Node.js a suivi une logique comparable en intégrant l’API Temporal par défaut dans sa version 26, là où Symfony choisit de masquer la complexité d’un composant déjà mature derrière un attribut déclaratif.
Ce qu’il faut retenir
L’attribut #[Lock] ne remplace pas le composant Lock : il en expose la partie la plus utile sous une forme déclarative, directement sur le contrôleur. Pour les imports, paiements et ventes à stock limité, l’adoption est quasi sans coût une fois le composant installé. Les équipes qui combinent déjà #[IsGranted] et #[RateLimit] devront en revanche vérifier l’ordre d’exécution des trois attributs avant de les empiler sur une même méthode.
Sur un projet client récent, j’avais dû écrire à la main une bonne partie de cette mécanique pour sécuriser un formulaire de commande contre les doubles clics — une vingtaine de lignes pour acquérir, vérifier et libérer un verrou proprement. Voir cette logique condensée dans un attribut est le genre de simplification qui compte vraiment au quotidien, bien plus qu’une grosse fonctionnalité qu’on n’utilisera jamais. Reste à vérifier, en production, que l’ordre d’évaluation avec #[IsGranted] ne crée pas de mauvaises surprises sur les routes déjà protégées. — Simon Janvier
Pour aller plus loin : le billet officiel détaillé sur symfony.com/blog.
