---
title: 'Zero-Downtime-Deployment für Laravel: atomare Releases, Migrationen und Queues | DevSense'
description: 'Wie Sie Laravel ohne Ausfallzeit ausrollen: Releases per Symlink, OPcache und realpath, Asset-Build in der CI, sichere PostgreSQL-Migrationen nach dem Expand/Contract-Muster und ein sanfter Neustart der Queue-Worker.'
faq:
    - { question: 'Warum eignet sich php artisan down nicht für ein Deployment ohne Ausfallzeit?', answer: 'Der Befehl versetzt die Anwendung in den Wartungsmodus und liefert allen Nutzern einen 503, solange Abhängigkeiten installiert, Migrationen ausgeführt und Caches aufgewärmt werden. Das ist eine ehrliche, aber geplante Ausfallzeit. Bricht das Skript außerdem nach down und vor up ab, bleibt die Seite bis zu einem manuellen Eingriff nicht erreichbar.' }
    - { question: 'Was ist ein atomares Release per Symlink?', answer: 'Jede Codeversion wird in einem eigenen Verzeichnis releases/<id> gebaut, und der Webserver zeigt auf den symbolischen Link current. Sobald die neue Version vollständig bereit ist (Abhängigkeiten, Caches, Assets), wird der Link mit einer einzigen atomaren Operation mv -T umgestellt. Requests landen entweder in der alten oder in der neuen Version, aber nie in einer halb aktualisierten.' }
    - { question: 'Wie führt man Migrationen so aus, dass alter und neuer Code gleichzeitig funktionieren?', answer: 'Nach dem Expand/Contract-Muster: Zuerst nur hinzufügen (neue Spalten, Tabellen, Indizes mit CONCURRENTLY), dann Code ausrollen, der an beide Stellen schreibt, und erst im nächsten Release das Alte entfernen. Wer eine Spalte im selben Release umbenennt oder löscht, in dem sich auch der Code ändert, bricht die Queries der alten Worker.' }
    - { question: 'Wie startet man Queue-Worker neu, ohne Jobs zu verlieren?', answer: 'Mit php artisan queue:restart (bzw. horizon:terminate für Horizon). Die Worker arbeiten den aktuellen Job zu Ende, beenden sich, und Supervisor startet sie mit dem neuen Code neu. Wichtig ist, dass serialisierte Jobs, die der alte Code eingereiht hat, mit dem neuen kompatibel bleiben.' }
published: '2026-09-28'
---
# Zero-Downtime-Deployment für Laravel: atomare Releases, Migrationen und Queues

Ein Deployment, das „meistens durchläuft“, ist gefährlicher als eines, das sofort scheitert. Vor Kurzem ist unser eigenes Deployment bei `git fetch` abgebrochen: Die Zugangsdaten für das private Repository waren abgelaufen. Die Seite hat nur überlebt, weil `git fetch` **vor** `php artisan down` stand. Wäre es ein paar Zeilen weiter unten gewesen, hätte die Seite die ganze Nacht im Wartungsmodus festgesteckt. Die meisten Laravel-Projekte deployen genau so: `down`, `git pull`, `composer install`, `migrate`, `up`. Das funktioniert bis zum ersten Fehler mittendrin und zeigt den Nutzern jedes Mal einen 503.

**Verwandte Leitfäden:** [Umgebung, CI und Deployment mit Sail](../tools/sail-env-deploy) · [Message Queues im Vergleich](message-queues-compared) · [Observability und Monitoring](observability-monitoring-laravel)

## Inhalt

* [Woher die Ausfallzeit beim Deployment kommt](#where-downtime-comes-from)
* [Die Grundidee: daneben vorbereiten, atomar umschalten](#core-idea)
* [Atomare Releases: releases, shared und current](#atomic-releases)
* [PHP-FPM, OPcache und realpath: warum der Symlink „nicht umschaltet“](#opcache)
* [Assets: Build in der CI und alte Tabs](#assets)
* [Migrationen ohne Sperren: Expand/Contract](#migrations)
* [Queues und Scheduler: sanfter Neustart](#queues)
* [Health-Check und Rollback](#health-and-rollback)
* [Wo der Ansatz an seine Grenzen stößt](#limitations)
* [Häufige Fehler](#common-mistakes)
* [Checkliste](#checklist)
* [Selbsttest-Quiz](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## Woher die Ausfallzeit beim Deployment kommt

Eine Ausfallzeit sieht selten so aus, als sei „der Server aus“. Meist sind es einige Sekunden oder Minuten, in denen sich die Anwendung in einem Zwischenzustand befindet:

* **`composer install` über laufendem Code.** Während die Pakete in `vendor/` überschrieben werden, laden parallele Requests eine Mischung aus alten und neuen Klassen. Das Ergebnis: `Class not found` oder fatale Fehler wegen inkompatibler Signaturen.
* **Konfigurations-Cache einer anderen Version.** `bootstrap/cache/config.php` wurde vom alten Code erzeugt, Routen oder Service Provider sind aber schon neu. Bis `php artisan optimize` gelaufen ist, lebt die Anwendung mit diesem Versatz.
* **Migrationen mit Sperren.** `ALTER TABLE` auf einer großen Tabelle nimmt eine exklusive Sperre, und alle Queries auf diese Tabelle stauen sich. Für den Nutzer sieht das aus wie eine hängende Seite.
* **Worker auf altem Code.** Supervisor hält `queue:work`-Prozesse am Leben, die vor dem Deployment geladen wurden. Sie führen weiterhin Jobs mit den alten Klassen gegen das neue Datenbankschema aus.
* **Assets in der falschen Version.** `public/build/manifest.json` wurde früher oder später als der Code aktualisiert, und die Seite verweist auf CSS und JS, die es noch nicht (oder nicht mehr) gibt.

Der Wartungsmodus kaschiert all diese Probleme, indem er einen 503 anzeigt. Das ist aber keine Lösung, sondern ein ehrliches Eingeständnis der Ausfallzeit.

---

<a id="core-idea"></a>
## Die Grundidee: daneben vorbereiten, atomar umschalten

**Ein Deployment ohne Ausfallzeit bedeutet: Die neue Version wird vollständig neben der alten gebaut, das Umschalten ist eine einzige atomare Operation, und das Datenbankschema ist zu jedem Zeitpunkt sowohl mit dem alten als auch mit dem neuen Code kompatibel.**

Daraus ergeben sich drei Regeln:

1. Nichts „an Ort und Stelle“ in dem Verzeichnis ändern, aus dem gerade Requests bedient werden.
2. Das Umschalten ist eine einzige Operation ohne Zwischenzustand.
3. Migrationen und Queue-Jobs werden so entworfen, dass zwei Codeversionen gleichzeitig existieren können.

---

<a id="atomic-releases"></a>
## Atomare Releases: releases, shared und current

Die Struktur auf dem Server:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← neues Release
├── shared/
│   ├── .env
│   └── storage/                  ← Logs, Uploads, Sessions
└── current -> releases/20260927-2101-1d94729
```

Jedes Release ist ein eigenes Verzeichnis mit dem vollständigen Code. Alles, was Releases überdauern muss (`.env`, `storage/`), liegt in `shared/` und wird per Symlink eingebunden. Der Webserver zeigt auf `current/public`.

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

APP=/var/www/app

# 1. Code des neuen Release in ein eigenes Verzeichnis, die laufende Seite bleibt unberührt
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. Gemeinsame Dateien
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Abhängigkeiten und Caches werden vor dem Umschalten gebaut
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Migrationen – nur solche, die mit dem aktuellen Code kompatibel sind (siehe Expand/Contract)
php artisan migrate --force

# 5. Atomares Umschalten: rename(2) hat keinen Zwischenzustand
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. OPcache zurücksetzen und Worker sanft neu starten
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Die letzten 5 Releases für einen Rollback aufbewahren
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` ist nicht atomar.** Unter der Haube sind das zwei Systemaufrufe: den alten Link löschen und einen neuen anlegen. Dazwischen gibt es einen Moment, in dem `current` nicht existiert und nginx einen 404 liefert. Atomar ist nur `rename(2)`, also `mv -T` über einen bestehenden Link.

Wer das nicht von Hand einrichten möchte: Deployer (`deployer/deployer`) und Laravel Envoyer setzen denselben Ansatz um.

---

<a id="opcache"></a>
## PHP-FPM, OPcache und realpath: warum der Symlink „nicht umschaltet“

Eine häufige Beschwerde: „Der Link ist umgestellt, aber die Seite zeigt die alte Version.“ Die Ursache sind zwei Caches:

* Der **realpath-Cache** in PHP merkt sich, worauf der Pfad `/var/www/app/current/...` aufgelöst wird;
* **OPcache** speichert kompilierte Dateien unter dem aufgelösten Pfad. In Produktion ist meist `opcache.validate_timestamps=0` gesetzt, und PHP prüft überhaupt nicht, ob sich Dateien geändert haben.

Die Lösung besteht aus zwei Teilen. Zuerst muss nginx den bereits aufgelösten Pfad an PHP übergeben, nicht den Pfad über den 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` löst den Symlink bei jedem Request auf. Neue Requests kommen mit dem Pfad `releases/20260928-...` an, und OPcache kompiliert sie als neue Dateien. Anschließend braucht es nach dem Umschalten einen `reload` von PHP-FPM (der ist sanft: laufende Requests werden zu Ende bearbeitet) oder `opcache_reset()` über `cachetool`, um den Speicher vom alten Release freizugeben.

---

<a id="assets"></a>
## Assets: Build in der CI und alte Tabs

Das Frontend auf dem Produktionsserver zu bauen, bedeutet unnötige Last und eine unnötige Abhängigkeit (Node.js). Besser in der CI bauen und vor dem Umschalten in das Verzeichnis des **neuen Release** hochladen:

```yaml
# .github/workflows/deploy-prod.yml (Ausschnitt)
- 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 hängt einen Hash an die Dateinamen an (`app-TDO2OnyO.js`), und `manifest.json` liegt innerhalb des Release. Deshalb verweist alter Code auf alte Dateien, neuer Code auf neue, und das Umschalten des Symlinks tauscht sie zusammen mit dem PHP-Code aus.

Bleibt eine Feinheit: Ein Nutzer kann noch einen Tab mit der alten Seite offen haben, der eine Minute später einen lazy geladenen Chunk anfordert. Existiert dieser nicht mehr, gibt es einen Fehler beim Laden des Moduls. Behalten Sie deshalb mehrere frühere Releases auf der Festplatte oder laden Sie die Assets in einen gemeinsamen CDN-Bucket, ohne alte Hashes zu löschen.

---

<a id="migrations"></a>
## Migrationen ohne Sperren: Expand/Contract

Migrationen laufen **vor** dem Umschalten, also arbeitet der alte Code eine Zeit lang mit dem neuen Schema. Daraus folgt die Regel: Jede Migration muss mit dem Code des vorherigen Release kompatibel sein. Dafür werden Schemaänderungen in Phasen aufgeteilt.

**Beispiel: `users.name` in `users.full_name` umbenennen.**

| Release | Schema | Code |
|---|---|---|
| 1. Expand | `full_name` hinzufügen (nullable) | Schreibt in beide Spalten, liest `name` |
| 2. Migrate | Ein Hintergrund-Job kopiert die Daten | Liest `full_name`, schreibt in beide |
| 3. Contract | `name` entfernen | Arbeitet nur noch mit `full_name` |

In einem einzigen Release mit `renameColumn` geht das nicht: Die alten Worker scheitern an einer nicht existierenden Spalte.

Bei PostgreSQL ist außerdem wichtig, **welche Sperren eine Migration nimmt**:

```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 darf nicht innerhalb einer Transaktion laufen
    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');
    }
};
```

Worauf es hier ankommt:

* **`CONCURRENTLY`** baut den Index, ohne Schreibzugriffe zu sperren. Ein gewöhnliches `$table->index()` sperrt die Tabelle für die gesamte Dauer des Aufbaus.
* **`lock_timeout`** verhindert, dass die Migration endlos auf eine Sperre wartet. Andernfalls stauen sich dahinter alle anderen Queries auf diese Tabelle. Lieber nach 5 Sekunden abbrechen und erneut versuchen, als die Seite lahmzulegen.
* **Eine Spalte mit `DEFAULT`** wird in PostgreSQL 11+ sofort hinzugefügt, aber `NOT NULL` auf einer bestehenden Spalte erfordert einen vollständigen Scan. Der sichere Weg: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, danach `VALIDATE CONSTRAINT`, danach `SET NOT NULL` (seit PostgreSQL 12 nutzt es die bereits validierte Constraint und scannt die Tabelle nicht).

> [!WARNING]
> **Ein Code-Rollback macht das Schema nicht rückgängig.** `migrate:rollback` in einer Produktionsumgebung mit Daten ist fast immer schlechter als eine neue Migration „nach vorn“. Genau Expand/Contract macht einen Code-Rollback sicher: Die vorherige Version arbeitet weiterhin mit dem erweiterten Schema.

---

<a id="queues"></a>
## Queues und Scheduler: sanfter Neustart

Ein `queue:work`-Worker ist ein langlebiger Prozess, der den Code einmal geladen hat. Nach dem Umschalten des Release führt er Jobs weiterhin mit dem alten Code aus, bis er neu gestartet wird.

```bash
php artisan queue:restart      # für queue:work unter Supervisor
php artisan horizon:terminate  # für Horizon
```

Beide Befehle sind sanft: Der Worker arbeitet den aktuellen Job zu Ende und beendet sich, und Supervisor startet einen neuen Prozess, bereits aus `current`. Es gehen keine Jobs verloren.

Das zweite Problem ist die **Kompatibilität der Jobs**. Ein Job, den der alte Code eingereiht hat, ist mit dessen Satz an Eigenschaften serialisiert. Hat der neue Code eine Eigenschaft umbenannt oder ein Pflichtargument im Konstruktor ergänzt, schlägt die Deserialisierung fehl:

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

    public function __construct(
        public readonly int $orderId,
        // Neues Feld – mit Standardwert, damit Jobs
        // aus dem alten Release erfolgreich deserialisiert werden
        public readonly string $locale = 'en',
    ) {}
}
```

Der Scheduler (`schedule:run` per Cron) braucht keinen Neustart: Cron startet jede Minute einen neuen Prozess, der bereits das aktualisierte `current` sieht.

---

<a id="health-and-rollback"></a>
## Health-Check und Rollback

Mit dem Umschalten ist das Deployment nicht abgeschlossen. Man muss sich vergewissern, dass die neue Version lebt:

```bash
# Nach dem Umschalten: Health-Route von Laravel 11+ (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

# Nicht hochgekommen – vorheriges Release mit demselben atomaren Umschalten zurückholen
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
```

Ein Rollback dauert Sekunden, weil das vorherige Release vollständig auf der Festplatte liegt, samt `vendor/` und gebauten Assets. Das Datenbankschema bleibt dabei allerdings das neue – und genau deshalb muss es abwärtskompatibel sein.

---

<a id="limitations"></a>
## Wo der Ansatz an seine Grenzen stößt

* **Ein einzelner Server ist keine Ausfallsicherheit.** Der Symlink beseitigt die Ausfallzeit beim Deployment, aber nicht beim Absturz des Servers. Dafür braucht es mehrere Nodes hinter einem Load Balancer und ein Rolling- oder Blue-Green-Deployment.
* **Manche Migrationen lassen sich nicht online durchführen.** Ein Wechsel des Spaltentyps mit Neuschreiben der Tabelle, das Aufteilen einer großen Tabelle, das Verschieben von Daten zwischen Datenbanken. Manchmal ist ein ehrliches nächtliches Wartungsfenster günstiger als ein wochenlanges Expand/Contract.
* **Sessions und Cache.** Ändert die neue Version das Datenformat in der Session oder die Cache-Keys, bekommen Nutzer mit alten Sessions Fehler. Versionieren Sie Cache-Präfixe und ändern Sie das Session-Format nicht ohne Abwärtskompatibilität.
* **Komplexität.** Für ein Hobbyprojekt mit zehn Besuchern pro Stunde kann `down`/`up` mit einem ERR-Trap ein vernünftiger Kompromiss sein. Hauptsache, die Entscheidung ist bewusst getroffen und ein Skriptfehler lässt die Seite nicht im Wartungsmodus zurück.

---

<a id="common-mistakes"></a>
## Häufige Fehler

**1. `git pull` und `composer install` in dem Verzeichnis, aus dem Requests bedient werden.**
Parallele Requests sehen eine Mischung aus Versionen. Bauen Sie das Release in einem eigenen Verzeichnis.

**2. `ln -sfn` zum Umschalten.**
Das ist nicht atomar. Verwenden Sie `ln -s` auf einen temporären Namen und `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` in nginx.**
PHP cacht den Pfad über den Symlink und liefert weiterhin alten Code aus. Verwenden Sie `$realpath_root`.

**4. Eine Spalte im selben Release umbenennen, in dem sich der Code ändert.**
Alte Worker und Requests scheitern im Moment des Umschaltens. Teilen Sie die Änderung in Expand/Contract auf.

**5. `$table->index()` auf einer großen Tabelle.**
Sperrt Schreibzugriffe für die gesamte Dauer des Aufbaus. Verwenden Sie `CREATE INDEX CONCURRENTLY` und `$withinTransaction = false`.

**6. `queue:restart` vergessen.**
Worker führen wochenlang Jobs mit altem Code gegen das neue Schema aus.

**7. `php artisan down` ohne Garantie für `up`.**
Bricht das Skript mittendrin ab, bleibt die Seite im Wartungsmodus. Das Minimum ist `trap 'php artisan up' ERR`.

---

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

1. Code, Abhängigkeiten, Caches und Assets des neuen Release werden in einem eigenen Verzeichnis vorbereitet.
2. `.env` und `storage/` sind nach `shared/` ausgelagert.
3. Das Umschalten erfolgt per `mv -T` des Symlinks, nginx verwendet `$realpath_root`.
4. Nach dem Umschalten: `reload` von PHP-FPM und `queue:restart` (bzw. `horizon:terminate`).
5. Jede Migration ist mit dem Code des vorherigen Release kompatibel; Indizes mit `CONCURRENTLY`; `lock_timeout` ist gesetzt.
6. Neue Job-Argumente haben Standardwerte.
7. Nach dem Umschalten wird `/up` geprüft, bei einem Fehlschlag erfolgt ein automatischer Rollback auf das vorherige Release.
8. Auf der Festplatte werden mehrere der letzten Releases aufbewahrt.

---

## Zusammenfassung

Zero-Downtime-Deployment ist kein Werkzeug, sondern eine Eigenschaft des Systems: Der Code kann in zwei Versionen gleichzeitig existieren. Symlinks, `$realpath_root` und `queue:restart` lösen die Mechanik des Umschaltens. Die eigentliche Arbeit steckt aber in der Disziplin bei Migrationen und der Kompatibilität der Jobs – sie macht aus einem Rollback eine langweilige Operation von ein paar Sekunden statt eines nächtlichen Incidents.

---

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

### Frage 1: Warum eignet sich `ln -sfn new current` nicht für ein atomares Umschalten des Release?
- A) Der Befehl funktioniert nicht mit absoluten Pfaden.
- B) Er führt zwei Systemaufrufe aus (unlink und symlink), und dazwischen existiert der Link `current` nicht.
- C) Er kopiert die Dateien des Release, statt einen Link anzulegen.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
`ln -sfn` löscht den alten Link und legt einen neuen an. In der Zwischenzeit kann der Webserver einen Request erhalten und `current` nicht finden. Atomar ist nur `rename(2)`, deshalb wird der neue Link unter einem temporären Namen angelegt und per `mv -T` umbenannt.
</details>

### Frage 2: Sie müssen eine Spalte umbenennen, mit der Queue-Worker arbeiten. Welcher Ansatz ist bei einem Deployment ohne Ausfallzeit sicher?
- A) Eine Migration mit `renameColumn` und gleichzeitiges Aktualisieren des Codes.
- B) Eine neue Spalte hinzufügen, Code ausrollen, der in beide schreibt, die Daten übertragen und die alte Spalte in einem separaten Release entfernen.
- C) Die Seite nur für die Dauer der Migration in den Wartungsmodus versetzen.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Das Expand/Contract-Muster garantiert, dass sowohl alter als auch neuer Code zu jedem Zeitpunkt die benötigten Spalten vorfinden. Variante C ist schon kein Zero-Downtime mehr, und Variante A bricht Worker und Requests, die noch auf dem alten Release laufen.
</details>

### Frage 3: Warum setzt man in einer Migration mit `CREATE INDEX CONCURRENTLY` `public $withinTransaction = false;`?
- A) Damit die Migration schneller läuft.
- B) PostgreSQL verbietet `CREATE INDEX CONCURRENTLY` innerhalb eines Transaktionsblocks, und Laravel kapselt Migrationen standardmäßig in eine Transaktion.
- C) Damit der Index auf allen Replikas gleichzeitig angelegt wird.

<details>
<summary><b>Antworten anzeigen</b></summary>

**Antwort: B**
Der nebenläufige Indexaufbau besteht aus mehreren Phasen mit eigenen Transaktionen und ist deshalb innerhalb von `BEGIN ... COMMIT` nicht möglich. Die Eigenschaft `$withinTransaction = false` schaltet die Transaktionskapselung von Laravel für die jeweilige Migration ab.
</details>