Abre cualquier codebase madura de Symfony y haz grep de onKernelResponse. Te apuesto un café a que encuentras al menos un listener que se dispara en absolutamente todas las peticiones, escarba en $request->attributes buscando alguna clave con guion bajo a medio documentar, y sale con un early return el noventa y ocho por ciento de las veces. Ese patrón nunca fue la idea de buen diseño de nadie. Era la única forma de que un atributo de controlador personalizado hiciera algo útil a lo largo del ciclo de vida de la petición, porque el propio framework no te daba una opción mejor. Symfony 8.1 elimina esa excusa, y creo que cambia en silencio cómo deberíamos estructurar el código transversal en nuestras aplicaciones.
El fallo de fondo era casi vergonzosamente pequeño. Los atributos se resolvían durante kernel.controller y luego quedaban aparcados en el propio objeto ControllerEvent. ControllerArgumentsEvent todavía podía verlos a través de una referencia hacia atrás, pero para cuando llegaba ResponseEvent, ya no estaban. El propio CacheAttributeListener del framework tenía que pasar de contrabando los datos de #[Cache] mediante una clave privada _cache en el request solo para poder fijar cabeceras más tarde. Cuando el núcleo necesita un workaround para su propia API, todo el mundo aguas abajo acaba copiando ese workaround con sus propios nombres de clave, y eso es exactamente lo que pasó en el mundo real.
El arreglo de 8.1 es aburrido en el mejor sentido: los atributos ahora viven en el Request bajo _controller_attributes, y como cada evento del kernel ya lleva la misma instancia de Request, son legibles en cualquier punto del ciclo de vida, ruta de excepción incluida. Hay incluso limpieza para el caso límite en el que setController() cambia el controlador en pleno vuelo, de modo que nunca leas atributos de un controlador que ya no es el que se ejecuta. Nada de trucos ingeniosos, solo estado movido al objeto que de verdad sobrevive a toda la petición. La mayoría de los bugs de framework que he perseguido en mi carrera se reducían a estado viviendo en el objeto equivocado, así que agradezco una nota de versión que es en esencia una admisión de eso.
El segundo cambio es el que va a remodelar el código del día a día. Un nuevo ControllerAttributesListener despacha un subevento dedicado por cada instancia de atributo, nombrado componiendo el evento del kernel con el FQCN del atributo, de modo que kernel.response más Cache::class se convierte en su propio evento suscribible. Tu listener se registra para ese nombre compuesto y recibe un ControllerAttributeEvent que lleva la instancia tipada del atributo junto con el evento original del kernel. Sin comprobación de presencia, sin pescar en arrays, sin invocación alguna cuando el atributo no está. El contenedor sabe en tiempo de compilación qué eventos de atributo tienen suscriptores, así que no se despacha nada especulativo. Si alguna vez has perfilado una petición y has visto a una docena de listeners despertarse, mirar alrededor y volver a dormirse, sabes por qué esto importa más allá de la estética.
Ahora la concesión honesta, porque la hay. Los nombres de evento compuestos son cadenas pegadas con un punto, y el despacho basado en strings es exactamente el tipo de magia que hace que un compañero nuevo se quede mirando el profiler preguntándose por qué su listener nunca se dispara. Las reglas del ciclo de vida también añaden fricción: antes de que corra el controlador llamas a getAttributes() en el evento del kernel, después pasas por controllerMetadata, y llamar al método equivocado para tu fase es un error fatal en lugar de un null. El esquema de prioridades, -10000 en los eventos de controlador y 10000 en los posteriores, es deliberado y correcto, pero es un contrato invisible más que necesitas saber que existe. Si a tu equipo ya le cuesta seguir el flujo de eventos, esto añade una capa.
Aun así me quedo firmemente en el lado del sí, por una razón: la alternativa era peor y todos la estábamos viviendo. El mundo antiguo tenía la misma magia, solo que casera, sin documentar y distinta en cada proyecto. Una clave privada del request inventada por un desarrollador en 2022 es mucho más hostil para un recién llegado que una convención a nivel de framework que debug:event-dispatcher puede introspeccionar. Y el mecanismo está probado, ya que #[Cache] y #[RateLimit] corren ahora internamente sobre exactamente este pipeline. Cuando el núcleo se come su propia cocina, confío más en la receta.
Así que mi consejo práctico es tratar esto como una invitación a refactorizar, no solo como una línea del changelog. Ese listener que comprueba si una ruta necesita una entrada en el log de auditoría, esa cosa medio middleware que impone aislamiento de tenants, ese decorador de respuestas que añade cabeceras de deprecación a versiones antiguas de la API: todos están pidiendo a gritos convertirse en un atributo más un listener dirigido. Pero también sé adónde puede llevar este camino, porque he visto controladores luciendo seis atributos como un general luce medallas, y en algún punto declarativo deja de significar legible. Así que dime, colega: ¿cuál es el primer listener genérico del kernel que convertirías en tu codebase, y cuántos atributos sobre una misma acción marcan tu umbral personal de dolor antes de devolver la lógica a servicios normales?
Comentarios
Aún no hay comentarios — escribe el primero.
Inicia la conversación
Sin cuenta ni contraseña — introduce tu correo y te enviamos un enlace de acceso de un solo uso. ¿Primera vez? Todo se configura automáticamente.
Tu valoración se aplicará automáticamente al iniciar sesión.
Revisa tu bandeja de entrada
Hemos enviado un enlace de acceso a …. Ábrelo en este dispositivo — esta pestaña te conectará automáticamente.
¿No llega nada? Mira en la carpeta de spam — y marca el correo como «No es spam» para que la próxima vez llegue directo.