Deployer finishes, every task is green, and the fix you just shipped is not on the site. You refresh, it is still broken, and you start looking for what went wrong with the fix. Often nothing did. The server is still serving the previous release, and it will stop on its own in a minute or two. We have reached the wrong conclusion from that window more than once, on more than one of our own Yii2 sites, and this post explains where it comes from and how we now close it.
How a symlink deploy goes live
Deployer builds each release in its own directory and then switches a symlink, current, to point at it. The web server's document root is current/web. The switch is atomic, which is why this style of deploy has no downtime: one moment requests resolve to the old release, the next to the new one.
That is true for the filesystem. It is not true for PHP, which keeps two caches of its own in every worker process:
- The realpath cache remembers what a path resolved to. With
realpath_cache_ttl=120on our server, a worker can keep resolvingcurrentto the old release directory for up to two minutes after the symlink has moved. - OPcache keeps compiled bytecode in memory. With
opcache.revalidate_freq=60it checks a file's timestamp at most once a minute.
Together they can keep serving the previous release for about two minutes after a deploy that reported success.
What it cost us
On one of our sites, in one session in August 2026, this produced two confident and wrong conclusions: an HTTPS redirect reported as still broken, and a disabled OAuth login reported as still on offer. Both fixes had in fact shipped; we were checking inside the window.
The step that looks like it should help does not. Our deploys run ./yii cache/flush-all, and that clears the application's data cache, not PHP's realpath cache or OPcache.
It can also make things worse. On pinuno.com.gh the cache flush ran before the symlink switch, so for the length of the window the old release was still serving traffic and filling the freshly emptied cache with old pages. One crawler request for /sitemap.xml in that gap cached the retired sitemap's output for its full 24-hour lifetime, and the new code served the old bytes until someone flushed the cache by hand. The fix was a second flush after the switch.
Reload the process that is actually serving
The direct way to end the window is to make the PHP workers start again. Our language-learning app added a deploy task in June 2026 that reloads php8.3-fpm after the symlink switch, so stale bytecode is discarded straight away.
pinuno.com.gh had a task with the same name and the same reload, and it did nothing useful, because that site does not run under PHP-FPM at all. It runs under mod_php in Apache, so the processes holding the old caches were Apache children, and reloading FPM left every one of them untouched. The task also swallowed errors, so it always looked successful. After one release the course listing kept serving the old view for about a minute although the "reload" had run. Since 30 September 2026 the task runs apache2ctl configtest and then a graceful Apache reload, with no || true to hide a failure. A graceful reload keeps the listening sockets open and lets each child finish the request it is serving, so no visitor gets an error.
Reloading is not free on a shared server. When we looked at doing the same on that site, it would have cleared OPcache for every site on the host, a cold start for all of them on each deploy of one. We decided that was not one project's call to make. That site documents the window instead, and its verification step reads the deployed file on the server before trusting a request to the live site.
Prove the new release is serving
A reload that runs is still not proof. What we wanted was a deploy that fails if the old release is still answering, so pinuno.com.gh now ends with a revision check:
- A small endpoint returns the contents of the release's
REVISIONfile as plain text, uncached, and only with a health-check token (without a configured token it returns 404). - After the reload, the deploy makes 12 requests to it per round and requires all 12 to return the revision it has just deployed.
- It tries up to 10 rounds, two seconds apart. If the workers still disagree, the deploy fails and prints the recovery commands.
Twelve requests rather than one, because each request may be served by a different worker, and a single worker holding the old release is enough to show some visitors the old site.
| Approach | Ends the window | Cost |
|---|---|---|
| Wait about two minutes before checking | Eventually | Every check in the window can mislead |
| Reload the process that serves PHP | Immediately | Cold caches; on a shared host, for every site |
| Revision check after the reload | Proves it ended | A small token-guarded endpoint |
Lessons
- A green deploy proves the tasks ran, not that the new code is answering requests.
- Know which process serves your PHP before you reload it. A reload of the wrong service succeeds and changes nothing.
- Flush application caches after the switch, or old code will refill them.
- When you must verify by hand, read the file on the server first; a request to the live site is the slower signal.
- Better still, let the deploy verify itself, and fail when it cannot.
If you want to learn to build and deploy PHP and Yii2 applications like these, have a look at our PHP Application Developer course.