Tech Verse Logo
Enable dark mode
Zero-Downtime Laravel Deployments That Work

Zero-Downtime Laravel Deployments That Work

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

If your deployment process involves logging into a server, running git pull, and executing php artisan migrate on live code, you are breaking your application every time you ship. During those 10 to 30 seconds, PHP-FPM reads partially copied files, users hit missing assets, and running queue jobs crash because underlying class definitions changed mid-execution.

Achieving a reliable laravel zero downtime deployment requires treating releases as immutable snapshots. You build the new version alongside the current version, prepare everything in isolation, and swap traffic instantly. Here is how to structure this pipeline properly without third-party SaaS tools getting in the way.

The Mechanics of Atomic Symlinks

The core of zero-downtime deployment is the directory structure. Instead of serving your application directly from /var/www/app, you use a releases folder alongside a persistent shared folder and a current symlink pointing to the active build.

/var/www/app/
├── current -> /var/www/app/releases/20260330140000
├── shared/
│   ├── .env
│   └── storage/
└── releases/
    ├── 20260330130000/
    └── 20260330140000/

Your Web server (Nginx or Caddy) points its document root to /var/www/app/current/public. When you deploy, you build inside a brand-new timestamped directory under releases/. Once the release is built, you swap the symlink.

The Symlink Trap: Avoid standard ln -sf

Many deployment scripts use ln -sfn /var/www/app/releases/NEW /var/www/app/current. This is not atomic on Linux systems. Under high traffic, there is a tiny fraction of a millisecond where the target symlink is removed before being recreated. Requests hitting Nginx during that window throw 404 or 500 errors.

To perform an atomic swap on Linux, create a temporary symlink and rename it over the existing target using mv -Tf. The kernel guarantees that the replacement happens atomically at the filesystem level:

ln -sfn /var/www/app/releases/20260330140000 /var/www/app/current_tmp
mv -Tf /var/www/app/current_tmp /var/www/app/current

Handling PHP 8.3 OPcache and Realpath Caches

Flipping the symlink changes the directory on disk, but PHP-FPM won't notice immediately if OPcache and realpath caches are enabled. PHP caches resolved symlink paths in memory to avoid stat system calls. If you flip current, PHP 8.3 will continue loading old scripts from memory or throw missing file exceptions when trying to load un-cached includes.

You must reload PHP-FPM immediately after the symlink flip. A graceful reload keeps active worker processes alive until they finish their current HTTP request, while new processes pick up the fresh code and new symlink target.

# Reload PHP-FPM without dropping active connections
sudo systemctl reload php8.3-fpm

Make sure your Nginx config sets fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; instead of using $document_root. The $realpath_root variable resolves the actual target path of the symlink, ensuring OPcache tags cached files with their explicit release path rather than the dynamic /current/ path.

Deployment Script Execution Order

Order of execution dictates whether your deploy is clean or broken. You must warm up caches and prepare dependencies before pointing traffic to the new directory. Here is a complete Bash script pattern for deploying Laravel 12.

#!/usr/bin/env bash
set -e

RELEASE_DIR="/var/www/app/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/app/shared"

mkdir -p "$RELEASE_DIR"
git clone --depth 1 git@github.com:org/repo.git "$RELEASE_DIR"

# Link shared files and directories
ln -sfn "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -sfn "$SHARED_DIR/storage" "$RELEASE_DIR/storage"

cd "$RELEASE_DIR"

# Production Composer install
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader

# Warm up Laravel caches inside the isolated release directory
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# Run migrations before traffic cutover
php artisan migrate --force

# Atomic symlink swap
ln -sfn "$RELEASE_DIR" /var/www/app/current_tmp
mv -Tf /var/www/app/current_tmp /var/www/app/current

# Reload FPM and restart queue processing
sudo systemctl reload php8.3-fpm
php artisan horizon:terminate

Database Migration Ordering Rules

Database migrations are the hardest part of zero-downtime updates. If Release A is serving traffic while Release B runs a migration that drops a column, active requests from Release A fail instantly with SQL errors.

To maintain absolute uptime, database changes must be non-breaking and deployed across multiple iterations using a two-phase schema update strategy.

Rule 1: Add before remove

Never rename or drop a column in a single release. If you need to rename name to full_name:

  1. Deploy 1: Add full_name as a nullable column. Update code to write to both name and full_name, but read from name.
  2. Data Migration: Backfill existing data from name to full_name in a background job.
  3. Deploy 2: Update code to read and write exclusively from full_name.
  4. Deploy 3: Drop the original name column in a dedicated migration.

Rule 2: Adding required columns requires defaults

If you add a NOT NULL column without a default value, queries executed by old code versions attempting to INSERT records without specifying that new field will fail. Always provide a default value in the migration or set the column as nullable during the transition deploy.

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Safe: Nullable column doesn't break old INSERT statements
            $table->string('avatar_path')->nullable();
            
            // Safe: Default value prevents missing key failures on insert
            $table->string('locale')->default('en');
        });
    }
};

Handling Queue Workers and Horizon

Laravel queue workers execute inside long-running CLI PHP processes. They load application code into memory once when booted. When you push a new release, running workers keep executing old code pulled into RAM, even though disk files changed.

Running php artisan queue:restart or php artisan horizon:terminate instructs workers to finish their current job and exit gracefully. Supervisor or systemd then immediately starts a fresh worker process, which boots off the newly symlinked code.

The Cache Key Requirement

The queue:restart command works by writing a timestamp into your application's primary cache store. Queue workers check this cache value after processing every job. If the timestamp is newer than the worker's boot time, the worker exits.

This means your queue workers and web deployment pipeline must share the same cache driver instance (e.g., Redis). If your web app uses a local file cache and your queue daemon reads from a separate store, running queue:restart on the deploy server won't trigger worker resets elsewhere.

If you run Laravel Horizon, invoke php artisan horizon:terminate instead. Horizon sends a SIGTERM to its master supervisor, allowing child workers up to the configured timeout value in config/horizon.php to finish running jobs before shutting down cleanly.

Frontend Sync with Next.js 16

If you run a decoupled frontend using Next.js 16 communicating with a Laravel 12 API backend, keep build artifacts isolated. Deploy your Laravel API updates first using non-breaking contract changes, then deploy your frontend application. Avoid breaking API payload signatures so that legacy client sessions or cached static assets on Next.js continue functioning during deployment transitions.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Structuring a Laravel and Next.js Monorepo

    Structuring a Laravel and Next.js Monorepo

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Laravel API Versioning: URI vs Header Strategies

    Laravel API Versioning: URI vs Header Strategies

    Server Components vs Client Components: Boundary Rules

    Server Components vs Client Components: Boundary Rules

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Prevent Data Leaks in Laravel API Resources

    Prevent Data Leaks in Laravel API Resources

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Laravel S3 Signed URLs and Private Filesystem Storage

    Laravel S3 Signed URLs and Private Filesystem Storage