planeta jupiter planeta tierra

Caché de cliente en Symfony: que el navegador no vuelva a preguntar

· 6 min de lectura · Symfony

Ilustración de estilo Star Trek con la insignia de la Flota Estelar y el logotipo de Symfony
nave extraterrestre
Ilustración de estilo Star Trek con la insignia de la Flota Estelar y el logotipo de Symfony

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 304 sin 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

  • public con 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 un public y 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.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.