Site Not Updating After Deploy — Clear All Five Cache Layers
Saying "deployment is done" because the files have reached the server and saying it because visitors can see the new version are two different things at different layers. Between them sit five layers that each need a different clearing method.
For a while, I had to update a client's website several times a day, and almost every time I got the same question: "Have you changed it? Why does it still look the same?" At first, I would say to wait a moment and try clearing the browser cache, which was partly right sometimes and completely wrong many other times. After this happened often enough, I sat down to trace everything that stood between the files I uploaded and their appearance on someone's screen.
The answer is five layers, and what makes this more frustrating than it should be is that each layer needs a different clearing method, and clearing the wrong layer produces no error at all. Nothing changes, so I wrongly conclude that the files have not been uploaded yet and upload them again, going around in circles.
All domains and paths in this article are examples.
The five layers between the files and the reader
Most people already know the bottom and top layers well. The three in the middle are the ones that regularly get missed because they live on the server itself, cannot be seen from outside, and give no indication that they are still holding the old version.
Layer 1: opcache — new files, but PHP still runs the old version
opcache stores compiled PHP in memory so it does not have to interpret the files again on every request, which is very good for performance. The problem comes down to this one setting.
; php.ini ของ pool ที่ใช้จริง
opcache.enable=1
opcache.validate_timestamps=0 ; <- ตัวนี้แปลว่า "ไม่ต้องไปดูว่าไฟล์เปลี่ยนไหม"
opcache.revalidate_freq=0
If validate_timestamps=0, PHP completely ignores changes to files on disk. It keeps running the same bytecode until someone clears it or reloads the process. If it is set to 1 with revalidate_freq=60, the wait can be up to one minute, which is just enough time to test once and say, "It's not showing up."
Check the actual settings of the pool serving the site, not those of the CLI, because the two can use different configuration files.
# ค่าฝั่ง CLI (มักไม่ตรงกับฝั่งเว็บ)
php -i | grep -E 'opcache.(enable|validate_timestamps|revalidate_freq)'
# ค่าฝั่งเว็บจริง — ต้องดูผ่าน request
printf '<?php phpinfo();' > ~/domains/app.example.com/public_html/_i.php
curl -s https://app.example.com/_i.php | grep -o 'validate_timestamps.*' | head -3
rm -f ~/domains/app.example.com/public_html/_i.php
There are two ways to clear it. The first is to reload PHP-FPM for the entire pool, which is possible with root privileges. The second, for shared hosting without those privileges, is to call opcache_reset() through a web page, because this function only affects the process running it. Calling it from the CLI has no effect on the web side.
<?php
// _opc.php — วางไว้ที่ docroot ชั่วคราว แล้ว "ลบทันที" ที่ใช้เสร็จ
if (function_exists('opcache_reset')) {
var_dump(opcache_reset());
} else {
echo 'no opcache';
}
curl -s https://app.example.com/_opc.php # bool(true)
rm -f ~/domains/app.example.com/public_html/_opc.php
Both _i.php and _opc.php can be accessed by anyone who guesses their names. The first exposes versions, modules, and paths across the machine. The second lets outsiders repeatedly clear the site's cache. Delete them immediately after use as part of the same command. Never leave them overnight, and do not give them an easily guessed name such as info.php.
Layer 2: config cache — changing .env has no effect
php artisan config:cache combines all configuration files, along with the values read from .env at that moment, into a single file. Once that file exists, the env() function in application code immediately returns an empty value because Laravel no longer loads .env. The symptom is that no amount of editing .env changes anything, or worse, values that used to work become empty after deployment.
php artisan config:clear # ทิ้งไฟล์รวม กลับไปอ่าน .env สด
php artisan config:cache # อบใหม่จากค่าปัจจุบัน
php artisan about --only=environment
The rule I follow is that application code must only read config('x.y'); never call env() outside configuration files. Always put values containing special characters in quotes, because a # in the middle of a line is read as the start of a comment, silently cutting off half the value.
# ผิด — ค่าที่ได้จริงคือ Secret เฉย ๆ
APP_PASSPHRASE=Secret#2026
# ถูก
APP_PASSPHRASE="Secret#2026"
Layer 3: route cache — a new route returns 404
This layer is the most straightforward. If a route cache file exists, newly added routes do not exist until the cache is rebuilt. The symptom is a 404 even though route:list shows the route, because route:list reads the source files rather than the cached file.
php artisan route:clear && php artisan route:cache
curl -sI https://app.example.com/privacy | head -1 # ต้องได้ 200 ไม่ใช่ 404
On some machines, the route cache causes every GET to return 405, which is a separate issue from the one discussed here. I covered that separately in Every GET becomes 405 after route:cache. If that is what you are seeing, skip over to that article instead.
Layer 4: view cache — and the trap that leaves 500 errors after clearing it
Every blade file is compiled into PHP and stored in storage/framework/views/, with a filename based on a hash of the original path. Laravel normally compares file modification times to decide whether recompilation is needed, which works well on a dev machine.
The case to watch out for is production with active visitors. If I simply run view:clear, there is a window when the compiled files have been deleted but have not yet been recreated, and requests arriving at exactly that moment get an error like this.
production.ERROR: filemtime(): stat failed for
/home/appuser/app/storage/framework/views/8f2c...c1.php
{"exception":"[object] (ErrorException(code: 0) ...
What puzzled me for a while was that this is not always a temporary error that goes away on its own. Sometimes it keeps returning 500 indefinitely, even after opening the page ten more times. The definitive fix is to avoid compiling at runtime altogether and precompile every file during deployment.
php artisan view:clear
php artisan view:cache # precompile ทุก blade ไม่เหลือช่องให้แข่งกัน
curl -sI https://app.example.com/ | head -1
When APP_DEBUG is off, the page only says Server Error, which provides no information at all. The actual details are always in storage/logs/laravel.log. Do not enable APP_DEBUG on production to inspect an error, because Laravel's debug page displays the whole set of .env values, including the database password. Read the log instead.
Layer 5: CDN — static files stay unchanged after overwriting the same filename
HTML pages returned by Laravel normally have headers that prohibit caching, so they rarely cause problems. The trouble comes from static files, including js, css, and images, because the CDN keeps them for the configured lifetime, and a default of 4 hours is very common. Uploading a new file over the same filename does not remove the copy already cached.
One thing I tried that did not help was temporarily switching the domain to DNS-only. Although requests did go directly to the origin, browsers and intermediaries along the way still held the old version. When I switched back, the old version appeared again, and while the proxy was off, the site's real server IP was exposed to the world as well. It was not worth it.
Click purge in the control panel
This takes effect immediately for the specified files, but it must be done manually every time. Forget once and the same problem returns, and it does not help with the cache on devices that have already visited the site.
Change the referenced filename
Append a version number or hash, such as app.css?v=20261015a. When the name changes, every layer, including browsers that have already visited, treats it as a different file. There is no need to rely on purging anymore.
// config/app.php — เลขรุ่นของชุดไฟล์สแตติก อัปเดตตอน deploy
'asset_ver' => env('ASSET_VER', '20261015a'),
{{-- ใน blade --}}
<link rel="stylesheet" href="{{ asset('css/app.css') }}?v={{ config('app.asset_ver') }}">
<script src="{{ asset('js/site.js') }}?v={{ config('app.asset_ver') }}" defer></script>
The table beside my monitor: what each check can still miss
What it actually tells me
ls -l on the server only tells me the files have reached the disk · Opening my own browser only tells me what my machine sees · A plain curl request only tells me what the origin returns to a non-human user agent
What each check cannot see
The files are on disk, but opcache still runs the old version · I just did a hard refresh, but other browsers still have the old version · curl gets the new version from the origin, but real visitors get the old version from the CDN
The check that most closely reflects reality is to send a request with a real browser's user agent and a random cache-busting parameter, then read the cache status headers alongside the response.
UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/151.0'
curl -sI -A "$UA" "https://app.example.com/css/app.css?v=$RANDOM" | grep -iE 'http/|age|cache-control|last-modified'
The clearing sequence I use regularly — one script handles it all
I stopped trying to remember each step and wrote a script to keep on the machine, because the order matters. If config is cleared afterward, the views will be compiled with the wrong values again.
#!/bin/bash
# deploy-flush.sh — รันหลังอัปไฟล์เสร็จทุกครั้ง
set -e
APP=~/domains/app.example.com/app
DOC=~/domains/app.example.com/public_html
cd "$APP"
php artisan config:clear
php artisan config:cache
php artisan route:clear
php artisan route:cache # เครื่องไหน route cache มีปัญหา ให้เหลือแค่บรรทัดบน
php artisan view:clear
php artisan view:cache
printf '<?php opcache_reset();' > "$DOC/_opc.php"
curl -s "https://app.example.com/_opc.php" >/dev/null
rm -f "$DOC/_opc.php"
curl -sI "https://app.example.com/?v=$RANDOM" | head -1
- Clear, then rebuild immediately on the next line Do not leave the machine in a "cleared but not yet rebuilt" state while people are using the site, because that is the window when missing compiled files can leave it stuck on 500 errors.
- Call opcache_reset through https, not the CLI Calling it from the CLI clears the memory of the CLI process, which is not the one serving the site. Always delete the helper file on the next line.
- Finish by requesting a real page and reading the status If the last line does not return 200, this deployment is not finished yet. Do not tell anyone it is done.
The rest is purely a matter of discipline. I spent half an hour writing that script that day, then went a whole year without having to answer "Why does it still look the same?" For anyone still clearing caches manually every time, at least remember these two points: rebuild immediately after clearing, and always verify the result by requesting a real page. If I have left anything out or made a typo, I will come back and fix it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first