You change a style, upload the file, refresh the page — no change. You hard refresh, still nothing. You open an incognito window and it is correct there.
If you have experienced this, you probably ran into a specific cache header that many hosts send by default and whose behaviour is fundamentally different from ordinary caching.
What the immutable header does#
Ordinary browser caching works like this: it keeps the file, and when it needs it again it sends a conditional request, which means "I have this version, has it changed?". If the server says no, a 304 response comes back and the file is not downloaded again. The important thing is that a request was made and the browser had a chance to learn that the file changed.
Now look at this header:
Cache-Control: public, max-age=31536000, immutableThe last word means: "for the whole of this period, this file will definitely not change." A browser that sees this makes no request at all — not conditional, not normal. It reads the file straight from disk.
The result: even a hard refresh does not necessarily bypass it. Hard refresh behaviour is not the same across browsers and in some cases it only refreshes the main document, not every resource. And if there is an intermediate cache in the middle, it holds the same version too.
First, confirm this is it#
See the real headers:
curl -sI https://example.com/assets/css/main.css | grep -i cache-controlIf immutable is in the output, the problem is found.
In the browser developer tools, on the Network tab, the Size column for this file shows something like "disk cache" and no request goes to the server at all. If you tick "Disable cache", the fresh file arrives, which confirms it.
The wrong fix and the right fix#
Wrong fix: remove the header#
It is tempting to switch off the header in .htaccess. It works, but you are sacrificing a good optimisation because of a solvable problem. Long caching of static assets is one of the most effective things you can do for speed, especially for returning visitors.
Right fix: change the URL#
If the browser is going to cache the file forever, then the new file must have a new URL. It comes in two forms:
/assets/css/main.css?v=1712345678
/assets/css/main.a1b2c3.cssThe first is simpler. The second is more reliable for content delivery networks, because some of them do not include the query parameter in the cache key.
Implementation: version from the file modification time#
The version value must be automatic. Anything manual eventually gets forgotten.
The simplest automatic source is the file's own last modification time:
class Asset
{
public static function url(string $path): string
{
$file = public_path($path);
if (! is_file($file)) {
return asset($path);
}
return asset($path) . '?v=' . filemtime($file);
}
}And in the template:
<link rel="stylesheet" href="{{ \App\Support\Asset::url('assets/css/main.css') }}">Now every time the file changes, the v value changes too and the browser sees it as a completely new resource. With no manual work.
Our experience: after building this class, we had to replace every use of the default
asset()function. One or two were missed, and those were exactly the ones that later "would not update". If you build such a helper, move all consumers over at the same time.
A note about the path#
If the project is outside the web root — which is the usual structure on cPanel — the public_path() function does not necessarily point to where the file is actually served from. Check the real path:
$file = base_path('public/' . ltrim($path, '/'));What to cache long and what not to#
A simple rule: anything whose URL is versioned can get long caching. Anything with a fixed URL must not.
| Resource | Suggested policy |
|---|---|
| CSS and JS with a version in the URL | One year, immutable |
| Fonts | One year, immutable |
| Uploaded images with a unique name | One year |
| HTML pages | No cache or a very short cache |
| robots and sitemap files | A few hours |
The common mistake is putting long caching on the HTML page itself. Then the user sees old content and no versioning can save them, because the page URL does not change.
When it is still not fixed#
If you added versioning and still see the old version, check in this order:
1. Does the output HTML really have the version? Look at the page source, not the template code:
curl -s https://example.com | grep -o 'assets/css/[^"]*'If the version parameter is not in the output, Laravel's template cache still has the old version:
php84 artisan view:clear
php84 artisan optimize:clear2. Did the file on the server really change?
ls -la --time-style=full-iso ~/app/public/assets/css/main.cssIt has happened several times that the file had not been uploaded at all.
3. Do you have an intermediate cache? If the site is behind a content delivery network, you must purge its cache too. The response headers usually have an indicator that says whether the response came from the cache or the origin server.
4. Is the PHP code cache active? On shared hosting the server's opcode cache sometimes keeps an old version of a PHP file. It usually refreshes itself after a few seconds, but if the timestamp check is off it can stay until a restart. If changes to PHP files are not being applied either, keep this in mind.
Setting headers in .htaccess#
If your host does not send these headers and you want to add them yourself, in the .htaccess of the web root:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2|png|jpe?g|webp|avif|svg)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>Do not remove the IfModule condition. If the module is not on the server and you took the condition out, the whole site gets a 500 error — and on shared hosting where you do not have easy access to the web server log, finding the cause takes time.
And remember to add this header only when the URLs are versioned. The order matters: versioning first, long caching second. The reverse means months of old content for old visitors.
Check compression too#
While you are here, one more thing is worth checking. See whether responses are compressed:
curl -sI -H 'Accept-Encoding: gzip, br' https://example.com/assets/css/main.css | grep -i content-encodingIf it is empty, text files are transferred uncompressed. For a CSS file of a few tens of kilobytes, compression usually reduces the transfer size several times over, and on slow connections the difference is noticeable.
Asset budget: a number you should set#
One decision that is better made early: set a size ceiling for each asset and stick to it. What we have:
| Asset | Ceiling |
|---|---|
| Store CSS | 60 KB |
| Store JavaScript | 8 KB |
| Web font | 90 KB |
| External library | Zero |
Without such a ceiling, the CSS file grows gradually and there is never a moment when someone says "this got too big". With a ceiling, the moment arrives by itself.
When you approach the ceiling, the right move is splitting, not more compression: styles needed only on blog pages can go into a separate file loaded only there. Do the same for JavaScript — there is no reason for the admin panel script to be downloaded by a store visitor.
A note about fonts#
A web font for a non-Latin script such as Arabic or Persian is usually the biggest static asset on the page and should definitely be self-hosted. Third-party font CDNs can be slow or unreachable for some visitors, and the result is several seconds of a page with no text.
Self-host it, give it a one-year cache and put font-display: swap in the font rules so that text shows in a fallback font until the main font arrives.
Do not confuse Laravel cache with browser cache#
A common confusion: when someone says "clear the cache", which cache do they mean? In a Laravel project on shared hosting there are at least five separate layers, and each has its own command.
| Layer | What it holds | How to clear it |
|---|---|---|
| Config cache | Values from the env file and config | config:clear |
| Route cache | The list of routes | route:clear |
| View cache | Compiled Blade files | view:clear |
| Application cache | Whatever you stored yourself | cache:clear |
| Browser and intermediate cache | Static files | URL versioning |
The optimize:clear command clears the first four together and is what you usually put in a deploy script. But it has no effect on the fifth layer — and that is exactly the layer this post was about.
If nothing changed after a deploy, first identify which layer it is:
- The output HTML changed but the style did not? The fifth layer. Versioning.
- The output HTML did not change either? The third layer. View cache.
- A configuration value is old? The first layer. Config cache.
This simple diagnosis saves half the wasted time.
Deploying without an inconsistent moment#
A subtle point that matters on a high-traffic site: between the moment the new CSS file is uploaded and the moment the new HTML is served, there is a window in which a user may get old HTML with new CSS or the reverse.
With versioning, this problem is largely solved by itself: the old HTML points to the old versioned URL and that file is still there. As long as you do not delete the old file — which is automatic with the query parameter approach, but with the file renaming approach you need to keep the last few versions.
Summary#
- The
immutableheader means the browser does not even check; a hard refresh is not a reliable fix. - Instead of switching off caching, version the URL.
- Take the version from the file modification time so it is automatic.
- Long caching only for versioned URLs; never for HTML.
- If it is still old, in order: view cache, the file on the server, intermediate cache, code cache.