Releasing Without Downtime

Switch releases atomically, restart the workers holding old code in memory, ship migrations both releases survive, and test the rollback.

Syntaxphp artisan reload # queue:restart + schedule:interrupt

Downtime during a release comes from three places: the code changing underneath a request already running, the workers still holding the previous release in memory, and a migration only the new code understands.

An atomic release

Build the release somewhere else, then switch to it in one step. On a plain server that means a new timestamped directory, composer install and php artisan optimize inside it, then repointing a current symlink and reloading PHP-FPM so OPcache stops serving the old files. On Kubernetes the equivalent is a new image tag and a rolling update. Either way a request is served entirely by one release or entirely by the other, never half of each.

Restarting the workers

A queue worker boots the application once and keeps it in memory, so it runs the code it started with until it exits. php artisan queue:restart writes a restart signal into the cache; each worker reads it between jobs, finishes the job in hand and exits, and the process manager starts a replacement. php artisan reload, added in Laravel 12.41, is the shorthand for queue:restart plus schedule:interrupt, plus whatever packages add to that list, such as Octane's octane:reload. --except=queue drops one of them.

Migrations both releases survive

During a rolling release both versions run at once. So add nullable columns, never rename or drop in the same release as the code change, backfill as a separate step, and remove the old column one release later. A rollback redeploys the previous image; it does not undo the migration. That is exactly why the migration had to be compatible.

Example

Example · php
<?php
// database/migrations/2026_10_04_091500_add_display_name_to_users_table.php
// Generated with:
// php artisan make:migration add_display_name_to_users_table

declare(strict_types=1);

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

/*
 * A migration is a PHP class describing one schema change, with up() to
 * apply it and down() to undo it. Since Laravel 9 they are returned as
 * anonymous classes, so the file name is the only identifier.
 *
 * This one is the "expand" half of an expand-and-contract change. It is
 * additive and nullable, so the release currently serving traffic keeps
 * working while the rollout finishes.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('display_name')->nullable();
        });
    }

    public function down(): void
    {
        // Tested, because an untested down() is not a rollback plan.
        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn('display_name');
        });
    }
};

When to use it

  • Shipping a schema change to a site with four web pods and no maintenance window.
  • Explaining why a queue worker keeps running code you deleted an hour ago.
  • Adding a rollback step to a pipeline that currently has none.
  • Renaming a column without taking the application down to do it.

More examples

The backfill, as its own step

Backfilling inside the migration locks the table for as long as the update takes, and a deploy that waits on it is a deploy with downtime. As a command it is restartable, chunked, and can be run after the rollout finishes. The contract migration that finally drops the old column belongs in a later release, once this has completed and nothing reads the old column.

Example · php
<?php
// app/Console/Commands/BackfillDisplayNames.php
// Generated with: php artisan make:command BackfillDisplayNames

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;

class BackfillDisplayNames extends Command
{
    protected $signature = 'app:backfill-display-names {--chunk=500}';

    protected $description = 'Fill display_name for rows written before it existed';

    public function handle(): int
    {
        User::query()
            ->whereNull('display_name')
            ->chunkById((int) $this->option('chunk'), function (Collection $users): void {
                foreach ($users as $user) {
                    $user->update(['display_name' => $user->name]);
                }
            });

        return self::SUCCESS;
    }
}

An atomic release on one server

The two symlinks into shared/ are what keeps .env and uploaded files out of each release directory. The reload of PHP-FPM is the step people forget: the new code is on disk and the symlink points at it, but the running pool still has the old files compiled in memory. Keep the previous release directories; the rollback is a second ln -sfn.

Example · shell
set -e
RELEASE=/srv/shop/releases/$(date +%Y%m%d%H%M%S)

git clone --depth 1 [email protected]:shop.git "$RELEASE"
ln -s /srv/shop/shared/.env "$RELEASE/.env"
ln -s /srv/shop/shared/storage "$RELEASE/storage"

cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan optimize

# The switch. ln -sfn writes the new symlink in one syscall, so no
# request ever sees a missing current/ directory.
ln -sfn "$RELEASE" /srv/shop/current

# OPcache keys on file paths, so the pool must be told to look again.
sudo systemctl reload php8.5-fpm

# Workers and the scheduler are still running the old release.
cd /srv/shop/current && php artisan reload

A rollback you have actually run

Rolling the code back is quick and safe. Rolling the schema back is neither, because down() often drops a column and the data in it is gone. This is the argument for expand-and-contract: if every migration is additive, a rollback is only the first three lines, and migrate:rollback stays unused.

Example · shell
# Kubernetes: back to the previous ReplicaSet.
kubectl rollout undo deploy/shop-web
kubectl rollout undo deploy/shop-worker
kubectl rollout status deploy/shop-web --timeout=300s

# Symlink deploy: back to the previous directory.
ln -sfn /srv/shop/releases/20261003T2112 /srv/shop/current
sudo systemctl reload php8.5-fpm
php artisan reload

# The schema does NOT come back with the code. Only run this if the
# migration is the thing that broke, and only after reading down().
php artisan migrate:rollback --step=1

# Rehearse it on staging, including the part where it is the wrong call.
php artisan migrate:status

Discussion

  • Be the first to comment on this lesson.