laravel
Zero-downtime Laravel deploys — the atomic-symlink pipeline that keeps queues, sessions, and OPcache honest
What it actually takes to deploy Laravel without dropping requests or losing jobs: atomic releases, the queue worker dance, OPcache reset timing, and the Envoyer-style pipeline we ship to customers.
20 de mayo de 2026 · 10 min · por Sudhanshu K.
Zero-downtime Laravel deploys — the atomic-symlink pipeline that keeps queues, sessions, and OPcache honest
"Zero-downtime deploy" is one of those phrases that gets thrown around without anyone defining what it means. Does it mean no 5xx errors? No connection resets? No request served by a mix of old and new code? No background jobs lost?
For Laravel applications we operate, it means all four. This post walks through the deploy pipeline we ship to managed customers — what's in it, why each step is there, and the specific Laravel pitfalls that "git pull and php artisan migrate" misses.
What "downtime" actually means
In our framing, a deploy that drops one request is a deploy that wasn't zero-downtime. The four things we're protecting against:
- HTTP 502/503 errors while the application is being swapped
- Mixed-version requests — request enters routing on old code, exits on new code
- Queue worker confusion — workers running old code pick up jobs intended for new code
- Session/CSRF discontinuity — users logged out, forms rejected mid-submission
Each is a separate problem with a separate fix. Most "zero-downtime" pipelines we audit solve (1) and ignore the other three.
The atomic-symlink pattern
The foundation, used by Envoyer, Deployer, Capistrano, and our own pipeline: deploys go into a new directory, and a single symlink swap promotes them. Layout on disk:
/var/www/myapp/
├── current -> /var/www/myapp/releases/20260518T103000Z
├── releases/
│ ├── 20260516T091200Z/
│ ├── 20260517T140500Z/
│ └── 20260518T103000Z/
├── shared/
│ ├── .env
│ ├── storage/
│ └── bootstrap/cache/.gitignore
└── repo.git/ (bare clone)
The deploy:
#!/bin/bash
set -euo pipefail
RELEASE_DIR=/var/www/myapp/releases/$(date -u +%Y%m%dT%H%M%SZ)
SHARED_DIR=/var/www/myapp/shared
CURRENT_LINK=/var/www/myapp/current
# 1. Clone into a fresh release directory
git clone --reference /var/www/myapp/repo.git --dissociate \
-b "$DEPLOY_REF" git@github.com:org/myapp.git "$RELEASE_DIR"
cd "$RELEASE_DIR"
# 2. Link shared resources (env, storage, etc.)
ln -snf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -snf "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
# 3. Install dependencies (with optimizations)
composer install --no-dev --no-interaction --optimize-autoloader \
--no-progress --prefer-dist
# 4. Build front-end assets if needed
if [ -f package.json ]; then
npm ci --omit=dev
npm run build
fi
# 5. Cache framework artefacts
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 6. Run migrations (--force in prod; see article on migration safety)
php artisan migrate --force
# 7. Atomic symlink swap
ln -snf "$RELEASE_DIR" "${CURRENT_LINK}.new"
mv -Tf "${CURRENT_LINK}.new" "$CURRENT_LINK"
# 8. Reload PHP-FPM / restart Octane workers
sudo systemctl reload php8.3-fpm
# (Octane reload happens separately — see below)
# 9. Restart queue workers gracefully
php artisan horizon:terminate
# 10. Keep last 5 releases, prune older
cd /var/www/myapp/releases
ls -1t | tail -n +6 | xargs -r rm -rfThis is the spine. Steps 1-6 happen in the new release directory while the current one is still serving traffic. Step 7 is the moment of cutover — a single mv syscall on a symlink, atomic on POSIX filesystems. Step 8 onwards tells everything that holds open file descriptors to that symlink to refresh.
The OPcache problem
Here's the bit a lot of deploy scripts get wrong. PHP-FPM doesn't read the symlink fresh on every request. OPcache caches the resolved path of every included file. When you swap the symlink, OPcache is still serving compiled bytecode from the old release path — even though that path no longer exists.
Three approaches to deal with this:
Approach 1: php-fpm reload — sends SIGUSR2 to PHP-FPM, which gracefully restarts workers. New workers load files from the new symlink. Old workers finish in-flight requests on the old code, then exit.
sudo systemctl reload php8.3-fpmThis is the standard. It works on every FPM-based deployment. Latency cost: 50-200ms blip as workers cycle, but no requests are dropped because FPM does a true graceful restart.
Approach 2: opcache_reset() via HTTP
curl -sf https://internal.myapp.com/_internal/opcache-resetWith an endpoint that calls opcache_reset(). Faster than the FPM reload (no worker recycling), but has a subtle window: if request A is mid-execution with a partially loaded class graph from the old release, and opcache_reset() fires, request A can fail with "class not found" errors. We don't use this approach for that reason.
Approach 3: Configure OPcache to never cache resolved paths
; php.ini
opcache.revalidate_freq=0
opcache.validate_timestamps=1This makes OPcache check file mtimes on every request. It's the slowest option and we don't recommend it for production. It's useful in staging.
We use approach 1 by default. It's boring, it's well-understood, and it costs 100ms of latency once per deploy. Worth it.
Octane changes the story
If you've migrated to Octane (as covered in our Octane runtime article), the OPcache reset isn't enough. Octane workers hold the entire bootstrapped Laravel application in memory. The new code on disk doesn't propagate until the workers restart.
The Octane-specific deploy step:
# After symlink swap
php artisan octane:reloadUnder the hood, this sends a signal to Octane's master process, which gracefully recycles each worker — old workers finish their in-flight requests on old code, new workers boot on new code. As long as your Octane is configured with at least 2 workers and your load balancer has connection reuse, you don't drop a request.
Important: octane:reload is per-host. If you have 4 application servers, you run it on each, and (this is the part teams skip) you stagger them. We do host1, wait 10 seconds, host2, wait 10 seconds, ... so the cluster never has all workers recycling at once.
The queue worker dance
Horizon (or queue:work) processes hold the application in memory for the same reason Octane does. A worker started against the old release will, by default, keep processing jobs against the old code indefinitely.
If a job's payload was serialized by old code and is now being unserialized by new code, you get version-skew exceptions. The classic example: you renamed a job class, but jobs of the old class are still in the queue.
Our pattern:
# 1. After symlink swap and FPM reload, terminate Horizon gracefully
php artisan horizon:terminate
# horizon:terminate sends SIGTERM to all supervisors and workers
# They finish in-flight jobs, then exit
# supervisord (or systemd) restarts horizon, which loads from current symlinkThe horizon:terminate does not lose jobs. In-flight jobs finish; queued jobs wait in Redis; the new Horizon picks them up.
There's a subtle issue with renamed/deleted job classes. If you deploy a release that removes App\Jobs\OldJobClass and there are pending instances of that class in the queue, the new workers can't unserialize them — they fail catastrophically.
The pattern for safe rename/delete:
- Release N: Keep the old class, add the new class, dispatch the new class going forward
- Wait until no jobs of the old class remain in the queue (a few hours, usually)
- Release N+1: Remove the old class
This is the same kind of multi-step migration we'll cover for database schema changes — old-code/new-code compatibility is a real constraint, and it adds steps.
Sessions and CSRF
If you store sessions in files (SESSION_DRIVER=file), they live in storage/framework/sessions/. With the atomic-symlink layout, storage/ is symlinked into shared/storage/, so sessions survive the deploy.
If you store sessions in the database or Redis (we strongly prefer Redis for any non-trivial app), they're external to the release entirely — no problem.
What can go wrong: the APP_KEY rotates and existing session payloads can't be decrypted. The fix is "don't rotate APP_KEY in a routine deploy." Treat key rotation as a separate, planned operation with session-invalidation messaging to users.
CSRF tokens are tied to the session. They survive deploy as long as the session does.
Front-end asset versioning
If you build assets with Vite (default in modern Laravel), the build produces hashed filenames and a manifest. The browser caches the old assets indefinitely; new HTML references new hashes. No problem.
The trap: if your old HTML is still being served (cached at CDN, in a user's tab) and references the old hash, and you've deleted the old asset files, the page breaks.
The fix: keep at least the last 5 release directories on disk (the tail -n +6 in our deploy script) and serve assets directly from current/public/build/ via nginx alias. We also configure CDN to set a 1-week cache on hashed assets and a no-cache on index.html-style entries.
Health-check based deploy verification
The pipeline doesn't end at mv. We verify:
# Smoke test the new release
for i in $(seq 1 20); do
status=$(curl -sf -o /dev/null -w "%{http_code}" https://myapp.com/_health)
if [ "$status" != "200" ]; then
echo "Health check failed: $status"
# Roll back
ln -snf "$PREVIOUS_RELEASE" "$CURRENT_LINK"
sudo systemctl reload php8.3-fpm
php artisan octane:reload
exit 1
fi
sleep 1
doneThe /_health endpoint we ship with managed Laravel apps checks: database connectivity, Redis connectivity, queue ping, recent migration state, and a fast equity check that the app booted (a known query returns a known result). Twenty seconds of green health checks before we declare the deploy successful.
Rollback
The atomic-symlink pattern makes rollback trivial:
ln -snf /var/www/myapp/releases/<previous> /var/www/myapp/current
sudo systemctl reload php8.3-fpm
php artisan octane:reload
php artisan horizon:terminateFive seconds, no rebuild, no re-download. The only thing rollback doesn't undo is database migrations — which is exactly why our article on migration safety treats them as the most dangerous part of any deploy.
The pipeline we ship
For managed Laravel customers on DigitalOcean, AWS, GCP, and Azure, the deploy pipeline we provision and operate for them includes:
- Git-tag-triggered deploys via GitHub Actions or GitLab CI
- Pre-deploy hook: run the test suite, fail the deploy if anything's red
- Pre-deploy hook:
composer auditandnpm audit --omit=dev— refuse the deploy on critical vulnerabilities - The atomic-symlink shell pipeline above, parameterised per environment
- Staged rollout across multiple hosts with the 10-second stagger
- Health-check verification gate
- Slack notification on success/failure with deploy duration and the git diff
- One-command rollback that an on-call engineer can run from their phone
If you're still deploying Laravel via git pull && php artisan migrate && php artisan config:cache, you're a single composer install partial-state-on-disk away from a 502 storm. Get in touch and we'll either build you the pipeline or run it for you.
Sudhanshu K. is a Senior DevOps engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). He has deployed Laravel applications more than 18,000 times in production and has the rollback-from-phone story to prove it.