Bug fixed in production — ticket closed. Users still see the error. CDN cache? Purged. Opcode? opcache.validate_timestamps=0 from "performance" tuning: PHP files updated on disk, old bytecode served forty minutes until a panicked manual PHP-FPM restart.
OPcache is acceleration — mishandled in production, it is a code cache ignoring your deploys. The goal is not "clear cache"; it is syncing filesystem release and PHP workers without triggering a cascade of 502 errors.
Healthy deploy cycle
A reliable PHP deploy follows a documented sequence:
- Atomic release:
/releases/20260719/+ symlinkcurrent. - Composer install, migrations, app cache warm-up.
php artisan optimizeor Symfony cache if applicable.- Reload PHP-FPM:
systemctl reload php8.2-fpm(graceful). - Smoke test on health endpoint.
- Rollback = revert symlink + reload.
reload recycles workers progressively — in-flight requests finish; new workers load fresh OPcache. A brutal restart causes outage; reload is the preferred choice for zero-downtime deploy.
Brutal restart vs graceful reload: the first interrupts service; the second preserves in-flight connections.
validate_timestamps: the trade-off to understand
| Setting | Behavior | Use |
|---|---|---|
validate_timestamps=1 | stat file, recompile if mtime changes | Dev, staging |
validate_timestamps=0 | never recheck disk | Production + reload each deploy |
With revalidate_freq=0 and validate=1, every request checks disk — useless IO in production. The pair validate_timestamps=0 + scripted FPM reload is the standard for teams deploying several times per week.
Invalidation strategies
A — FPM reload (recommended) Post-deploy step in script: systemctl reload php-fpm. Simple, predictable, no major interruption.
B — CLI opcache_reset() post-deploy Via SSH one-shot — acceptable with a single PHP-FPM pool. Never in an HTTP request.
C — Rolling deploy on N servers Load balancer drain → deploy → reload → re-enable. Zero downtime on a cluster.
D — opcache.file_cache Second level on disk — watch sync across nodes during deploy.
Multi-version and container traps
- Blue-green with two releases: mixed workers if partial reload — finish reload on all nodes before switching traffic.
- Preloading PHP 7.4+: preload file change requires full restart, not simple reload.
- Docker: new image = new container, fresh OPcache. Volume-mounted code = same validate_timestamps issue.
On shared hosting, no self-service FPM reload — FTP deploy with forced validate_timestamps=1 or support ticket. More reason for VPS or PaaS if you deploy frequently.
The peak: OPcache without deploy procedure is involuntary A/B testing
Here is what "performance" settings forget to document.
PHP deploy checklist: documented symlink + FPM reload — not "upload and hope."
Decide and move forward without blind spots
In half a day you can secure your PHP deploys:
- Set
validate_timestamps=0in production with scripted FPM reload. - Implement atomic deploy via symlink.
- Make mandatory a smoke test post-reload.
- Ban
opcache_reset()in web endpoints. - Plan rolling deploy if you have multiple servers.
Document the full sequence in your deploy runbook: who triggers reload, how to verify all workers recycled, and which test confirms new code is served. Cross-read PHP profiling to measure bad deploy impact, and Blue-green deployment for multi-instance architectures.
Frequently asked questions
Why does old code run after deploy?
OPcache keeps compiled bytecode in memory. With validate_timestamps=0, changed files on disk trigger no reload until a worker is recycled via FPM reload.
Is opcache_reset() safe in prod?
Not in a web request — it is a brutal global reset. Prefer graceful PHP-FPM reload or CLI reset post-deploy, outside user traffic.
validate_timestamps=1 in production?
Fine for staging. In production it adds stat() calls on every include. The common compromise remains validate_timestamps=0 + FPM reload each deploy.
Does atomic deploy help?
Yes. Symlink switch + FPM reload syncs all workers on the same file tree, without mixing old and new releases.
After deploy, if OPcache was not invited, you run two site versions — one in git, one in RAM.