You change a stylesheet, deploy, reload, and nothing happens. You SSH in and the file on the server is definitely the new one. You deploy again. Still nothing. Then you open the site on your phone and see the new design straight away.
Nothing is broken. Your browser and your host's CDN are both holding a copy of the old file, and neither has any reason to ask for a new one.
Confirm it before you change anything
Check what the server actually sends:
curl -sSI https://yoursite.com/css/app.css | grep -i "cache-control\|age"
A response like this is the whole story:
cache-control: public, max-age=604800
x-cache-status: HIT
604800 seconds is seven days. Until that expires, the CDN answers from its own copy and your server is never asked. Compare the two directly:
curl -sS https://yoursite.com/css/app.css | head -5
curl -sS "https://yoursite.com/css/app.css?anything" | head -5
If the second shows your changes and the first does not, it is caching, not deployment.
The fix: change the URL whenever the file changes
Caching aggressively is correct — you just need a new URL when the content changes. The simplest reliable stamp is the file's modification time.
Add a small helper:
namespace App\Support;
class Asset
{
public static function url(string $path): string
{
$file = public_path($path);
return asset($path).(is_file($file) ? '?v='.filemtime($file) : '');
}
}
And a Blade directive so templates stay readable:
Blade::directive('css', function ($expression) {
return "<?php echo '<link rel=\"stylesheet\" href=\"'
. e(\App\Support\Asset::url({$expression})) . '\">'; ?>";
});
Use it in place of the usual link tag:
@css('css/app.css')
Which renders as /css/app.css?v=1786621606. Edit the file and the number changes, so the CDN treats it as a new object and fetches it. Nothing else changes and no cache needs purging.
Do the same for scripts
It is easy to version stylesheets, see the problem disappear, and forget JavaScript entirely — until a script change fails to appear a week later. Add the matching @js() directive at the same time.
Now cache harder, not less
With versioned URLs you can safely tell browsers to keep assets for a year, because a changed file arrives under a different URL:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2|webp|png|jpe?g|svg)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.(html|php)$">
Header set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
</IfModule>
The second block matters as much as the first. HTML must never be cached that way, or your deploy becomes invisible again — this time for a year.
What about fonts and images?
Those are usually content-named — logo-v2.webp rather than logo.webp — so a change already produces a new URL. If you overwrite an image in place, version it the same way or rename it.
Verify it, do not assume it
curl -sS https://yoursite.com/ | grep -o 'css/[a-z]*\.css?v=[0-9]*'
curl -sSI "https://yoursite.com/css/app.css?v=123" | grep -i cache-control
You want a version stamp in the HTML and a long max-age on the asset. Repeat visitors then download nothing at all, and a deploy is visible immediately.
If you are running on shared hosting, this pairs with two other things that catch people out — see deploying Laravel on Hostinger shared hosting and fixing uploads when symlink() is disabled.