Article URL Copied to Clipboard!
Home Blog Cloud & DevOps Zero-Downtime Deployments for Laravel Ap...
Cloud & DevOps

Zero-Downtime Deployments for Laravel Apps: A Practical Walkthrough

If your Laravel deploy starts with php artisan down, your users see a 503 maintenance page every time you ship. Laravel's own documentation on maintenance mode says it plainly: maintenance...

codesalen
codesalen
September 30th 2026 • 9 mins, 1633 words
Zero-Downtime Deployments for Laravel Apps: A Practical Walkthrough

If your Laravel deploy starts with php artisan down, your users see a 503 maintenance page every time you ship. Laravel's own documentation on maintenance mode says it plainly: maintenance mode requires your application to have several seconds of downtime. The alternative is zero-downtime deployment for your Laravel app, and on an ordinary Linux server it takes less work than most teams expect.

This walkthrough covers the pattern we use, the deploy script, the web server detail that trips people up, and the two parts that need real thought: database migrations and queue workers.

Why in-place deploys break requests

The common setup is a single folder on the server. A deploy runs git pull, then composer install, then migrations and cache commands, all inside the folder that is serving live traffic. For the minute or two that takes, requests hit a mix of old and new files. A controller from the new commit might load before the vendor folder it needs has been installed. A cached config file might point at a class that no longer exists. Queue workers keep running old code in memory until someone restarts them.

Most of the time, nothing visible happens. Sometimes a customer at checkout gets a 500 error. You cannot tell in advance which deploy will be the unlucky one.

The pattern behind zero-downtime deployment in Laravel

The fix is to never change the live folder. Each deploy builds a complete new copy of the app in its own directory, and only when that copy is fully ready does a single symlink switch point the web server at it:

/var/www/shop
  current -> releases/20260929-101500
  releases/
    20260928-163000/
    20260929-101500/
  shared/
    .env
    storage/

The shared folder holds what must survive across releases: the .env file and the storage directory with uploads, logs and sessions. Each release links to them. Deployer's Laravel recipe and Laravel Forge's zero-downtime option both work this way. Forge's deployment documentation describes it as cloning new code into a releases directory and activating it with a symbolic link once every step has succeeded, and it keeps the last four releases by default for quick rollbacks.

A deploy script you can adapt

Here is a trimmed version of the script we would start from. It assumes one server, Nginx, PHP-FPM, and a process manager such as Supervisor running your queue workers.

# deploy.sh (run with bash)
set -euo pipefail

APP=/var/www/shop
RELEASE="$APP/releases/$(date +%Y%m%d-%H%M%S)"
PREVIOUS="$(readlink -f "$APP/current" || true)"

git clone --depth 1 --branch main git@github.com:acme/shop.git "$RELEASE"
cd "$RELEASE"

rm -rf storage
ln -s "$APP/shared/storage" storage
ln -s "$APP/shared/.env" .env

composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan storage:link

php artisan optimize
php artisan migrate --force

# Switch traffic in one atomic step
ln -sfn "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

if curl -fsS --max-time 10 https://shop.example.com/up > /dev/null; then
  php artisan reload   # older versions: php artisan queue:restart
else
  echo "Health check failed, rolling back"
  ln -sfn "$PREVIOUS" "$APP/current_tmp"
  mv -Tf "$APP/current_tmp" "$APP/current"
  exit 1
fi

# Keep the five newest releases
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf

A few details matter more than they look:

  • set -euo pipefail stops the script at the first failing command. If Composer, the build or a migration fails, the script exits before the switch and users keep getting the old release. (A migration that fails halfway can still leave the database changed, which is one more reason for the migration rule below.)
  • mv -Tf after ln -sfn. Depending on the version, ln -sfn on the live link may remove it before creating the new one, which leaves a tiny gap. Creating the link under a temporary name and renaming it over current is a single atomic rename whatever ln your server has, so every request sees either the old release or the new one.
  • php artisan optimize runs before the switch, inside the new release, so config, route, event and view caches are ready before any user reaches it. Laravel's deployment documentation warns that once config is cached, env() calls outside your config files return null, so check for those first.
  • /up is Laravel's built-in health route. It returns 200 if the application boots without exceptions and 500 otherwise. Add your own checks, such as a database query, through the DiagnosingHealth event if you need more. Workers are restarted only after the check passes, so a failed release never reaches the queue.
  • Building assets on the server with npm run build is the simple option. A cleaner one is to build in CI and upload a finished artifact, which is how the CI/CD pipeline for small teams we described earlier would hand over to this script.

The Nginx line that makes or breaks it

The web server root points at the symlink, but PHP must receive the resolved path of the new release:

root /var/www/shop/current/public;

location ~ ^/index\.php(/|$) {
    fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    include fastcgi_params;
}

$realpath_root makes Nginx resolve the current symlink on each request and pass the real release path to PHP-FPM. Laravel's recommended Nginx configuration already uses it. If yours uses $document_root instead, PHP can keep serving files from the old release after the switch, because PHP caches resolved paths. With $realpath_root, each release is a new path, which is why Forge's documentation says reloading PHP-FPM is unnecessary with zero-downtime deployments. If you ever do see old code after a deploy, check this line first.

Database migrations: expand, then contract

This is the part no tool can do for you. During the switch, and for as long as old queued jobs are still being processed, old code runs against the new database schema. So every migration must work with both the old code and the new code.

Say you want to rename customers.phone to customers.mobile. A single migration that renames the column breaks every request still running old code. Instead, split it across releases:

// Release 1: expand. Old code ignores the new column.
Schema::table('customers', function (Blueprint $table) {
    $table->string('mobile')->nullable();
});
  1. Release 1: add the new nullable column. Code writes to both columns and reads mobile, falling back to phone.
  2. Between releases: backfill mobile from phone in batches, using a queued job or an Artisan command rather than one huge update that locks a large table.
  3. Release 2: code reads and writes only mobile.
  4. Release 3: drop phone, once nothing uses it.

It is slower than one migration, but every step is safe to deploy and safe to roll back. The same thinking applies to adding a non-nullable column (add it nullable or with a default first) and to dropping tables (stop using them one release before removing them). On large tables, test how long a schema change takes on a copy of production data before it runs for real.

Queue workers, scheduler and cache

Queue workers are long-running processes that keep the old code in memory, so they must be restarted after the switch. On recent Laravel versions, php artisan reload restarts queue workers and other long-running services such as Reverb and Octane in one command. On older versions, php artisan queue:restart tells each worker to exit after finishing its current job, and php artisan horizon:terminate does the same for Horizon. Three things make this work:

  • Supervisor must start workers with php /var/www/shop/current/artisan queue:work, so restarted workers pick up the new release.
  • The restart signal is stored in the cache, so a working cache driver is required. With several servers, it must be a shared cache such as Redis.
  • Supervisor's stopwaitsecs should be longer than your longest job, or Supervisor may kill a job halfway through.

Jobs queued by the old release may be processed by the new one. If you change a job's constructor, keep the new version able to handle payloads from the old one for at least one release. Point the scheduler's cron entry at current as well, so schedule:run always uses the live release.

Rolling back, and when a tool is the better choice

With this layout, rollback means pointing current back at the previous release and restarting workers. It takes seconds. It is only safe because the migrations were backward compatible. Rolling back code over a schema it does not understand just moves the outage.

You do not have to maintain this script yourself. Deployer is an open-source PHP tool with a Laravel recipe that implements this pattern, and Envoyer and Forge do it as hosted services. One caution from Forge's documentation: its zero-downtime option has to be chosen when a site is created and cannot be added to an existing site later, and it should not be combined with Octane, which handles graceful restarts itself.

And sometimes you do not need any of it. An internal tool used only during office hours can be deployed at night with maintenance mode, and nobody will notice. Zero-downtime deployment earns its keep on customer-facing apps, anything with payments, and teams that ship several times a week.

If your team deploys a Laravel app by hand today, start with the folder layout and the script above on a staging server, and move production over once a few staging deploys have gone smoothly. If you would like help, our Laravel developers can review your migrations and job classes for deploy safety, and our DevOps engineers can set up the server side, from Nginx and Supervisor to the pipeline that runs the script. You can also describe your current deploy process and we will tell you what we would change first.

Author

codesalen

Senior Software Architect & Solutions Lead

Passionate about enterprise web systems, high-scale cloud infrastructure, and modern AI engineering. Leading technical innovations at Codesalen.

💬 Comments (0)

Be the first to share your thoughts on this article!

Leave a Comment

Share this article:
Need Help? Ask CodeSalen AI Online ×

CodeSalen AI

Active • Solutions Advisor
Call Solutions Lead

Search CodeSalen