Las cabeceras de caché que su framework no va a poner por usted
Laravel pone Cache-Control: no-cache, private en el HTML que devuelve. Es correcto y no debería cambiarlo: esas respuestas llevan una cookie de sesión, y una caché compartida que guarde una es alguien viendo la página de otra persona.
Lo que Laravel no hace es decir nada sobre su JavaScript, su CSS, sus imágenes o sus fuentes. nginx los sirve sin que PHP se ejecute en absoluto, así que cualquier cabecera que ponga en un middleware es irrelevante para ellos. Si nadie lo ha configurado, el navegador no recibe ninguna instrucción y recurre a adivinar, lo que normalmente significa preguntar al servidor por cada archivo en cada carga de página.
Conservar la cabecera de Laravel en el HTML y añadir una a los assets
La forma limpia de hacer las dos cosas es un map, porque nginx omite add_header por completo cuando la variable se evalúa a una cadena vacía:
map $uri $emyrix_cache_control {
~^/build/assets/ "public, max-age=31536000, immutable";
~^/images/ "public, max-age=2592000";
# Everything else — notably the HTML — gets no header from us.
default "";
}
add_header Cache-Control $emyrix_cache_control always;
El HTML cae en default, recibe una cadena vacía y nginx no envía nada, así que lo que puso Laravel sobrevive intacto. Sin bloques location, sin if, y nada que duplicar en un vhost que certbot reescribe.
Immutable es una promesa sobre URLs, no sobre archivos
immutable le dice al navegador que no revalide durante toda la duración. No «pregunta y recibe un 304»: no preguntes. Esa es toda la ganancia de rendimiento, y solo es segura cuando cambiar el contenido cambia siempre la URL.
La salida de build de Vite cumple, porque los nombres de archivo llevan un hash del contenido (shared-BPnlJ6Bu.js). Edite el código fuente y el nombre cambia, así que la promesa de un año es sobre un archivo que nunca puede ser distinto.
Nuestro directorio /images/ no cumple. Esos nombres son estables —ecommerce-workspace-hero-960.avif—, con el ancho y el formato como sufijos. Treinta días, sin immutable, e invalidar uno significa cambiarle el nombre. Hacerlo al revés es la versión del error que no puede deshacer: un navegador que tiene una respuesta immutable ignorará lo que despliegue durante todo el año.
La misma lógica cubre los iconos y el manifest, y caen en sitios distintos. Los iconos reciben treinta días, porque un rediseño que llega despacio al navegador es un intercambio que los favicons siempre han hecho. site.webmanifest recibe un día, porque es un documento de punteros: una copia obsoleta puede nombrar un icono que ya no existe, y es el archivo que edita cuando añade uno.
El megabyte que nadie estaba mirando
El hallazgo que hizo que esto mereciera escribirse. Filament publica los assets del panel de admin en public/css/filament, public/js/filament y public/fonts/filament en cada deploy, y los referencia con una query string construida a partir de la versión del paquete instalado: ?v=5.7.6.0.
Sin una regla, el panel revalidaba los once en cada página que servía. Alrededor de un megabyte de CSS, JavaScript y un woff2 que no cambia entre deploys: once peticiones condicionales, cada una un viaje de ida y vuelta, antes de nada de lo que el admin venía a hacer.
Es seguro fijarlos, y la query de versión es la razón:
~^/(css|js|fonts)/filament/ "public, max-age=31536000, immutable";
Los nombres de archivo son estables, pero esa query forma parte de la clave de caché, así que actualizar Filament cambia todas las URLs a la vez. Es la misma garantía que da un hash del contenido, llegando por otro camino.
Una cosa que probamos primero y que nos saltaríamos si lo hiciéramos otra vez: reducir el CSS con un tema personalizado de Filament. El tema generado resultó mayor que el predeterminado —606 KB frente a 599 KB—, porque Filament v5 distribuye CSS de componentes compilado en lugar de utilidades de Tailwind escaneables, así que no hay nada que la build pueda eliminar por tree-shaking. Añadió una entrada de Vite que mantener y empeoró la cifra. El tamaño es el coste fijo del framework. La caché es la palanca que existe de verdad.
La trampa que hace desaparecer todo esto
nginx hereda add_header de un nivel exterior solo cuando el nivel interior no declara ningún add_header propio. Un add_header dentro de su bloque server, o dentro de cualquier bloque location dentro de él, y todas las reglas de arriba dejan de aplicarse a ese ámbito. Sin error, sin aviso de nginx -t, nada en el log. Las cabeceras sencillamente dejan de llegar.
Esto nos pilló en un sitio fácil de pasar por alto. El vhost tiene bloques location ^~ /build/assets/ y ^~ /images/ que existen por access_log off y try_files $uri =404, ninguno de los cuales es válido en el contexto http, así que no pueden salir de ahí. Esos bloques cubren exactamente las rutas que el map cachea. Ponga un solo add_header en cualquiera de ellos y la caché se detiene en silencio.
Nos topamos con el mismo comportamiento con las cabeceras de seguridad, que viven en un archivo hermano y llevan la misma advertencia en un comentario al principio.
Dos cosas más que saber antes de copiar esto. La configuración en el contexto http se aplica a todos los bloques server del host, así que un segundo sitio en el mismo droplet hereda estas reglas para su propia ruta /images; bien hasta que deja de estarlo. Y always importa: sin él, la cabecera se omite en las respuestas de error.
Comprobar que funcionó
curl -I contra un asset compilado y contra una página HTML, y lea lo que vuelve:
curl -sI https://example.com/build/assets/app-BPnlJ6Bu.js | grep -i cache-control
curl -sI https://example.com/ | grep -i cache-control
El asset debería decir public, max-age=31536000, immutable. La página debería seguir diciendo no-cache, private desde Laravel. Si la página ha cogido la regla del asset, su map está coincidiendo demasiado ampliamente. Si el asset no tiene cabecera, algo más abajo está declarando su propio add_header.
Después cargue el admin dos veces con la pestaña de red abierta y cuente las peticiones de la segunda carga. Ese número es de lo que va esto.
La caché es el trabajo de rendimiento menos interesante que existe, y en nuestro sitio superó a todo lo que hicimos en el código de la aplicación. Reducir bytes es difícil. No volver a pedirlos es un archivo de configuración.
Si su aplicación Laravel es lenta de formas que no parecen tener que ver con su código, cuéntenos qué está ejecutando. Hacemos trabajo de rendimiento en aplicaciones web con regularidad, y normalmente empieza por lo que se le está diciendo al navegador.
Preguntas frecuentes
¿Cache-Control debería ponerse en Laravel o en nginx?
En nginx, para los archivos estáticos. Los sirve sin que PHP llegue a ejecutarse, así que una cabecera puesta en un middleware nunca se aplicaría. Laravel ya pone la cabecera correcta en las respuestas HTML que genera, y lo útil es que nginx se calle en esas y hable solo de los assets.
¿Cuándo es seguro usar immutable?
Cuando un cambio en el contenido produce siempre una URL distinta. Los nombres de archivo con hash de Vite cumplen. También un nombre de archivo estable con una query string de versión, siempre que esa query cambie en cada release. Un archivo cuya URL nunca cambia no debe ser immutable, porque no tendría forma de actualizarlo.
¿Por qué no cachear todo un año sin más?
Porque no puede echarse atrás. Un navegador que tiene una respuesta immutable no volverá a preguntar por ella durante toda la duración, despliegue lo que despliegue. Cualquier cosa que pueda necesitar cambiar bajo su nombre actual necesita una duración que pueda permitirse esperar.