Let's Connect

Network server racks with cabling in a data center, representing zero-downtime PHP deployments behind a load balancer

Zero downtime deployment for a PHP app starts the moment you stop running git pull in the live document root. The instant you do, you have a half-updated app: new PHP files calling old vendor classes, templates referencing controllers that have not landed yet, and every request during those few seconds gets a 500. If composer install fails partway, you are down until you fix it by hand. The fix is atomic releases: build each release in its own directory, then switch to it with a single symlink swap that points fully at the new code or fully at the old code, never in between. I have run this pattern on production PHP for years, and the deploy I used to dread now flips in under a second.

Why does git pull on the live directory cause downtime?

A deploy is not one event, it is many file writes. git pull rewrites tracked files one at a time over the live tree while PHP-FPM is actively serving requests out of that exact tree. For the duration of the checkout you have a mixed build. A request that loads a freshly-updated controller referencing a class composer has not installed yet fatals. Run database changes in the same step and the window widens to seconds. Then there is the failure case: a network blip mid-pull, a composer dependency that 404s, a build script that errors, and the live directory is frozen in a broken state with no clean way back except reversing it by hand under pressure.

The root problem is that the live path and the build path are the same path. Atomic releases separate them. You build in isolation, verify, and only then redirect the live path. The switch is a single rename of a symlink, which the kernel performs atomically, so no request sees a partial state.

What does the atomic symlink release layout look like?

The whole pattern hangs off one directory structure. Your web server and PHP-FPM point at a path that ends in current, and current is a symlink, never a real directory. Each deploy lands in releases/<timestamp>, and anything that must survive a deploy (uploads, the .env file, logs) lives in a shared directory that gets symlinked into the new release.

/var/www/myapp directory layout
/var/www/myapp
├── current -> releases/20260507143012   # the live symlink (atomic switch point)
├── releases
│   ├── 20260507120433                    # previous release (rollback target)
│   └── 20260507143012                    # release just deployed
└── shared
    ├── .env                              # one env file, never in git
    └── storage                           # uploads, cache, logs persist here

Your Nginx root points at the symlink, not the timestamped directory. Because Nginx and PHP-FPM both resolve current at request time, flipping the symlink moves all new requests onto the new release the moment the rename completes.

/etc/nginx/sites-available/myapp.conf
server {
    listen 443 ssl;
    http2 on;
    server_name myapp.example.com;

    # Point at the symlink. $realpath_root resolves it per request
    # so PHP-FPM gets the real release path, not the symlink path.
    root /var/www/myapp/current/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }
}

The choice of $realpath_root over $document_root matters in practice. If you pass the symlink path to PHP-FPM, OPcache keys its cache on /var/www/myapp/current/index.php, a path that never changes across deploys, so it can serve stale bytecode after the swap. Resolving the real path means each release lives under a distinct path and gets its own cache keys, so a fresh release compiles fresh. Note that on Nginx 1.25.1 and newer the listen ssl http2 shorthand is deprecated in favor of the separate http2 on; directive shown above.

How do I script the atomic deploy?

The deploy script builds the new release fully before touching current. Every expensive or failure-prone step (clone, composer install, asset build) runs against the new directory while the old release keeps serving. Only after everything succeeds do you flip the symlink with an atomic rename.

deploy.sh
#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/var/www/myapp
RELEASE="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"
REPO=git@github.com:me/myapp.git

# 1. Build the new release in isolation.
git clone --depth 1 --branch main "$REPO" "$RELEASE"

# 2. Symlink shared, non-versioned state into the release.
ln -s "$APP_DIR/shared/.env"     "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -s "$APP_DIR/shared/storage"  "$RELEASE/storage"

# 3. Install deps (no dev) and build assets, optimized for prod.
cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
npm ci && npm run build

# 4. Run backward-compatible migrations BEFORE the flip.
php artisan migrate --force

# 5. Atomic switch. ln -sfn creates the new link under a temp name,
#    then mv -T renames it over 'current' in a single rename(2) syscall.
#    No request ever sees a missing or half-written symlink.
ln -sfn "$RELEASE" "$APP_DIR/current-tmp"
mv -T   "$APP_DIR/current-tmp" "$APP_DIR/current"

# 6. Reload PHP-FPM gracefully (SIGUSR2) and restart queue workers.
sudo systemctl reload php8.3-fpm
php artisan queue:restart

# 7. Keep the last 5 releases, prune the rest.
cd "$APP_DIR/releases" && ls -1dt */ | tail -n +6 | xargs -r rm -rf

Step 5 is the load-bearing part. A plain ln -sfn directly onto an existing symlink is not atomic, because under the hood it unlinks the old link and then creates the new one, leaving a brief window where current does not exist. Creating the link under current-tmp and using mv -T to rename it over the target is a single rename(2) syscall, which the kernel guarantees is atomic on the same filesystem. (-T, --no-target-directory, forces mv to treat current as the name to replace rather than a directory to move into; no -f is needed since the destination is a symlink.) That atomic rename is the whole point of the pattern.

What do I actually need to do about OPcache on deploy?

OPcache compiles your PHP to bytecode and caches it in shared memory, keyed by the resolved file path. Because $realpath_root gives every release a distinct real path, each new release's files are new keys and compile fresh on first request, so you do not need a global opcache_reset() after the flip. In fact a blind global reset is counterproductive: it dumps the warm cache the old workers are still serving from during the brief overlap, causing a recompile spike under live traffic. The two things that genuinely matter are setting opcache.validate_timestamps=0 in production (so OPcache does not stat every file on every request) and reloading FPM, not restarting it.

systemctl restart php8.3-fpm kills the master and all workers immediately, dropping every in-flight request. systemctl reload sends SIGUSR2, which spins up new workers and lets existing ones finish their current request before retiring. That is the difference between a clean deploy and a handful of dropped connections at the exact moment of the switch. If you ever do need to flush the pool explicitly (for example because you cannot use realpath keying), reach for a tool like cachetool that talks to the FPM socket, rather than hitting a web route, because opcache_reset() called from the CLI does not touch the FPM pool's shared memory at all.

/etc/php/8.3/fpm/conf.d/10-opcache.ini (production)
opcache.enable=1
opcache.validate_timestamps=0    ; do not stat files on every request
opcache.max_accelerated_files=20000
opcache.memory_consumption=256
opcache.interned_strings_buffer=16

In a Laravel app you also rebuild the framework's own cached config and routes (php artisan config:cache and route:cache) as part of the build, before the flip. Combined with distinct realpaths per release, that covers the symptom people chase for an hour: files on disk are new, but the app still behaves like the old release.

Developer at a terminal monitoring a deployment, representing the atomic symlink flip moment during a PHP release
The flip is one rename(2) syscall. Everything before it is build; everything after is reload. No request straddles the two.

How do migrations stay safe during the symlink flip?

This is the subtle one. During the flip there is a brief overlap: old FPM workers finish in-flight requests against the old code while new requests hit the new code. If a migration dropped a column the old code still selects, those in-flight requests crash. The rule is that each migration must be backward-compatible with the immediately previous release for the span of one deploy. That is also why running migrate before the flip is safe here: a backward-compatible migration does not break the old code that is still serving.

So you do destructive schema changes across two deploys, never one. This is the expand-and-contract pattern:

  • Renaming a column: deploy 1 adds the new column and writes to both; deploy 2, after the old code is gone, drops the old column.
  • Adding a NOT NULL column: ship it nullable with a default first, backfill, then tighten the constraint in a later deploy.
  • Dropping a column: deploy 1 stops the code referencing it; deploy 2 actually drops it once no running code reads it.
  • Index changes on large tables: build indexes with an online or concurrent strategy so the migration does not lock the table and stall live queries.

Queue workers are their own trap. A worker is a long-lived PHP process that loaded your old code into memory at boot and will keep running it against the new database schema for hours. php artisan queue:restart sets a cache flag that tells each worker to exit gracefully after its current job, so Supervisor restarts it on the new release. Skip this and you get jobs failing against a schema they were never written for. I cover the worker side in depth in my notes on running Laravel queue workers with Supervisor.

How do I roll back instantly when a deploy goes wrong?

This is the payoff. Because the previous release still sits intact in releases/, rollback is just pointing current back at it and reloading. No re-clone, no rebuild, no waiting on composer. It is the same atomic rename in reverse.

rollback.sh
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp

# Second-newest release directory = the one before the current deploy.
PREVIOUS=$(ls -1dt "$APP_DIR"/releases/*/ | sed -n '2p')

ln -sfn "$PREVIOUS" "$APP_DIR/current-tmp"
mv -T   "$APP_DIR/current-tmp" "$APP_DIR/current"
sudo systemctl reload php8.3-fpm
php artisan queue:restart
echo "Rolled back to $PREVIOUS"

The one thing a symlink rollback cannot undo is a migration. This is exactly why migrations must be backward-compatible: if deploy N only added columns, rolling code back to N-1 is safe because the old code simply ignores them. If you had dropped a column in the same deploy, the old code would now be broken against the live database and a code-only rollback would not save you. Backward-compatible migrations are what make instant rollback actually work.

A deploy you cannot reverse in one command is not a deploy, it is a bet you are hoping pays off.Md Raihan Hasan

When should I move up to blue-green or rolling deploys?

The symlink pattern is a single-server strategy and it is genuinely enough for a large share of production PHP apps. You outgrow it when one box can no longer take the traffic, or when you want to validate a release on live infrastructure before any user sees it. That is where blue-green and rolling deploys come in, sitting behind a load balancer such as an AWS Application Load Balancer.

Blue-green runs two identical fleets: blue is live, green gets the new release, you smoke-test green privately, then flip the load balancer's listener or target group from blue to green. Rollback is flipping it back. Rolling deploys update instances a few at a time, draining each from the ALB target group before deploying and re-registering it after a health check passes, so capacity dips slightly but you never go fully dark. The atomic-symlink technique still runs on each individual instance underneath both; the load balancer just decides which instances receive traffic. If you are sizing this out on AWS, my Laravel production architecture on AWS walkthrough covers the ALB and target-group setup.

One boundary worth drawing: this post is about the deployment strategy, the mechanics of swapping code without downtime. It is not about the pipeline that triggers it. Wiring this script into an automated build-test-deploy flow is a separate job, which I document in building a CI/CD pipeline with GitHub Actions. The strategy here is what your pipeline ultimately runs.

If you take one thing away, make it the separation of build path from live path. Every zero-downtime PHP technique (symlink swaps, blue-green, rolling) is a variation on the same idea: build somewhere the live app cannot see, verify it in full, and switch over in a single atomic operation that is just as easy to reverse. Start with the symlink pattern on your single server this week, pair it with backward-compatible migrations and a queue:restart, and the next time a deploy goes sideways you fix it with one command instead of an outage. The official PHP OPcache configuration reference is worth a read for the validate_timestamps and revalidate_freq settings that govern the cache behavior described here: https://www.php.net/manual/en/opcache.configuration.php