+1 (276) 265-7197

Atomic Deploys for a Legacy PHP App (and What to Do About OPcache)

Plenty of profitable PHP applications are still deployed by copying files into the live document root — git pull on the production server, an rsync from a laptop, or an FTP client with a drag and a prayer. It works most of the time, which is why nobody changes it. The failure mode is narrow but real: for the ten to ninety seconds the copy takes, visitors are running a codebase that is half old and half new. A controller from the new release calls a class that has not landed yet, and someone gets a fatal error on checkout.

You do not need a container platform or a CI rewrite to fix this. You need three things: releases in numbered directories with a symlink switch, a deliberate answer for OPcache, and database migrations that are not welded to the code deploy. All three fit on a single server, and all three are reversible.

The layout

The pattern is old and boring, which is the recommendation. On the server:

/var/www/app/
  releases/
    20260918T140233/
    20260919T091510/
    20260919T163044/
  shared/
    .env
    storage/
    public/uploads/
  current -> releases/20260919T163044

Apache's DocumentRoot points at /var/www/app/current/public. A deploy builds a brand-new release directory, symlinks the shared paths into it, warms whatever needs warming, and then flips current. The flip is a single rename(2) on the symlink — atomic at the filesystem level, so no request ever sees a partial tree.

The important design decision is what goes in shared/. Anything the application writes at runtime, and anything that differs per environment:

  • .env or config/local.php — credentials and environment config, never in the release
  • user uploads (public/uploads, public/media) — the single most common thing lost on a first attempt at this
  • caches, sessions if they are file-based, compiled templates
  • logs, unless you ship them off the box

Everything else is disposable. If you cannot delete a release directory without losing data, something that belongs in shared/ is still in the release.

The switch

Do not use ln -sf on a directory symlink. It does the wrong thing: if current already exists and points at a directory, ln -sf cheerfully creates the link inside it and you end up with current/20260919T163044. Use a temporary link and mv -T, which replaces atomically:

ln -s /var/www/app/releases/$REL /var/www/app/current.tmp
mv -T /var/www/app/current.tmp /var/www/app/current

A rollback is then the same command pointed at the previous release directory, and it takes a second. That warm rollback path is the whole reason to do this: a deploy you can undo in one command is a deploy you can do on a Tuesday afternoon.

Keep five releases and prune the rest, or you will fill the disk at 3 a.m. six months from now.

Apache has to be told to stop caching the old path

Two settings bite here.

First, Apache resolves the symlink and caches the real path. Under mpm_event with PHP-FPM, the PHP side matters more, but make sure the vhost has Options +FollowSymLinks (not SymLinksIfOwnerMatch, which adds a stat per path segment) and that you are not relying on .htaccess lookups walking a directory tree that just changed underneath them.

Second, and more importantly, PHP-FPM passes SCRIPT_FILENAME through to the pool. If your handler config resolves the symlink at request time, FPM sees the old absolute path after the switch and keeps serving the previous release until the workers recycle. The fix is to make sure the document root path stays symbolic — in most Apache + mod_proxy_fcgi setups this is already the case, but verify it after your first deploy by hitting a page that echoes __FILE__ or checking SCRIPT_FILENAME in the FPM slowlog. Verify it once; then you can stop thinking about it.

OPcache: the part everyone gets wrong

OPcache stores compiled bytecode keyed by file path, with a validity check based on mtime. Two failure modes come from this:

  1. opcache.validate_timestamps=0 (correct for production performance) means PHP never re-reads the file. After an atomic switch, workers keep running the old release forever until OPcache is cleared or FPM restarts.
  2. opcache.validate_timestamps=1 with the default revalidate_freq=2 means a two-second window where some workers have the old bytecode and some have the new. On a symlink flip that is usually harmless; combined with a rsync-in-place deploy it is exactly the half-updated-codebase problem again.

The clean answer is validate_timestamps=0 plus an explicit reset as the last step of the deploy. Do not do it with a web-accessible opcache_reset.php — that is an unauthenticated denial-of-service endpoint sitting in your document root. Two safe options:

CacheTool over the FPM socket, which talks FastCGI directly and needs no HTTP route:

php cachetool.phar opcache:reset --fcgi=/run/php/php8.3-fpm.sock

Or a graceful FPM reload, which finishes in-flight requests before recycling workers:

systemctl reload php8.3-fpm

Reload is heavier — you lose your warmed cache and the first requests after it are slower — but it needs no extra tooling and it also picks up php.ini changes. On a single-server app serving modest traffic, reload is fine. Measure the first-request penalty on your own app before deciding it is not.

If you use opcache.preload (a real win on framework apps), note that preloading only happens at FPM start: a reload is required, opcache_reset() will not do it. And if you run PHP 8.1 or newer with JIT enabled, the same reload rule applies.

One more setting worth checking while you are in there: opcache.max_accelerated_files. The default of 10,000 is below the file count of a mid-size Composer project. If opcache_get_status() shows num_cached_keys pinned at the limit and oom_restarts climbing, you are thrashing the cache on every request and paying compile time you think you eliminated. Set it to the next prime above your actual file count:

find /var/www/app/current -type f -name '*.php' | wc -l

Migrations do not belong inside the switch

The reason teams stay with in-place deploys is usually the database: the new code needs a new column, so the schema change and the code change have to happen together, so there is no atomic moment to aim at.

Break that coupling with expand-and-contract. Ship the schema change first, in a form the current code tolerates:

  1. Expand. Add the new nullable column, or the new table, in its own deploy. Old code ignores it. Nothing breaks.
  2. Backfill in batches from a script, not in a migration that locks a 200 GB table during a deploy window.
  3. Deploy code that writes both old and new fields and reads the new one.
  4. Contract. Once no running code reads the old column, drop it — a separate, later deploy.

This is more steps, and every one of them is individually safe and individually revertible. The alternative is a deploy that cannot be rolled back because the rollback code no longer matches the schema, which is how a two-minute problem becomes a two-hour one.

Practical rules: migrations run before the symlink flip, never as part of it. Additive-only changes get deployed automatically; destructive ones (DROP, MODIFY on a large table, unique index additions) run by hand with someone watching, or through an online schema change tool.

What a full deploy script looks like

End to end, on one server, no pipeline product required:

set -euo pipefail
REL=$(date -u +%Y%m%dT%H%M%S)
DIR=/var/www/app/releases/$REL

git clone --depth 1 --branch "$BRANCH" "$REPO" "$DIR"
cd "$DIR"
composer install --no-dev --optimize-autoloader --no-interaction
ln -s /var/www/app/shared/.env "$DIR/.env"
rm -rf "$DIR/public/uploads" && ln -s /var/www/app/shared/public/uploads "$DIR/public/uploads"
php bin/console doctrine:migrations:migrate --no-interaction   # additive only
ln -s "$DIR" /var/www/app/current.tmp
mv -T /var/www/app/current.tmp /var/www/app/current
php /usr/local/bin/cachetool.phar opcache:reset --fcgi=/run/php/php8.3-fpm.sock
ls -1dt /var/www/app/releases/* | tail -n +6 | xargs -r rm -rf

That is forty lines of shell. Run it from CI if you have CI, from a laptop over SSH if you do not. Either way the properties you gained are the ones that matter: no partial state visible to users, a rollback that is one command, and a deploy you can repeat identically instead of remembering.

Rehearse the rollback, not just the deploy

A rollback path you have never exercised is a hypothesis. Before you trust this, do it on purpose: deploy a release, then flip current back one directory, reset OPcache, and confirm the old version is actually serving. Time it. If that takes more than thirty seconds, find out why now rather than during an incident. It is the same argument as proving your backups restore — the mechanism is not real until it has run.

If you want this set up on an application that has been deployed by hand for eight years, it is a small piece of our performance and reliability work and usually a day or two, not a project. Tell us how you deploy today and we will tell you whether it is worth changing — sometimes a quiet FTP deploy to a low-traffic internal app genuinely is fine, and we will say so.