---
title: "Déploiement zero-downtime pour Laravel : releases atomiques, migrations et files d'attente | DevSense"
description: "Comment déployer Laravel sans interruption de service : releases via symlink, OPcache et realpath, build des assets en CI, migrations PostgreSQL sûres selon le schéma expand/contract et redémarrage en douceur des files d'attente."
faq:
    - { question: 'Pourquoi php artisan down ne convient-il pas à un déploiement sans interruption ?', answer: "La commande passe l'application en mode maintenance et renvoie une 503 à tous les utilisateurs pendant l'installation des dépendances, les migrations et le préchauffage des caches. C'est une interruption assumée, mais planifiée. De plus, si le script échoue entre down et up, le site reste inaccessible jusqu'à une intervention manuelle." }
    - { question: "Qu'est-ce qu'une release atomique via symlink ?", answer: "Chaque version du code est construite dans un dossier séparé releases/<id>, et le serveur web pointe vers le lien symbolique current. Lorsque la nouvelle version est entièrement prête (dépendances, caches, assets), le lien est basculé en une seule opération atomique mv -T. Les requêtes vont soit vers l'ancienne version, soit vers la nouvelle, mais jamais vers une version à moitié mise à jour." }
    - { question: "Comment exécuter les migrations pour que l'ancien et le nouveau code fonctionnent simultanément ?", answer: "Selon le schéma expand/contract : commencez par uniquement ajouter (nouvelles colonnes, tables, index CONCURRENTLY), déployez ensuite le code qui écrit aux deux endroits, et ne supprimez l'ancien qu'à la release suivante. Renommer ou supprimer une colonne dans la même release que celle qui modifie le code casse les requêtes des anciens workers." }
    - { question: "Comment redémarrer les workers de files d'attente sans perdre de jobs ?", answer: "Avec la commande php artisan queue:restart (ou horizon:terminate pour Horizon). Les workers terminent le job en cours, s'arrêtent, et Supervisor les relance avec le nouveau code. Il est important que les jobs sérialisés, mis en file par l'ancien code, restent compatibles avec le nouveau." }
published: '2026-09-28'
---
# Déploiement zero-downtime pour Laravel : releases atomiques, migrations et files d'attente

Un déploiement qui « passe d'habitude » est plus dangereux qu'un déploiement qui échoue tout de suite. Récemment, notre propre déploiement a échoué sur `git fetch` : les identifiants d'accès au dépôt privé avaient expiré. Le site n'a survécu que parce que `git fetch` se trouvait **avant** `php artisan down`. Deux lignes plus bas, et le site serait resté bloqué en mode maintenance toute la nuit. La plupart des projets Laravel se déploient exactement ainsi : `down`, `git pull`, `composer install`, `migrate`, `up`. Cela fonctionne jusqu'à la première panne en cours de route, et affiche une 503 aux utilisateurs à chaque fois.

**Voir aussi :** [Environnement, CI et déploiement avec Sail](../tools/sail-env-deploy) · [Comparatif des files de messages](message-queues-compared) · [Observabilité et monitoring](observability-monitoring-laravel)

## Sommaire

* [D'où vient l'interruption lors d'un déploiement](#where-downtime-comes-from)
* [L'idée clé : préparer à côté, basculer de façon atomique](#core-idea)
* [Releases atomiques : releases, shared et current](#atomic-releases)
* [PHP-FPM, OPcache et realpath : pourquoi le symlink « ne bascule pas »](#opcache)
* [Assets : build en CI et anciens onglets](#assets)
* [Migrations sans verrous : expand/contract](#migrations)
* [Files d'attente et planificateur : redémarrage en douceur](#queues)
* [Health check et rollback](#health-and-rollback)
* [Les limites de l'approche](#limitations)
* [Erreurs fréquentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz d'auto-évaluation](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## D'où vient l'interruption lors d'un déploiement

Une interruption ressemble rarement à un « serveur éteint ». Le plus souvent, ce sont quelques secondes ou minutes pendant lesquelles l'application se trouve dans un état intermédiaire :

* **`composer install` par-dessus le code en production.** Pendant que les paquets de `vendor/` sont réécrits, les requêtes concurrentes chargent un mélange d'anciennes et de nouvelles classes. Résultat : `Class not found` ou des erreurs fatales dues à des signatures incompatibles.
* **Un cache de configuration issu d'une autre version.** `bootstrap/cache/config.php` a été généré par l'ancien code, alors que les routes ou les service providers sont déjà nouveaux. Tant que `php artisan optimize` n'a pas été exécuté, l'application tourne désynchronisée.
* **Des migrations qui posent des verrous.** Un `ALTER TABLE` sur une grande table prend un verrou exclusif, et toutes les requêtes sur cette table se mettent en file d'attente. Pour l'utilisateur, le site semble figé.
* **Des workers sur l'ancien code.** Supervisor maintient des processus `queue:work` chargés avant le déploiement. Ils continuent d'exécuter les jobs avec les anciennes classes contre le nouveau schéma de base de données.
* **Des assets de la mauvaise version.** `public/build/manifest.json` a été mis à jour avant ou après le code, et la page référence des fichiers CSS et JS qui n'existent pas encore (ou plus).

Le mode maintenance masque tous ces problèmes en affichant une 503. Mais ce n'est pas une solution : c'est simplement admettre l'interruption.

---

<a id="core-idea"></a>
## L'idée clé : préparer à côté, basculer de façon atomique

**Un déploiement sans interruption, c'est quand la nouvelle version est entièrement construite à côté de l'ancienne, que la bascule tient en une seule opération atomique et que le schéma de base de données est, à tout instant, compatible à la fois avec l'ancien et le nouveau code.**

Trois règles en découlent :

1. Ne rien modifier « sur place » dans le dossier qui sert actuellement les requêtes.
2. La bascule est une opération unique, sans état intermédiaire.
3. Les migrations et les jobs en file d'attente sont conçus pour que deux versions du code puissent cohabiter.

---

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

Structure sur le serveur :

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

Chaque release est un dossier distinct contenant l'intégralité du code. Tout ce qui doit survivre d'une release à l'autre (`.env`, `storage/`) se trouve dans `shared/` et est raccordé par des liens symboliques. Le serveur web pointe vers `current/public`.

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

APP=/var/www/app

# 1. Le code de la nouvelle release va dans un dossier séparé, on ne touche pas au site actuel
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. Fichiers partagés
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Les dépendances et les caches sont construits avant la bascule
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Migrations : uniquement celles compatibles avec le code actuel (voir expand/contract)
php artisan migrate --force

# 5. Bascule atomique : rename(2) n'a pas d'état intermédiaire
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Réinitialisation d'OPcache et redémarrage en douceur des workers
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. On conserve les 5 dernières releases pour le rollback
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` n'est pas atomique.** Sous le capot, ce sont deux appels système : supprimer l'ancien lien et en créer un nouveau. Entre les deux, il existe un instant où `current` n'existe pas, et nginx renvoie une 404. Seul `rename(2)` est atomique, c'est-à-dire `mv -T` par-dessus le lien existant.

Si vous ne souhaitez pas configurer tout cela à la main, Deployer (`deployer/deployer`) et Laravel Envoyer mettent en œuvre la même approche.

---

<a id="opcache"></a>
## PHP-FPM, OPcache et realpath : pourquoi le symlink « ne bascule pas »

Plainte fréquente : « on a basculé le lien, mais le site affiche l'ancienne version ». La cause tient en deux caches :

* le **realpath cache** de PHP mémorise vers quoi se résout le chemin `/var/www/app/current/...` ;
* **OPcache** stocke les fichiers compilés selon le chemin résolu. En production, on a généralement `opcache.validate_timestamps=0`, et PHP ne vérifie plus du tout si les fichiers ont changé.

La solution se fait en deux temps. D'abord, nginx doit transmettre à PHP le chemin déjà résolu, et non le chemin passant par le 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` résout le symlink à chaque requête. Les nouvelles requêtes arrivent avec le chemin `releases/20260928-...`, et OPcache les compile comme de nouveaux fichiers. Ensuite, après la bascule, il faut un `reload` de PHP-FPM (il est progressif : les requêtes en cours se terminent) ou un `opcache_reset()` via `cachetool` pour libérer la mémoire occupée par l'ancienne release.

---

<a id="assets"></a>
## Assets : build en CI et anciens onglets

Construire le frontend sur le serveur de production, c'est une charge inutile et une dépendance superflue (Node.js). Mieux vaut le construire en CI et l'envoyer dans le dossier de la **nouvelle release** avant la bascule :

```yaml
# .github/workflows/deploy-prod.yml (extrait)
- 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 ajoute un hash aux noms de fichiers (`app-TDO2OnyO.js`), et `manifest.json` se trouve à l'intérieur de la release. L'ancien code référence donc les anciens fichiers, le nouveau les nouveaux, et la bascule du symlink les change en même temps que le code PHP.

Reste une subtilité : l'utilisateur peut avoir un onglet ouvert sur l'ancienne page, qui demandera une minute plus tard un chunk chargé à la demande (lazy loading). S'il n'existe plus, le chargement du module échoue. Conservez donc plusieurs releases précédentes sur le disque, ou envoyez les assets dans un bucket CDN partagé sans supprimer les anciens hashs.

---

<a id="migrations"></a>
## Migrations sans verrous : expand/contract

Les migrations sont exécutées **avant** la bascule : pendant un certain temps, l'ancien code travaille donc avec le nouveau schéma. D'où la règle : chaque migration doit être compatible avec le code de la release précédente. Pour cela, les modifications de schéma sont découpées en phases.

**Exemple : renommer `users.name` en `users.full_name`.**

| Release | Schéma | Code |
|---|---|---|
| 1. Expand | Ajouter `full_name` (nullable) | Écrit dans les deux colonnes, lit `name` |
| 2. Migrate | Un job en arrière-plan copie les données | Lit `full_name`, écrit dans les deux |
| 3. Contract | Supprimer `name` | Ne travaille qu'avec `full_name` |

Impossible de faire cela en une seule release avec `renameColumn` : les anciens workers planteraient sur une colonne inexistante.

Avec PostgreSQL, il faut aussi savoir **quels verrous la migration pose** :

```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 ne peut pas être exécuté dans une 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');
    }
};
```

Ce qui compte ici :

* **`CONCURRENTLY`** construit l'index sans bloquer les écritures. Un `$table->index()` classique verrouille la table pendant toute la durée de la construction.
* **`lock_timeout`** empêche la migration d'attendre indéfiniment un verrou. Sinon, toutes les autres requêtes sur la table s'accumulent derrière elle. Mieux vaut échouer au bout de 5 secondes et réessayer que de bloquer le site.
* **Une colonne avec `DEFAULT`** s'ajoute instantanément depuis PostgreSQL 11, mais un `NOT NULL` sur une colonne existante exige un parcours complet de la table. La voie sûre : `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, puis `VALIDATE CONSTRAINT`, puis `SET NOT NULL` (depuis PostgreSQL 12, il s'appuie sur la contrainte déjà validée et ne parcourt pas la table).

> [!WARNING]
> **Revenir en arrière sur le code ne revient pas en arrière sur le schéma.** Un `migrate:rollback` en production sur des données réelles est presque toujours pire qu'une nouvelle migration « vers l'avant ». C'est justement expand/contract qui rend le rollback du code sûr : la version précédente continue de fonctionner avec le schéma étendu.

---

<a id="queues"></a>
## Files d'attente et planificateur : redémarrage en douceur

Un worker `queue:work` est un processus de longue durée qui a chargé le code une seule fois. Après la bascule de release, il continuera d'exécuter les jobs avec l'ancien code tant qu'il n'aura pas été redémarré.

```bash
php artisan queue:restart      # pour queue:work sous Supervisor
php artisan horizon:terminate  # pour Horizon
```

Les deux commandes sont progressives : le worker termine le job en cours puis s'arrête, et Supervisor lance un nouveau processus, cette fois depuis `current`. Aucun job n'est perdu.

Le second problème est la **compatibilité des jobs**. Un job mis en file par l'ancien code est sérialisé avec son propre jeu de propriétés. Si le nouveau code a renommé une propriété ou ajouté un argument obligatoire au constructeur, la désérialisation échouera :

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

    public function __construct(
        public readonly int $orderId,
        // Nouveau champ, avec une valeur par défaut pour que les jobs
        // de l'ancienne release se désérialisent correctement
        public readonly string $locale = 'en',
    ) {}
}
```

Le planificateur (`schedule:run` via cron) n'a pas besoin de redémarrage : cron lance chaque minute un nouveau processus, qui voit déjà le `current` mis à jour.

---

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

La bascule ne termine pas le déploiement. Il faut encore s'assurer que la nouvelle version est bien vivante :

```bash
# Après la bascule : route health de 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

# Elle ne répond pas : on revient à la release précédente avec la même bascule atomique
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
```

Le rollback prend quelques secondes, car la release précédente est intégralement présente sur le disque, avec `vendor/` et les assets construits. Le schéma de base de données, lui, reste le nouveau, et c'est précisément pour cela qu'il doit être rétrocompatible.

---

<a id="limitations"></a>
## Les limites de l'approche

* **Un seul serveur n'est pas de la haute disponibilité.** Le symlink supprime l'interruption lors du déploiement, mais pas lors d'une panne du serveur. Pour cela, il faut plusieurs nœuds derrière un load balancer et un déploiement rolling ou blue-green.
* **Certaines migrations ne peuvent pas se faire en ligne.** Changement de type de colonne avec réécriture de la table, découpage d'une grande table, transfert de données entre bases. Parfois, une vraie fenêtre de maintenance la nuit coûte moins cher qu'un schéma expand/contract étalé sur une semaine.
* **Sessions et cache.** Si la nouvelle version modifie le format des données de session ou les clés de cache, les utilisateurs ayant d'anciennes sessions rencontreront des erreurs. Versionnez les préfixes de cache et ne changez pas le format de session sans rétrocompatibilité.
* **La complexité.** Pour un projet perso avec dix visiteurs par heure, `down`/`up` avec un trap ERR peut être un compromis raisonnable. L'essentiel est que ce soit un choix conscient, et qu'un échec du script ne laisse pas le site en maintenance.

---

<a id="common-mistakes"></a>
## Erreurs fréquentes

**1. `git pull` et `composer install` dans le dossier qui sert les requêtes.**
Les requêtes concurrentes voient un mélange de versions. Construisez la release dans un dossier séparé.

**2. `ln -sfn` pour la bascule.**
Ce n'est pas atomique. Utilisez `ln -s` vers un nom temporaire, puis `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` dans nginx.**
PHP met en cache le chemin via le symlink et continue de servir l'ancien code. Utilisez `$realpath_root`.

**4. Renommer une colonne dans la même release que le code.**
Les anciens workers et les requêtes en cours au moment de la bascule planteront. Découpez selon expand/contract.

**5. `$table->index()` sur une grande table.**
Bloque les écritures pendant toute la construction. Utilisez `CREATE INDEX CONCURRENTLY` et `$withinTransaction = false`.

**6. Oublier `queue:restart`.**
Les workers exécutent pendant des semaines les jobs avec l'ancien code contre le nouveau schéma.

**7. `php artisan down` sans garantie de `up`.**
Si le script échoue en cours de route, le site reste en mode maintenance. Au minimum : `trap 'php artisan up' ERR`.

---

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

1. Le code, les dépendances, les caches et les assets de la nouvelle release sont préparés dans un dossier séparé.
2. `.env` et `storage/` sont déplacés dans `shared/`.
3. La bascule se fait par `mv -T` du symlink, nginx utilise `$realpath_root`.
4. Après la bascule : `reload` de PHP-FPM et `queue:restart` (ou `horizon:terminate`).
5. Chaque migration est compatible avec le code de la release précédente ; les index sont créés en `CONCURRENTLY` ; un `lock_timeout` est défini.
6. Les nouveaux arguments des jobs ont des valeurs par défaut.
7. Après la bascule, `/up` est vérifié ; en cas d'échec, rollback automatique vers la release précédente.
8. Plusieurs releases récentes sont conservées sur le disque.

---

## Conclusion

Le déploiement sans interruption n'est pas un outil, c'est une propriété du système : le code sait vivre en deux versions simultanément. Les symlinks, `$realpath_root` et `queue:restart` règlent la mécanique de la bascule. Mais le vrai travail réside dans la discipline des migrations et la compatibilité des jobs, qui font du rollback une opération banale de quelques secondes plutôt qu'un incident nocturne.

---

<a id="self-test-quiz"></a>
## Quiz d'auto-évaluation

### Question 1 : Pourquoi `ln -sfn new current` ne convient-il pas à une bascule de release atomique ?
- A) La commande ne fonctionne pas avec des chemins absolus.
- B) Elle effectue deux appels système (unlink et symlink), et entre les deux, le lien `current` n'existe pas.
- C) Elle copie les fichiers de la release au lieu de créer un lien.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
`ln -sfn` supprime l'ancien lien et en crée un nouveau. Dans l'intervalle, le serveur web peut recevoir une requête et ne pas trouver `current`. Seul `rename(2)` est atomique : c'est pourquoi on crée le nouveau lien sous un nom temporaire, puis on le renomme avec `mv -T`.
</details>

### Question 2 : Vous devez renommer une colonne utilisée par les workers de la file d'attente. Quelle approche est sûre lors d'un déploiement sans interruption ?
- A) Une seule migration avec `renameColumn` et la mise à jour simultanée du code.
- B) Ajouter la nouvelle colonne, déployer un code qui écrit dans les deux, migrer les données et supprimer l'ancienne colonne dans une release séparée.
- C) Passer le site en mode maintenance uniquement pendant la migration.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
Le schéma expand/contract garantit qu'à tout moment, l'ancien comme le nouveau code trouvent les colonnes dont ils ont besoin. L'option C n'est déjà plus du zero-downtime, et l'option A casse les workers et les requêtes qui tournent sur l'ancienne release.
</details>

### Question 3 : Pourquoi indique-t-on `public $withinTransaction = false;` dans une migration utilisant `CREATE INDEX CONCURRENTLY` ?
- A) Pour que la migration s'exécute plus vite.
- B) PostgreSQL interdit `CREATE INDEX CONCURRENTLY` dans un bloc transactionnel, et Laravel encapsule par défaut chaque migration dans une transaction.
- C) Pour que l'index soit créé simultanément sur tous les réplicas.

<details>
<summary><b>Afficher la réponse</b></summary>

**Réponse : B**
La construction concurrente d'un index comporte plusieurs phases, chacune dans sa propre transaction : elle est donc impossible à l'intérieur d'un `BEGIN ... COMMIT`. La propriété `$withinTransaction = false` désactive l'encapsulation de Laravel pour cette migration précise.
</details>