Puedes optimizar el controlador hasta responder en 20 ms y la página
seguirá tardando: queda el viaje de ida y vuelta. La petición más rápida es la que no se
hace, y eso no se arregla con código PHP sino con cuatro cabeceras HTTP. Symfony las pone de
dos maneras: con el atributo #[Cache] o a mano sobre la
Response.
Lo que estás enviando ahora mismo
curl -I https://ejemplo.test/precios
HTTP/1.1 200 OK
Cache-Control: no-cache, private
Es lo que manda Symfony si no dices nada: no guardes esto, y si lo guardas, que sea solo para este usuario. O sea, que no estás cacheando nada en el cliente aunque la página lleve tres años siendo idéntica.
Solo hay dos estrategias
Antes del código, el mapa, porque todo lo demás son formas de escribir una de estas dos cosas:
- Expiración: «no me preguntes en una hora». El navegador no hace la petición. Cero red, cero servidor.
- Validación: «pregúntame siempre, pero te contesto corto». El navegador
sí pregunta, y si su copia sigue valiendo recibe un
304sin cuerpo.
1. Expiración con el atributo #[Cache]
Desde Symfony 6.2, en
Symfony\Component\HttpKernel\Attribute\Cache. Es la forma corta:
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;
final class PricingController extends AbstractController
{
#[Route('/precios', name: 'app_pricing')]
#[Cache(public: true, maxage: 3600, mustRevalidate: true)]
public function index(): Response
{
return $this->render('pricing/index.html.twig');
}
}
Genera Cache-Control: max-age=3600, public, must-revalidate. Durante una hora,
ese navegador no vuelve a pedir la página.
| Parámetro | Qué hace |
|---|---|
maxage |
Segundos que vale la respuesta para cualquier caché, navegador incluido. |
smaxage |
Igual, pero solo para cachés compartidas (CDN, proxy). Manda sobre maxage en ellas. |
public |
Autoriza a un CDN o un proxy a guardarla y servírsela a otro usuario. |
expires |
Fecha absoluta ('+1 hour'). Versión antigua de maxage; si pones los dos, gana maxage. |
mustRevalidate |
Al caducar, prohibido servir la copia vieja: hay que preguntar. |
vary |
Cabeceras que cambian la respuesta (Accept-Language…). |
La diferencia entre public y private es la que sale cara:
private guarda la copia solo en el navegador que la pidió,
public deja que un CDN se la dé a cualquiera. Si en la página sale el nombre del
usuario, public es una fuga de datos.
El atributo también vale a nivel de clase y se aplica a todas las acciones:
#[Cache(smaxage: 600)]
final class BlogController extends AbstractController
{
// 10 minutos en el CDN y nada en el navegador, porque no hay maxage
}
2. Expiración a mano, sobre la Response
El atributo es una constante: se escribe una vez y vale igual para todas las peticiones. Cuando el tiempo depende del dato, baja a la respuesta:
#[Route('/producto/{slug}', name: 'app_product_show')]
public function show(Product $product): Response
{
$response = $this->render('product/show.html.twig', ['product' => $product]);
// un producto en oferta cambia de precio; uno normal, no
$ttl = $product->isOnSale() ? 60 : 3600;
$response->setPublic();
$response->setMaxAge($ttl);
$response->setSharedMaxAge($ttl * 6);
$response->setVary(['Accept-Language']);
return $response;
}
Mismos conceptos con otro nombre: setPublic(), setPrivate(),
setMaxAge(), setSharedMaxAge(), setExpires(),
setVary(). Y uno que merece la pena:
$response->setSharedMaxAge(60);
$response->setStaleWhileRevalidate(600);
Pasados los 60 segundos, el CDN sigue sirviendo la copia vieja mientras por detrás pide una nueva. El visitante nunca paga la regeneración.
En resumen: el atributo para lo fijo, la Response para lo que se decide en
tiempo de ejecución. Mezclar los dos en la misma acción solo confunde sobre quién pone qué
cabecera.
3. Validación: ETag y Last-Modified
La expiración tiene un problema: una vez enviada, no puedes retirarla. Si publicas un cambio
a los cinco minutos de un max-age=3600, hay gente que verá la versión vieja
durante 55 minutos y no puedes hacer nada.
La validación es el otro trato: el navegador pregunta siempre, pero adjunta una marca de lo
que ya tiene. Si sigue valiendo, contestas 304 Not Modified sin cuerpo.
Con Last-Modified
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[Route('/post/{slug}', name: 'app_post_show')]
public function show(Post $post, Request $request): Response
{
$response = new Response();
$response->setLastModified($post->getUpdatedAt());
$response->setPublic();
// ¿el navegador ya tiene esta versión?
if ($response->isNotModified($request)) {
return $response; // 304, sin cuerpo
}
// solo si hace falta, renderizamos
return $this->render('post/show.html.twig', ['post' => $post], $response);
}
isNotModified() compara con el If-Modified-Since que envía el
navegador y, si coincide, deja la respuesta en 304 y le vacía el cuerpo.
Lo importante, y es lo que casi todo el mundo hace mal: lo que va antes del
isNotModified() tiene que ser barato. Si renderizas la plantilla
entera y luego devuelves un 304, has ahorrado ancho de banda y nada más.
Con ETag
Un ETag es una huella de la versión. Vale cualquier cosa que cambie cuando cambia la respuesta:
$etag = hash('xxh128', sprintf(
'%d-%d-%s',
$post->getId(),
$post->getUpdatedAt()->getTimestamp(),
$request->getLocale(),
));
$response = new Response();
$response->setEtag($etag);
$response->setPublic();
if ($response->isNotModified($request)) {
return $response;
}
El navegador lo devuelve en If-None-Match. Symfony pone las comillas del formato
HTTP por ti; con setEtag($etag, true) lo marcas como débil
(W/"…"), que es lo que quieres si dos respuestas pueden diferir en detalles
irrelevantes.
Con el atributo
#[Route('/post/{slug}', name: 'app_post_show')]
#[Cache(
public: true,
lastModified: 'post.getUpdatedAt()',
etag: '"post_" ~ post.getId() ~ "_" ~ post.getUpdatedAt().getTimestamp()'
)]
public function show(Post $post): Response
{
return $this->render('post/show.html.twig', ['post' => $post]);
}
Son expresiones de ExpressionLanguage, así que hace falta el componente
(composer require symfony/expression-language). Las variables disponibles son
los argumentos del controlador, aquí post. Si coincide, Symfony devuelve el 304
antes de ejecutar tu método.
Con un matiz: la consulta que carga la entidad sí se ejecuta. Ahorras el render y la transferencia, no el acceso a base de datos. Si eso es lo caro, hazlo a mano con una consulta que traiga solo la fecha.
Expiración o validación
Expiración (max-age) |
Validación (ETag / Last-Modified) | |
|---|---|---|
| Peticiones a tu servidor | Ninguna hasta que caduque | Una por visita, siempre |
| Qué ahorras | Latencia, ancho de banda y CPU | Ancho de banda, y CPU solo si el validador es barato |
| Frescura | Puedes servir contenido viejo | Siempre al día |
| Se puede cancelar | No: lo enviado, enviado está | Sí: el siguiente 200 corrige |
| Para qué | Assets versionados, páginas estables | APIs, contenido que cambia sin avisar |
Entre los dos validadores: Last-Modified es cómodo si ya tienes un
updatedAt, pero tiene precisión de un segundo, así que dos cambios en el mismo
segundo se le escapan. El ETag no depende del reloj y puede incluir cosas que
una fecha no captura: el idioma, el rol, la versión de la plantilla. Si envías los dos, el
navegador manda ambos y Symfony da prioridad al ETag.
Y no son excluyentes: lo normal es max-age corto para que no pregunte durante un
minuto, más validación para que, cuando pregunte, casi siempre reciba un 304.
Traducido a casos: assets con hash en el nombre, un año de
max-age; página pública que cambia poco, public con
smaxage para el CDN; API que se consulta en bucle, ETag; y cualquier cosa con
sesión, private y poco más.
Lo que se rompe si no lo sabes
publiccon datos personales es una fuga. Un proxy guarda el panel de un usuario y se lo sirve al siguiente. El error más caro de la lista y el más fácil de cometer.- Sin
Vary, sirves el idioma equivocado. Si la respuesta depende de una cabecera, decláralo. - Si tocas la sesión, Symfony marca la respuesta como
private. Si esperabas unpublicy no aparece, mira si estás leyendo el usuario o un mensaje flash en la plantilla. - Un 304 que llega después de renderizarlo todo no ahorra CPU. Calcula el validador con lo mínimo y sal antes del trabajo pesado.
- Recargar no es la prueba. Un F5 revalida y un Ctrl+F5 ignora la caché: los dos te mienten. Navega con enlaces y mira la columna de tamaño en la pestaña de red.
Entonces, ¿y la caché de servidor?
Son cosas distintas y no compiten. La de servidor guarda el resultado de un cálculo caro para no repetirlo, pero la petición HTTP ocurre igual y el usuario espera el viaje entero. La de cliente evita la petición, o hace que vuelva vacía.
| Caché de servidor | Caché de cliente | |
|---|---|---|
| Dónde vive | Tu máquina (var/cache/, Redis) |
El navegador, el CDN, el proxy |
| Qué guarda | Datos y fragmentos, lo que tú decidas | La respuesta HTTP entera |
| Qué ahorra | Consultas y cálculo | La petición completa, red incluida |
| Quién la controla | Tú, del todo | El cliente; tú solo sugieres |
| Invalidar | Inmediato: delete(), etiquetas, cache:pool:clear |
Imposible hasta que caduque |
| Sirve para contenido personalizado | Sí, el trozo caro se comparte | Poco: como mucho private |
Así que la regla sale sola: servidor cuando lo que duele es generar la respuesta —una consulta lenta, una API externa—, cliente cuando lo que duele es pedirla. Y las dos a la vez cuando pasan las dos cosas: guardas el cálculo en servidor y mandas además un ETag barato para que quien ya lo tenga se lleve un 304. Cómo se monta esa otra mitad está en Crea fácilmente caché de servidor con Symfony.
Y ya está
Decide si esa respuesta puede envejecer. Si puede,
#[Cache(public: true, maxage: …)] y te quitas la petición entera. Si no puede,
un ETag calculado barato y un 304. Lo demás son matices.