---
title: 'Zero-downtime deployment for Laravel: atomic releases, migrations, and queues | DevSense'
description: 'Deploy Laravel without downtime: symlink releases, OPcache and realpath, CI-built assets, expand/contract PostgreSQL migrations, and graceful queue restarts.'
faq:
    - { question: 'Why is php artisan down not suitable for zero-downtime deployment?', answer: 'The command puts the application into maintenance mode and returns 503 to every user while dependencies are installed, migrations run, and caches warm up. That is honest, but it is still planned downtime. On top of that, if the script fails after down and before up, the site stays unavailable until someone intervenes manually.' }
    - { question: 'What is an atomic symlink release?', answer: 'Each version of the code is built in its own releases/<id> directory, and the web server points at a symbolic link called current. Once the new version is fully ready (dependencies, caches, assets), the link is switched with a single atomic mv -T operation. Requests go either to the old version or to the new one, but never to a half-updated one.' }
    - { question: 'How do you run migrations so that old and new code can work at the same time?', answer: 'Follow the expand/contract pattern: first only add things (new columns, tables, indexes built CONCURRENTLY), then ship code that writes to both places, and only remove the old structure in a later release. Renaming or dropping a column in the same release that changes the code breaks queries from old workers.' }
    - { question: 'How do you restart queue workers without losing jobs?', answer: 'Use php artisan queue:restart (or horizon:terminate for Horizon). Workers finish their current job and exit, and Supervisor starts them again on the new code. It is important that serialized jobs dispatched by the old code remain compatible with the new code.' }
published: '2026-09-28'
---
# Zero-downtime deployment for Laravel: atomic releases, migrations, and queues

A deploy that "usually works" is more dangerous than one that fails right away. Recently our own deploy failed on `git fetch`: the credentials for a private repository had expired. The site survived only because `git fetch` ran **before** `php artisan down`. Had it been a couple of lines lower, the site would have been stuck in maintenance mode all night. Most Laravel projects deploy exactly like this: `down`, `git pull`, `composer install`, `migrate`, `up`. It works until the first failure halfway through, and it shows users a 503 every single time.

**Related guides:** [Environment, CI, and deployment with Sail](../tools/sail-env-deploy) · [Message queues compared](message-queues-compared) · [Observability and monitoring](observability-monitoring-laravel)

## Contents

* [Where deployment downtime comes from](#where-downtime-comes-from)
* [The core idea: build alongside, switch atomically](#core-idea)
* [Atomic releases: releases, shared, and current](#atomic-releases)
* [PHP-FPM, OPcache, and realpath: why the symlink "doesn't switch"](#opcache)
* [Assets: building in CI and stale browser tabs](#assets)
* [Lock-free migrations: expand/contract](#migrations)
* [Queues and the scheduler: graceful restarts](#queues)
* [Health checks and rollback](#health-and-rollback)
* [Where this approach stops working](#limitations)
* [Common Mistakes](#common-mistakes)
* [Checklist](#checklist)
* [Self-Test Quiz](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## Where deployment downtime comes from

Downtime rarely looks like "the server is off." More often it is a few seconds or minutes during which the application is in an intermediate state:

* **`composer install` on top of live code.** While packages in `vendor/` are being overwritten, concurrent requests load a mix of old and new classes. The result is `Class not found` or fatal errors from incompatible signatures.
* **A config cache from a different version.** `bootstrap/cache/config.php` was built by the old code, while routes or service providers are already new. Until `php artisan optimize` runs, the application lives with the mismatch.
* **Migrations that take locks.** `ALTER TABLE` on a large table acquires an exclusive lock, and every query against that table queues up behind it. To the user, the site simply looks frozen.
* **Workers running old code.** Supervisor keeps `queue:work` processes that were loaded before the deploy. They keep executing jobs with old classes against the new database schema.
* **Assets from the wrong version.** `public/build/manifest.json` was updated before or after the code, and the page references CSS and JS that don't exist yet (or no longer exist).

Maintenance mode hides all of these problems behind a 503. But that isn't a solution; it's an honest admission of downtime.

---

<a id="core-idea"></a>
## The core idea: build alongside, switch atomically

**Zero-downtime deployment means the new version is fully built alongside the old one, the switchover is a single atomic operation, and the database schema is compatible with both the old and the new code at every moment.**

Three rules follow from this:

1. Never change anything "in place" in the directory that is currently serving requests.
2. The switchover is one operation with no intermediate state.
3. Migrations and queued jobs are designed so that two versions of the code can coexist.

---

<a id="atomic-releases"></a>
## Atomic releases: releases, shared, and current

The layout on the server:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← new release
├── shared/
│   ├── .env
│   └── storage/                  ← logs, uploads, sessions
└── current -> releases/20260927-2101-1d94729
```

Each release is a separate directory containing the full codebase. Everything that must survive across releases (`.env`, `storage/`) lives in `shared/` and is linked in with symlinks. The web server points at `current/public`.

```bash
#!/usr/bin/env bash
# deploy/release.sh
set -euo pipefail

APP=/var/www/app

# 1. New release code goes into its own directory; the live site is untouched
git -C "$APP/repo" fetch origin
RELEASE="$APP/releases/$(date +%Y%m%d-%H%M)-$(git -C "$APP/repo" rev-parse --short origin/prod)"
mkdir -p "$RELEASE"
git -C "$APP/repo" archive origin/prod | tar -x -C "$RELEASE"

# 2. Shared files
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Dependencies and caches are built before the switch
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Migrations: only ones compatible with the current code (see expand/contract)
php artisan migrate --force

# 5. Atomic switch: rename(2) has no intermediate state
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Reset OPcache and gracefully restart workers
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Keep the last 5 releases for rollback
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` is not atomic.** Under the hood it is two system calls: remove the old link and create a new one. In between there is a moment when `current` doesn't exist, and nginx returns 404. Only `rename(2)` is atomic, which means `mv -T` over the existing link.

If you'd rather not set this up by hand, Deployer (`deployer/deployer`) and Laravel Envoyer implement the same approach.

---

<a id="opcache"></a>
## PHP-FPM, OPcache, and realpath: why the symlink "doesn't switch"

A common complaint: "we switched the link, but the site still shows the old version." The cause is two caches:

* PHP's **realpath cache** remembers what the path `/var/www/app/current/...` resolves to;
* **OPcache** stores compiled files keyed by the resolved path. Production usually runs with `opcache.validate_timestamps=0`, so PHP never even checks whether files have changed.

The fix has two parts. First, nginx must pass PHP the already-resolved path rather than the path through the symlink:

```nginx
# /etc/nginx/sites-available/app.conf
root /var/www/app/current/public;

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT   $realpath_root;
    fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
```

`$realpath_root` resolves the symlink on every request. New requests arrive with a `releases/20260928-...` path, and OPcache compiles them as new files. Second, after the switch you need a PHP-FPM `reload` (it's graceful: in-flight requests finish) or `opcache_reset()` via `cachetool` to free the memory held by the old release.

---

<a id="assets"></a>
## Assets: building in CI and stale browser tabs

Building the frontend on the production server adds load and an extra dependency (Node.js). It's better to build in CI and upload the result into the **new release's** directory before the switch:

```yaml
# .github/workflows/deploy-prod.yml (excerpt)
- name: Build assets
  run: npm ci && npm run build

- name: Upload assets into the new release
  run: rsync -az --delete public/build/ "$SERVER:$RELEASE/public/build/"
```

Vite adds a hash to file names (`app-TDO2OnyO.js`), and `manifest.json` lives inside the release. So the old code references the old files, the new code references the new ones, and switching the symlink swaps them together with the PHP code.

One subtlety remains: a user may have a tab open with an old page that, a minute later, requests a lazily loaded chunk. If that chunk is already gone, they get a module load error. So keep several previous releases on disk, or upload assets to a shared CDN bucket without deleting old hashes.

---

<a id="migrations"></a>
## Lock-free migrations: expand/contract

Migrations run **before** the switch, which means that for a while the old code runs against the new schema. Hence the rule: every migration must be compatible with the code from the previous release. To achieve that, schema changes are split into phases.

**Example: renaming `users.name` to `users.full_name`.**

| Release | Schema | Code |
|---|---|---|
| 1. Expand | Add `full_name` (nullable) | Writes to both columns, reads `name` |
| 2. Migrate | A background job copies the data | Reads `full_name`, writes to both |
| 3. Contract | Drop `name` | Works only with `full_name` |

You can't do this in a single release with `renameColumn`: old workers will fail on a column that no longer exists.

With PostgreSQL, it also matters **which locks a migration takes**:

```php
// database/migrations/2026_09_28_000000_add_status_index_to_orders.php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    // CREATE INDEX CONCURRENTLY cannot run inside a transaction
    public $withinTransaction = false;

    public function up(): void
    {
        DB::statement('SET lock_timeout = \'5s\'');
        DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_status_idx ON orders (status)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_status_idx');
    }
};
```

What matters here:

* **`CONCURRENTLY`** builds the index without blocking writes. A plain `$table->index()` locks the table for the entire build.
* **`lock_timeout`** keeps the migration from waiting for a lock indefinitely. Otherwise every other query against the table queues up behind it. It's better to fail after 5 seconds and retry than to hang the site.
* **A column with a `DEFAULT`** is added instantly in PostgreSQL 11+, but `NOT NULL` on an existing column requires a full table scan. The safe path: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, then `VALIDATE CONSTRAINT`, then `SET NOT NULL` (since PostgreSQL 12 it uses the already-validated constraint and skips the scan).

> [!WARNING]
> **Rolling back code does not roll back the schema.** Running `migrate:rollback` in production with real data is almost always worse than a new "forward" migration. Expand/contract is exactly what makes a code rollback safe: the previous version keeps working with the expanded schema.

---

<a id="queues"></a>
## Queues and the scheduler: graceful restarts

A `queue:work` worker is a long-running process that loaded the code once. After the release switch, it keeps executing jobs with the old code until it's restarted.

```bash
php artisan queue:restart      # for queue:work under Supervisor
php artisan horizon:terminate  # for Horizon
```

Both commands are graceful: the worker finishes its current job and exits, and Supervisor starts a new process from `current`. No jobs are lost.

The second problem is **job compatibility**. A job dispatched by the old code is serialized with that code's set of properties. If the new code renamed a property or added a required constructor argument, deserialization will break:

```php
// app/Jobs/SendInvoice.php
final class SendInvoice implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly int $orderId,
        // New field with a default value so that jobs
        // from the old release deserialize successfully
        public readonly string $locale = 'en',
    ) {}
}
```

The scheduler (`schedule:run` from cron) doesn't need a restart: cron starts a fresh process every minute, and it already sees the updated `current`.

---

<a id="health-and-rollback"></a>
## Health checks and rollback

The switch doesn't end the deploy. You need to confirm the new version is alive:

```bash
# After the switch: the Laravel 11+ health route (bootstrap/app.php → health: '/up')
for i in {1..10}; do
  curl -fsS --max-time 5 https://example.com/up > /dev/null && exit 0
  sleep 3
done

# It didn't come up: restore the previous release with the same atomic switch
PREV=$(ls -1dt /var/www/app/releases/* | sed -n 2p)
ln -s "$PREV" /var/www/app/current_tmp && mv -Tf /var/www/app/current_tmp /var/www/app/current
sudo systemctl reload php8.5-fpm
exit 1
```

Rollback takes seconds because the previous release is still on disk in full, with `vendor/` and built assets. The database schema, however, stays new, which is exactly why it has to be backward compatible.

---

<a id="limitations"></a>
## Where this approach stops working

* **A single server is not high availability.** The symlink removes downtime during deploys, not when the server goes down. For that you need multiple nodes behind a load balancer and rolling or blue-green deployments.
* **Some migrations can't be done online.** Changing a column type with a table rewrite, splitting a large table, moving data between databases. Sometimes an honest maintenance window at night is cheaper than a week-long expand/contract sequence.
* **Sessions and cache.** If the new version changes the format of session data or cache keys, users with old sessions will hit errors. Version your cache prefixes and don't change the session format without backward compatibility.
* **Complexity.** For a pet project with ten visitors an hour, `down`/`up` with an ERR trap can be a reasonable trade-off. The key is to choose it deliberately, and to make sure a script failure never leaves the site in maintenance mode.

---

<a id="common-mistakes"></a>
## Common Mistakes

**1. Running `git pull` and `composer install` in the directory that serves requests.**
Concurrent requests see a mix of versions. Build the release in a separate directory.

**2. Using `ln -sfn` for the switch.**
It's not atomic. Use `ln -s` to a temporary name and then `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` in nginx.**
PHP caches the path through the symlink and keeps serving the old code. Use `$realpath_root`.

**4. Renaming a column in the same release as the code change.**
Old workers and in-flight requests will fail at the moment of the switch. Split it into expand/contract.

**5. `$table->index()` on a large table.**
It blocks writes for the entire build. Use `CREATE INDEX CONCURRENTLY` and `$withinTransaction = false`.

**6. Forgetting `queue:restart`.**
Workers spend weeks executing jobs with old code against the new schema.

**7. `php artisan down` with no guarantee of `up`.**
If the script fails halfway through, the site stays in maintenance mode. At the very least, add `trap 'php artisan up' ERR`.

---

<a id="checklist"></a>
## Checklist

1. The new release's code, dependencies, caches, and assets are prepared in a separate directory.
2. `.env` and `storage/` live in `shared/`.
3. The switch is an `mv -T` of the symlink, and nginx uses `$realpath_root`.
4. After the switch: PHP-FPM `reload` and `queue:restart` (or `horizon:terminate`).
5. Every migration is compatible with the previous release's code; indexes are built `CONCURRENTLY`; `lock_timeout` is set.
6. New job arguments have default values.
7. After the switch, `/up` is checked, and on failure the previous release is restored automatically.
8. The last several releases are kept on disk.

---

## Summary

Zero-downtime deployment isn't a tool; it's a property of the system: the code can live in two versions at once. Symlinks, `$realpath_root`, and `queue:restart` take care of the switchover mechanics. The real work, though, is the discipline around migrations and job compatibility, which turns a rollback into a boring two-second operation instead of a late-night incident.

---

<a id="self-test-quiz"></a>
## Self-Test Quiz

### Question 1: Why isn't `ln -sfn new current` suitable for an atomic release switch?
- A) The command doesn't work with absolute paths.
- B) It makes two system calls (unlink and symlink), and between them the `current` link doesn't exist.
- C) It copies the release files instead of creating a link.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
`ln -sfn` removes the old link and creates a new one. In between, the web server can receive a request and fail to find `current`. Only `rename(2)` is atomic, so the new link is created under a temporary name and renamed with `mv -T`.
</details>

### Question 2: You need to rename a column that queue workers use. Which approach is safe for a zero-downtime deploy?
- A) A single migration with `renameColumn` and a simultaneous code update.
- B) Add the new column, ship code that writes to both, migrate the data, and drop the old column in a separate release.
- C) Put the site into maintenance mode just for the duration of the migration.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
The expand/contract pattern guarantees that at any moment both the old and the new code find the columns they need. Option C is no longer zero-downtime, and option A breaks workers and requests running on the old release.
</details>

### Question 3: Why does a migration with `CREATE INDEX CONCURRENTLY` declare `public $withinTransaction = false;`?
- A) To make the migration run faster.
- B) PostgreSQL forbids `CREATE INDEX CONCURRENTLY` inside a transaction block, and Laravel wraps migrations in a transaction by default.
- C) So that the index is created on all replicas at the same time.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
A concurrent index build consists of several phases, each in its own transaction, so it can't run inside `BEGIN ... COMMIT`. The `$withinTransaction = false` property disables Laravel's transaction wrapper for that specific migration.
</details>