Symfony 8.2 añade un atributo PHP, #[Lock], que coloca un bloqueo de aplicación directamente sobre un método de controlador. El anuncio, publicado el 16 de octubre en el blog oficial del framework por Javier Eguiluz, pone fin a una tarea conocida por los equipos de backend: hasta ahora, proteger un controlador contra la ejecución concurrente exigía inyectar una lock factory, construir una clave, adquirir el bloqueo, gestionar el caso en que ya estuviera tomado y luego liberarlo. Un único atributo sustituye ahora toda esa mecánica.
El problema que resuelve el atributo
Algunas acciones no deben ejecutarse nunca en paralelo: la importación de un catálogo de productos, el cobro de un pedido, la venta de las últimas plazas de una conferencia, o simplemente un doble clic de un usuario sobre un botón de confirmación. Sin un bloqueo, dos peticiones concurrentes pueden duplicar un pago o corromper una importación en curso. El componente Lock ya existía para esto, pero su API de bajo nivel frenaba su adopción en los controladores más expuestos.
La sintaxis de #[Lock]
El atributo se coloca sobre un método de controlador y acepta varios parámetros para ajustar su comportamiento según el caso de uso.
| Parámetro | Función | Valor por defecto |
|---|---|---|
key | Clave del bloqueo (cadena, placeholders {nombre}, o una expresión ExpressionLanguage) | obligatorio |
methods | Método(s) HTTP afectados por el bloqueo | todos |
blocking | Esperar a la liberación en lugar de rechazar la petición | false |
read | Bloqueo compartido mediante SharedLockInterface | false |
ttl | Duración de vida del bloqueo, en segundos | 30 |
factory | Nombre de una lock factory específica | factory por defecto |
Por defecto, una petición que choca con un bloqueo ya tomado recibe una respuesta 409 Conflict. Un argumento LockInterface o SharedLockInterface, declarado como nullable en el método, permite recuperar el bloqueo adquirido por el atributo para manipularlo, por ejemplo liberándolo antes del final de la petición.
Un ejemplo concreto
La entrada oficial ilustra varios casos: una importación exclusiva, una descarga que tolera lecturas concurrentes, una venta de entradas que espera en lugar de rechazar, y un bloqueo por usuario construido mediante una expresión.
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 */ }
}El último método de la entrada oficial combina una expresión ExpressionLanguage y una factory con nombre para bloquear por usuario autenticado en lugar de por recurso global, lo que evita que un informe lanzado por una cuenta bloquee el de otra.
El bloqueo se adquiere antes de resolver los argumentos del método y antes de evaluar #[IsGranted]: un usuario no autorizado puede recibir un 409 antes incluso de ser rechazado con un 403.
Las limitaciones que hay que conocer antes de adoptarlo
Tres comportamientos merecen una lectura atenta antes de pasar a producción. Primero, el orden de evaluación de los atributos: #[Lock] se ejecuta antes de #[IsGranted] y antes de #[RateLimit], lo que puede hacer esperar o rechazar una petición que debería haberse denegado por un motivo de autorización. Segundo, el modo blocking: true no tiene un tiempo de espera propio: una petición puede quedar bloqueada mientras el bloqueo esté retenido en otro lugar. Por último, una subpetición interna puede reutilizar el bloqueo de la petición principal, lo que justifica declarar siempre el argumento LockInterface como nullable.
Sobre el TTL. Su valor por defecto de 30 segundos no se renueva automáticamente. Para un tratamiento más largo, hay que refrescar el bloqueo manualmente mediante la instancia LockInterface inyectada, so pena de que otro proceso lo adquiera antes de que termine el primer tratamiento.
Esta simplificación se inscribe en una tendencia más amplia de los frameworks de backend: reducir el código de fontanería alrededor de primitivas ya existentes en lugar de añadir otras nuevas. Node.js siguió una lógica comparable al integrar la API Temporal por defecto en su versión 26, mientras que Symfony opta por ocultar la complejidad de un componente ya maduro detrás de un atributo declarativo.
Lo que hay que recordar
El atributo #[Lock] no sustituye al componente Lock: expone su parte más útil en una forma declarativa, directamente sobre el controlador. Para las importaciones de catálogo, los pagos y las ventas con stock limitado, su adopción es casi gratuita una vez instalado el componente. Los equipos que ya combinan #[IsGranted] y #[RateLimit] deberán, en cambio, verificar el orden de ejecución de los tres atributos antes de apilarlos en un mismo método.
En un proyecto de cliente reciente, tuve que escribir a mano buena parte de esta mecánica para proteger un formulario de pedido contra los dobles clics: una veintena de líneas para adquirir, verificar y liberar un bloqueo correctamente. Ver esa lógica condensada en un atributo es el tipo de simplificación que de verdad importa en el día a día, mucho más que una gran funcionalidad que nunca se llega a usar. Queda por comprobar, en producción, que el orden de evaluación con #[IsGranted] no provoque sorpresas desagradables en las rutas ya protegidas. — Simon Janvier
Para profundizar: la entrada oficial detallada en symfony.com/blog.
