Deploying updates to a high-traffic Laravel application without dropping a single active request requires moving away from naive git pull strategies. In this guide, we walk through the architecture, deployment script, and automated CI/CD pipeline required to achieve true atomic zero-downtime deployments for Laravel 12 with Nginx and PHP-FPM.
The Problem with Traditional In-Place Deployments
When you run commands like git pull, composer install, or php artisan migrate directly inside your live webroot, you introduce race conditions:
- Inconsistent Code State: Requests arriving midway through a Composer file update may try to execute classes that do not exist yet or have mismatched method signatures.
- Composer Autoload Cache Corruption: While the autoloader map is being generated, incoming requests will fail with fatal
Class not founderrors. - OPcache Stale Bytecode: PHP-FPM caches compiled bytecode in shared memory. Even if files change on disk, PHP-FPM might execute old files until the cache is cleared or expires, causing subtle runtime discrepancies.
The Atomic Symlink Architecture
To eliminate deployment downtime, production deployments isolate each release in its own directory, pre-compile assets and vendor dependencies, and then atomically switch a symbolic link (current) pointing to the active release.
/var/www/sejan.dev/
├── current -> /var/www/sejan.dev/releases/20260825000000
├── shared/
│ ├── .env
│ └── storage/
│ ├── app/
│ ├── framework/
│ └── logs/
└── releases/
├── 20260824120000/
└── 20260825000000/ (Active Release)
Key Benefits of this Structure
- Atomic Switch: The
ln -nfscommand operates as an atomic filesystem operation on POSIX systems. Requests before the switch execute the old release; requests immediately after execute the new release. - Zero In-Flight Interruption: PHP processes already executing requests continue reading from the file handles they opened until completion.
- Instant Rollback: If a fatal regression is detected in production, rolling back is as fast as pointing the symlink back to the previous release folder.
Configuring Nginx & PHP-FPM
Ensure Nginx points directly to the current/public directory, and that realpath caching is properly configured.
server {
listen 80;
listen [::]:80;
server_name sejan.dev www.sejan.dev;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name sejan.dev;
root /var/www/sejan.dev/current/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
include fastcgi_params;
# Realpath resolution prevents OPcache symlink lockup
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Crucial Tip: Notice the use of$realpath_rootinstead of$document_root. This ensures PHP-FPM resolves the canonical path of the file rather than caching symlinked paths in OPcache!
Zero-Downtime Deployment Bash Script
Here is a robust deployment script designed to run on the production server:
#!/usr/bin/env bash
set -e
APP_DIR="/var/www/sejan.dev"
SHARED_DIR="$APP_DIR/shared"
RELEASES_DIR="$APP_DIR/releases"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
echo "==> Starting deployment: $TIMESTAMP"
# 1. Clone repository to release directory
git clone --depth 1 --branch main [email protected]:sejanH/sejan.dev.git "$NEW_RELEASE_DIR"
cd "$NEW_RELEASE_DIR"
# 2. Link shared files and directories (.env and storage)
rm -rf "$NEW_RELEASE_DIR/storage"
ln -nfs "$SHARED_DIR/.env" "$NEW_RELEASE_DIR/.env"
ln -nfs "$SHARED_DIR/storage" "$NEW_RELEASE_DIR/storage"
# 3. Install Composer dependencies (optimizing autoloader)
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction --quiet
# 4. Run database migrations safely
php artisan migrate --force
# 5. Build cache and optimize framework
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 6. Atomic symlink switch
ln -nfs "$NEW_RELEASE_DIR" "$APP_DIR/current_temp"
mv -Tf "$APP_DIR/current_temp" "$APP_DIR/current"
# 7. Reload PHP-FPM & Restart Queue Workers
sudo systemctl reload php8.4-fpm
php artisan queue:restart
# 8. Clean up old releases (keep latest 5)
cd "$RELEASES_DIR"
ls -1dt * | tail -n +6 | xargs -r rm -rf
echo "==> Deployment $TIMESTAMP completed successfully!"
Automating via GitHub Actions
Below is a minimal, secure GitHub Actions workflow that executes the deployment over SSH upon pushing to the main branch:
name: Production Deployment
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup PHP & Run Tests
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
extensions: mbstring, pdo_mysql, bcmath, redis
- run: composer install --prefer-dist --no-progress
- run: php artisan test
- name: Execute Remote Deployment Script
uses: appleboy/[email protected]
with:
host: ${{ secrets.PROD_SERVER_IP }}
username: ${{ secrets.PROD_SERVER_USER }}
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
script: /var/www/sejan.dev/deploy.sh
Managing Horizon & Long-Running Queue Workers
When you update application code, running queue workers in memory still have the old class files loaded. Calling php artisan queue:restart sends a signal to worker processes instructing them to finish processing their active job and gracefully terminate.
Your process supervisor (such as Supervisor or systemd) will immediately start new worker instances, which load the newly deployed code.
Conclusion
By pairing atomic symlink switching with PHP-FPM bytecode reloading and graceful queue worker recycling, you achieve 100% uninterrupted uptime during continuous delivery. Your users will enjoy instant updates without ever encountering a 502 Bad Gateway error.