The Cache Headers Your Framework Won't Set for You
Laravel sets Cache-Control: no-cache, private on the HTML it returns. That's correct and you shouldn't change it — those responses carry a session cookie, and a shared cache holding one is somebody seeing another person's page.
What Laravel doesn't do is say anything about your JavaScript, your CSS, your images, or your fonts. nginx serves those without PHP running at all, so any header you set in middleware is irrelevant to them. If nobody has configured this, the browser gets no instruction and falls back to guessing, which usually means asking the server about every file on every page load.
Keep Laravel's header on HTML while adding one to assets
The neat way to do both is a map, because nginx omits add_header entirely when the variable evaluates to an empty string:
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;
HTML matches default, gets an empty string, and nginx sends nothing — so what Laravel set survives untouched. No location blocks, no if, and nothing to duplicate across a vhost that certbot rewrites.
Immutable is a promise about URLs, not about files
immutable tells the browser not to revalidate for the whole duration. Not "ask and get a 304" — don't ask. That's the entire performance win, and it's only safe when changing the content always changes the URL.
Vite's build output qualifies, because the filenames carry a content hash (shared-BPnlJ6Bu.js). Edit the source and the name changes, so the year-long promise is about a file that can never be different.
Our /images/ directory does not qualify. Those names are stable — ecommerce-workspace-hero-960.avif — with the width and format as suffixes. Thirty days, no immutable, and busting one means changing its name. Getting this backwards is the version of the mistake you can't undo: a browser holding an immutable response will ignore whatever you deploy for the full year.
The same logic covers the icons and the manifest, and they land in different places. Icons get thirty days, because a redesign reaching a browser slowly is a trade favicons have always made. site.webmanifest gets one day, because it's a pointer document — a stale copy can name an icon that no longer exists, and it's the file you edit when you add one.
The megabyte nobody was looking at
The find that made this worth writing up. Filament publishes the admin panel's assets to public/css/filament, public/js/filament and public/fonts/filament on every deploy, and references them with a query string built from the installed package version: ?v=5.7.6.0.
Without a rule, the panel revalidated all eleven of those on every page it served. Roughly a megabyte of CSS, JavaScript and a woff2 that doesn't change between deploys — eleven conditional requests, each one a round trip, before anything the admin actually came for.
They're safe to pin, and the version query is why:
~^/(css|js|fonts)/filament/ "public, max-age=31536000, immutable";
The filenames are stable, but that query is part of the cache key, so upgrading Filament changes every URL at once. It's the same guarantee a content hash gives, arriving by a different route.
One thing we tried first and would skip if we did it again: shrinking the CSS with a custom Filament theme. The generated theme measured larger than the default — 606 KB against 599 KB — because Filament v5 ships compiled component CSS instead of scannable Tailwind utilities, so there's nothing for the build to tree-shake. It added a Vite entry to maintain and made the number worse. The size is the framework's fixed cost. Caching is the lever that actually exists.
The trap that makes all of this vanish
nginx inherits add_header from an outer level only when the inner level declares no add_header of its own. One add_header inside your server block, or inside any location block in it, and every rule above stops applying to that scope. No error, no warning from nginx -t, nothing in the log. The headers simply stop arriving.
This bit us in a place that's easy to miss. The vhost has location ^~ /build/assets/ and ^~ /images/ blocks that exist for access_log off and try_files $uri =404, neither of which is valid in the http context, so they can't move out. Those blocks cover exactly the paths the map caches. Put a single add_header in any of them and the caching silently stops.
We hit the same behavior with the security headers, which live in a sibling file and carry the same warning in a comment at the top.
Two other things to know before you copy this. Configuration in the http context applies to every server block on the host, so a second site on the same droplet inherits these rules for its own /images path — fine until it isn't. And always matters: without it the header is skipped on error responses.
Checking it worked
curl -I against a built asset and an HTML page, and read what comes back:
curl -sI https://example.com/build/assets/app-BPnlJ6Bu.js | grep -i cache-control
curl -sI https://example.com/ | grep -i cache-control
The asset should say public, max-age=31536000, immutable. The page should still say no-cache, private from Laravel. If the page has picked up the asset rule, your map is matching too broadly. If the asset has no header, something below is declaring its own add_header.
Then load the admin twice with the network tab open and count the requests on the second load. That number is the one this is about.
Caching is the least interesting performance work there is, and on our site it beat everything we did to the application code. Reducing bytes is hard. Not asking for them again is a config file.
If your Laravel application is slow in ways that don't seem to be about your code, tell us what you're running. We do performance work on web applications regularly, and it usually starts with what the browser is being told.
Frequently asked questions
Should Cache-Control be set in Laravel or in nginx?
nginx, for static files. It serves them without PHP ever running, so a header set in middleware would never apply. Laravel already sets the right header on the HTML responses it generates, and the useful arrangement is for nginx to stay silent on those and speak only about assets.
When is immutable safe to use?
When a change to the content always produces a different URL. Vite's hashed filenames qualify. So does a stable filename with a version query string, as long as that query changes on every release. A file whose URL never changes must not be immutable, because you would have no way to update it.
Why not just cache everything for a year?
Because you cannot take it back. A browser holding an immutable response will not ask about it again for the full duration, whatever you deploy. Anything you might need to change under its existing name needs a duration you can live with waiting out.