---
title: 'Zero-downtime deployment per Laravel: release atomiche, migrazioni e code | DevSense'
description: 'Come rilasciare Laravel senza downtime: release tramite symlink, OPcache e realpath, build degli asset in CI, migrazioni PostgreSQL sicure secondo lo schema expand/contract e riavvio graduale delle code.'
faq:
    - { question: 'Perché php artisan down non è adatto a un deployment senza downtime?', answer: "Il comando mette l'applicazione in modalità manutenzione e restituisce 503 a tutti gli utenti mentre sono in corso l'installazione delle dipendenze, le migrazioni e il riscaldamento delle cache. È un downtime onesto, ma pur sempre pianificato. Inoltre, se lo script fallisce dopo down e prima di up, il sito resta irraggiungibile fino a un intervento manuale." }
    - { question: "Che cos'è una release atomica tramite symlink?", answer: "Ogni versione del codice viene preparata in una cartella separata releases/<id>, mentre il web server punta al link simbolico current. Quando la nuova versione è completamente pronta (dipendenze, cache, asset), il link viene commutato con un'unica operazione atomica mv -T. Le richieste arrivano o alla vecchia o alla nuova versione, ma mai a una aggiornata a metà." }
    - { question: 'Come eseguire le migrazioni in modo che il vecchio e il nuovo codice funzionino contemporaneamente?', answer: 'Secondo lo schema expand/contract: prima si aggiunge soltanto (nuove colonne, tabelle, indici CONCURRENTLY), poi si rilascia il codice che scrive in entrambi i punti e solo nella release successiva si elimina il vecchio. Rinominare o eliminare una colonna nella stessa release in cui cambia il codice rompe le query dei vecchi worker.' }
    - { question: 'Come riavviare i worker delle code senza perdere job?', answer: 'Con il comando php artisan queue:restart (oppure horizon:terminate per Horizon). I worker completano il job corrente, terminano e Supervisor li riavvia già con il nuovo codice. È importante che i job serializzati accodati dal vecchio codice restino compatibili con il nuovo.' }
published: '2026-09-28'
---
# Zero-downtime deployment per Laravel: release atomiche, migrazioni e code

Un deployment che «di solito va a buon fine» è più pericoloso di uno che fallisce subito. Di recente il nostro stesso deployment si è bloccato su `git fetch`: le credenziali per il repository privato erano scadute. Il sito si è salvato solo perché `git fetch` si trovava **prima** di `php artisan down`. Se fosse stato un paio di righe più in basso, il sito sarebbe rimasto bloccato in modalità manutenzione per tutta la notte. La maggior parte dei progetti Laravel viene rilasciata proprio così: `down`, `git pull`, `composer install`, `migrate`, `up`. Funziona fino al primo errore a metà strada e ogni volta mostra agli utenti un 503.

**Materiali correlati:** [Ambiente, CI e deployment con Sail](../tools/sail-env-deploy) · [Code di messaggi a confronto](message-queues-compared) · [Osservabilità e monitoraggio](observability-monitoring-laravel)

## Indice

* [Da dove nasce il downtime durante il deployment](#where-downtime-comes-from)
* [L'idea chiave: preparare accanto, commutare in modo atomico](#core-idea)
* [Release atomiche: releases, shared e current](#atomic-releases)
* [PHP-FPM, OPcache e realpath: perché il symlink «non si commuta»](#opcache)
* [Asset: build in CI e vecchie schede del browser](#assets)
* [Migrazioni senza lock: expand/contract](#migrations)
* [Code e scheduler: riavvio graduale](#queues)
* [Health check e rollback](#health-and-rollback)
* [Dove l'approccio smette di funzionare](#limitations)
* [Errori comuni](#common-mistakes)
* [Checklist](#checklist)
* [Quiz di autoverifica](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## Da dove nasce il downtime durante il deployment

Il downtime raramente si presenta come «server spento». Più spesso si tratta di qualche secondo o minuto in cui l'applicazione si trova in uno stato intermedio:

* **`composer install` sopra il codice in esecuzione.** Mentre i pacchetti in `vendor/` vengono sovrascritti, le richieste parallele caricano un misto di classi vecchie e nuove. Il risultato è `Class not found` o errori fatali dovuti a firme incompatibili.
* **Cache di configurazione di un'altra versione.** `bootstrap/cache/config.php` è stato generato dal vecchio codice, mentre le rotte o i service provider sono già quelli nuovi. Finché non si esegue `php artisan optimize`, l'applicazione vive fuori sincronia.
* **Migrazioni con lock.** Un `ALTER TABLE` su una tabella grande acquisisce un lock esclusivo e tutte le query su quella tabella finiscono in coda. Per l'utente sembra un sito bloccato.
* **Worker sul vecchio codice.** Supervisor mantiene i processi `queue:work` caricati prima del deployment. Continuano a eseguire job con le vecchie classi contro il nuovo schema del DB.
* **Asset della versione sbagliata.** `public/build/manifest.json` è stato aggiornato prima o dopo il codice, e la pagina fa riferimento a CSS e JS che non esistono ancora (o non esistono più).

La modalità manutenzione maschera tutti questi problemi mostrando un 503. Ma non è una soluzione: è un'onesta ammissione di downtime.

---

<a id="core-idea"></a>
## L'idea chiave: preparare accanto, commutare in modo atomico

**Un deployment senza downtime è quello in cui la nuova versione viene preparata interamente accanto alla vecchia, la commutazione richiede un'unica operazione atomica e lo schema del DB è in ogni momento compatibile sia con il vecchio sia con il nuovo codice.**

Da questa affermazione derivano tre regole:

1. Non modificare nulla «sul posto» nella cartella da cui vengono servite le richieste in quel momento.
2. La commutazione è un'unica operazione, priva di stati intermedi.
3. Le migrazioni e i job nelle code vanno progettati in modo che due versioni del codice possano convivere.

---

<a id="atomic-releases"></a>
## Release atomiche: releases, shared e current

Struttura sul server:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← nuova release
├── shared/
│   ├── .env
│   └── storage/                  ← log, upload, sessioni
└── current -> releases/20260927-2101-1d94729
```

Ogni release è una cartella separata con il codice completo. Tutto ciò che deve sopravvivere tra una release e l'altra (`.env`, `storage/`) si trova in `shared/` e viene collegato tramite symlink. Il web server punta a `current/public`.

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

APP=/var/www/app

# 1. Il codice della nuova release va in una cartella separata, il sito attuale non si tocca
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. File condivisi
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Dipendenze e cache vengono preparate prima della commutazione
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Migrazioni: solo quelle compatibili con il codice attuale (vedi expand/contract)
php artisan migrate --force

# 5. Commutazione atomica: rename(2) non ha stati intermedi
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Reset di OPcache e riavvio graduale dei worker
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Conserviamo le ultime 5 release per il rollback
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` non è atomico.** Dietro le quinte si tratta di due chiamate di sistema: eliminare il vecchio link e crearne uno nuovo. Tra le due c'è un momento in cui `current` non esiste e nginx restituisce 404. È atomica solo `rename(2)`, cioè `mv -T` sopra un link esistente.

Se non si vuole configurare tutto a mano, lo stesso approccio è implementato da Deployer (`deployer/deployer`) e Laravel Envoyer.

---

<a id="opcache"></a>
## PHP-FPM, OPcache e realpath: perché il symlink «non si commuta»

Una lamentela frequente: «abbiamo commutato il link, ma il sito mostra la vecchia versione». La causa sono due cache:

* la **realpath cache** di PHP memorizza in cosa si risolve il percorso `/var/www/app/current/...`;
* **OPcache** conserva i file compilati in base al percorso risolto. In produzione di solito è impostato `opcache.validate_timestamps=0` e PHP non verifica affatto se i file sono cambiati.

La soluzione si compone di due parti. Innanzitutto nginx deve passare a PHP il percorso già risolto, non quello attraverso il 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` risolve il symlink a ogni richiesta. Le nuove richieste arrivano con il percorso `releases/20260928-...` e OPcache le compila come file nuovi. Poi, dopo la commutazione, serve un `reload` di PHP-FPM (è graduale: le richieste in corso vengono completate) oppure `opcache_reset()` tramite `cachetool`, per liberare la memoria occupata dalla vecchia release.

---

<a id="assets"></a>
## Asset: build in CI e vecchie schede del browser

Compilare il frontend sul server di produzione è un carico superfluo e una dipendenza superflua (Node.js). Meglio eseguire la build in CI e caricarla nella cartella della **nuova release** prima della commutazione:

```yaml
# .github/workflows/deploy-prod.yml (frammento)
- 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 aggiunge un hash ai nomi dei file (`app-TDO2OnyO.js`) e `manifest.json` si trova all'interno della release. Per questo il vecchio codice fa riferimento ai vecchi file, il nuovo ai nuovi, e la commutazione del symlink li cambia insieme al codice PHP.

Rimane una sottigliezza: l'utente potrebbe avere aperta una scheda con la vecchia pagina che, dopo un minuto, richiederà un chunk caricato in modo lazy. Se quel file non esiste più, si otterrà un errore di caricamento del modulo. Per questo conservate su disco alcune release precedenti oppure caricate gli asset in un bucket CDN condiviso senza eliminare i vecchi hash.

---

<a id="migrations"></a>
## Migrazioni senza lock: expand/contract

Le migrazioni vengono eseguite **prima** della commutazione, quindi per un certo tempo il vecchio codice lavora con il nuovo schema. Da qui la regola: ogni migrazione deve essere compatibile con il codice della release precedente. Per ottenerlo, le modifiche allo schema vengono suddivise in fasi.

**Esempio: rinominare `users.name` in `users.full_name`.**

| Release | Schema | Codice |
|---|---|---|
| 1. Expand | Aggiungere `full_name` (nullable) | Scrive in entrambe le colonne, legge `name` |
| 2. Migrate | Un job in background copia i dati | Legge `full_name`, scrive in entrambe |
| 3. Contract | Eliminare `name` | Lavora solo con `full_name` |

Non si può fare con un'unica release con `renameColumn`: i vecchi worker falliranno su una colonna inesistente.

Per PostgreSQL conta anche **quali lock acquisisce la migrazione**:

```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 non può essere eseguito all'interno di una transazione
    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');
    }
};
```

Cosa è importante qui:

* **`CONCURRENTLY`** costruisce l'indice senza bloccare le scritture. Un normale `$table->index()` blocca la tabella per tutta la durata della costruzione.
* **`lock_timeout`** impedisce alla migrazione di attendere il lock all'infinito. Altrimenti dietro di essa si formerebbe una coda con tutte le altre query sulla tabella. Meglio fallire dopo 5 secondi e riprovare che bloccare il sito.
* **Una colonna con `DEFAULT`** in PostgreSQL 11+ viene aggiunta istantaneamente, ma `NOT NULL` su una colonna esistente richiede una scansione completa. La strada sicura: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, poi `VALIDATE CONSTRAINT`, poi `SET NOT NULL` (da PostgreSQL 12 sfrutta il vincolo già verificato e non scansiona la tabella).

> [!WARNING]
> **Il rollback del codice non annulla lo schema.** Un `migrate:rollback` in produzione con dati reali è quasi sempre peggio di una nuova migrazione «in avanti». È proprio expand/contract a rendere sicuro il rollback del codice: la versione precedente continua a funzionare con lo schema esteso.

---

<a id="queues"></a>
## Code e scheduler: riavvio graduale

Il worker `queue:work` è un processo di lunga durata che ha caricato il codice una sola volta. Dopo la commutazione della release continuerà a eseguire i job con il vecchio codice finché non verrà riavviato.

```bash
php artisan queue:restart      # per queue:work sotto Supervisor
php artisan horizon:terminate  # per Horizon
```

Entrambi i comandi sono graduali: il worker completa il job corrente e termina, e Supervisor avvia un nuovo processo già da `current`. Nessun job va perso.

Il secondo problema è la **compatibilità dei job**. Un job accodato dal vecchio codice è serializzato con il suo insieme di proprietà. Se il nuovo codice ha rinominato una proprietà o aggiunto un argomento obbligatorio al costruttore, la deserializzazione si romperà:

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

    public function __construct(
        public readonly int $orderId,
        // Nuovo campo con valore predefinito, affinché i job
        // della vecchia release vengano deserializzati correttamente
        public readonly string $locale = 'en',
    ) {}
}
```

Lo scheduler (`schedule:run` da cron) non ha bisogno di riavvio: cron avvia ogni minuto un nuovo processo che vede già il `current` aggiornato.

---

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

La commutazione non conclude il deployment. Bisogna assicurarsi che la nuova versione sia viva:

```bash
# Dopo la commutazione: rotta di health di 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

# Non è partita: ripristiniamo la release precedente con la stessa commutazione atomica
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
```

Il rollback richiede pochi secondi, perché la release precedente è interamente su disco, con `vendor/` e gli asset già compilati. Lo schema del DB resta però quello nuovo, ed è proprio per questo che deve essere retrocompatibile.

---

<a id="limitations"></a>
## Dove l'approccio smette di funzionare

* **Un solo server non è fault tolerance.** Il symlink elimina il downtime durante il deployment, ma non in caso di guasto del server. Per questo servono più nodi dietro un load balancer e un deployment rolling o blue-green.
* **Alcune migrazioni non si possono fare online.** Cambio del tipo di una colonna con riscrittura della tabella, partizionamento di una tabella grande, spostamento di dati tra database. A volte un'onesta finestra di manutenzione notturna costa meno di uno schema expand/contract lungo una settimana.
* **Sessioni e cache.** Se la nuova versione cambia il formato dei dati in sessione o le chiavi di cache, gli utenti con sessioni vecchie riceveranno errori. Versionate i prefissi della cache e non cambiate il formato della sessione senza retrocompatibilità.
* **Complessità.** Per un pet project con dieci visitatori all'ora, `down`/`up` con un ERR-trap può essere un compromesso ragionevole. L'importante è che sia una scelta consapevole e che un errore dello script non lasci il sito in maintenance.

---

<a id="common-mistakes"></a>
## Errori comuni

**1. `git pull` e `composer install` nella cartella da cui vengono servite le richieste.**
Le richieste parallele vedono un misto di versioni. Preparate la release in una cartella separata.

**2. `ln -sfn` per la commutazione.**
Non è atomico. Usate `ln -s` verso un nome temporaneo e `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` in nginx.**
PHP mette in cache il percorso attraverso il symlink e continua a servire il vecchio codice. Usate `$realpath_root`.

**4. Rinominare una colonna nella stessa release del codice.**
I vecchi worker e le richieste in corso al momento della commutazione falliranno. Suddividete in expand/contract.

**5. `$table->index()` su una tabella grande.**
Blocca le scritture per tutta la durata della costruzione. Usate `CREATE INDEX CONCURRENTLY` e `$withinTransaction = false`.

**6. Dimenticare `queue:restart`.**
I worker eseguono per settimane i job con il vecchio codice contro il nuovo schema.

**7. `php artisan down` senza la garanzia di `up`.**
Se lo script fallisce a metà, il sito resta in modalità manutenzione. Come minimo: `trap 'php artisan up' ERR`.

---

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

1. Codice, dipendenze, cache e asset della nuova release vengono preparati in una cartella separata.
2. `.env` e `storage/` sono spostati in `shared/`.
3. La commutazione avviene con `mv -T` del symlink, nginx usa `$realpath_root`.
4. Dopo la commutazione: `reload` di PHP-FPM e `queue:restart` (oppure `horizon:terminate`).
5. Ogni migrazione è compatibile con il codice della release precedente; indici con `CONCURRENTLY`; `lock_timeout` impostato.
6. I nuovi argomenti dei job hanno valori predefiniti.
7. Dopo la commutazione si verifica `/up`; in caso di esito negativo, rollback automatico alla release precedente.
8. Su disco vengono conservate alcune delle ultime release.

---

## Conclusione

Il deployment senza downtime non è uno strumento, ma una proprietà del sistema: il codice sa vivere in due versioni contemporaneamente. Symlink, `$realpath_root` e `queue:restart` risolvono la meccanica della commutazione. Ma il vero lavoro sta nella disciplina delle migrazioni e nella compatibilità dei job, che trasforma il rollback in un'operazione noiosa di un paio di secondi anziché in un incidente notturno.

---

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

### Domanda 1: Perché `ln -sfn new current` non è adatto alla commutazione atomica di una release?
- A) Il comando non funziona con i percorsi assoluti.
- B) Esegue due chiamate di sistema (unlink e symlink) e tra le due il link `current` non esiste.
- C) Copia i file della release invece di creare un link.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
`ln -sfn` elimina il vecchio link e ne crea uno nuovo. Nell'intervallo il web server può ricevere una richiesta e non trovare `current`. È atomica solo `rename(2)`, per questo il nuovo link si crea con un nome temporaneo e lo si rinomina tramite `mv -T`.
</details>

### Domanda 2: Bisogna rinominare una colonna usata dai worker delle code. Quale approccio è sicuro in un deployment senza downtime?
- A) Un'unica migrazione con `renameColumn` e l'aggiornamento simultaneo del codice.
- B) Aggiungere la nuova colonna, rilasciare il codice che scrive in entrambe, trasferire i dati ed eliminare la vecchia colonna in una release separata.
- C) Mettere il sito in modalità manutenzione solo per la durata della migrazione.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
Lo schema expand/contract garantisce che in qualsiasi momento sia il vecchio sia il nuovo codice trovino le colonne di cui hanno bisogno. L'opzione C non è più zero-downtime, mentre l'opzione A rompe i worker e le richieste che girano sulla vecchia release.
</details>

### Domanda 3: Perché in una migrazione con `CREATE INDEX CONCURRENTLY` si specifica `public $withinTransaction = false;`?
- A) Per far eseguire la migrazione più velocemente.
- B) PostgreSQL vieta `CREATE INDEX CONCURRENTLY` all'interno di un blocco transazionale, e Laravel per impostazione predefinita racchiude la migrazione in una transazione.
- C) Per creare l'indice su tutte le repliche contemporaneamente.

<details>
<summary><b>Mostra la risposta</b></summary>

**Risposta: B**
La costruzione concorrente di un indice è composta da diverse fasi con transazioni separate, quindi non è possibile all'interno di `BEGIN ... COMMIT`. La proprietà `$withinTransaction = false` disattiva il wrapper di Laravel per quella specifica migrazione.
</details>