---
title: 'Zero-downtime deployment для Laravel: атомарні релізи, міграції та черги | DevSense'
description: "Як викочувати Laravel без простою: релізи через symlink, OPcache і realpath, збирання асетів у CI, безпечні міграції PostgreSQL за схемою expand/contract і м'який перезапуск черг."
faq:
    - { question: 'Чому php artisan down не підходить для розгортання без простою?', answer: 'Команда переводить застосунок у режим обслуговування й віддає 503 усім користувачам, поки триває встановлення залежностей, міграції та прогрівання кешів. Це чесний, але запланований простій. До того ж, якщо скрипт упаде після down і до up, сайт залишиться недоступним до ручного втручання.' }
    - { question: 'Що таке атомарний реліз через symlink?', answer: 'Кожна версія коду збирається в окремій теці releases/<id>, а вебсервер дивиться на символьне посилання current. Коли нова версія повністю готова (залежності, кеші, асети), посилання перемикається однією атомарною операцією mv -T. Запити йдуть або в стару, або в нову версію, але ніколи — в наполовину оновлену.' }
    - { question: 'Як виконувати міграції, щоб старий і новий код працювали одночасно?', answer: 'За схемою expand/contract: спершу лише додавайте (нові колонки, таблиці, індекси CONCURRENTLY), потім викочуйте код, який пише в обидва місця, і тільки в наступному релізі видаляйте старе. Перейменування або видалення колонки в тому самому релізі, де змінюється код, ламає запити старих воркерів.' }
    - { question: 'Як перезапустити воркери черг без втрати задач?', answer: 'Командою php artisan queue:restart (або horizon:terminate для Horizon). Воркери доопрацьовують поточну задачу, завершуються, а Supervisor піднімає їх уже з новим кодом. Важливо, щоб серіалізовані задачі, поставлені старим кодом, залишалися сумісними з новим.' }
published: '2026-09-28'
---
# Zero-downtime deployment для Laravel: атомарні релізи, міграції та черги

Розгортання, яке «зазвичай проходить», небезпечніше за розгортання, що падає одразу. Нещодавно наш власний деплой упав на `git fetch`: прострочилися облікові дані для приватного репозиторію. Сайт вцілів лише тому, що `git fetch` стояв **до** `php artisan down`. Якби він був на кілька рядків нижче, сайт застряг би в режимі обслуговування на всю ніч. Більшість Laravel-проєктів розгортаються саме так: `down`, `git pull`, `composer install`, `migrate`, `up`. Це працює до першого збою посередині й щоразу показує користувачам 503.

**Пов'язані матеріали:** [Оточення, CI та деплой із Sail](../tools/sail-env-deploy) · [Порівняння черг повідомлень](message-queues-compared) · [Спостережуваність і моніторинг](observability-monitoring-laravel)

## Зміст

* [Звідки береться простій під час розгортання](#where-downtime-comes-from)
* [Головна ідея: готуємо поруч, перемикаємо атомарно](#core-idea)
* [Атомарні релізи: releases, shared і current](#atomic-releases)
* [PHP-FPM, OPcache і realpath: чому symlink «не перемикається»](#opcache)
* [Асети: збирання в CI і старі вкладки](#assets)
* [Міграції без блокувань: expand/contract](#migrations)
* [Черги та планувальник: м'який перезапуск](#queues)
* [Перевірка здоров'я і відкат](#health-and-rollback)
* [Де підхід перестає працювати](#limitations)
* [Типові помилки](#common-mistakes)
* [Чеклист](#checklist)
* [Квіз для самоперевірки](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## Звідки береться простій під час розгортання

Простій рідко має вигляд «сервер вимкнено». Частіше це кілька секунд чи хвилин, коли застосунок перебуває в проміжному стані:

* **`composer install` поверх робочого коду.** Поки пакети у `vendor/` перезаписуються, паралельні запити підвантажують суміш старих і нових класів. Результат — `Class not found` або фатальні помилки через несумісні сигнатури.
* **Кеш конфігурації від іншої версії.** `bootstrap/cache/config.php` зібрано старим кодом, а маршрути чи сервіс-провайдери вже нові. Поки не виконано `php artisan optimize`, застосунок живе з розсинхроном.
* **Міграції з блокуваннями.** `ALTER TABLE` на великій таблиці бере ексклюзивне блокування, і всі запити до неї стають у чергу. Для користувача це виглядає як сайт, що завис.
* **Воркери на старому коді.** Supervisor тримає процеси `queue:work`, завантажені до розгортання. Вони й далі виконують задачі старими класами проти нової схеми БД.
* **Асети не тієї версії.** `public/build/manifest.json` оновився раніше або пізніше за код, і сторінка посилається на CSS та JS, яких ще (або вже) немає.

Режим обслуговування маскує всі ці проблеми, показуючи 503. Але це не рішення, а чесне визнання простою.

---

<a id="core-idea"></a>
## Головна ідея: готуємо поруч, перемикаємо атомарно

**Розгортання без простою — це коли нова версія повністю збирається поруч зі старою, перемикання займає одну атомарну операцію, а схема БД у кожен момент сумісна і зі старим, і з новим кодом.**

З цього твердження випливають три правила:

1. Нічого не змінювати «на місці» в теці, з якої зараз обслуговуються запити.
2. Перемикання — одна операція, що не має проміжного стану.
3. Міграції й задачі в чергах проєктуються так, щоб дві версії коду могли жити одночасно.

---

<a id="atomic-releases"></a>
## Атомарні релізи: releases, shared і current

Структура на сервері:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← новий реліз
├── shared/
│   ├── .env
│   └── storage/                  ← логи, завантаження, сесії
└── current -> releases/20260927-2101-1d94729
```

Кожен реліз — окрема тека з повним кодом. Усе, що має переживати релізи (`.env`, `storage/`), лежить у `shared/` і підключається симлінками. Вебсервер дивиться в `current/public`.

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

APP=/var/www/app

# 1. Код нового релізу — в окрему теку, поточний сайт не чіпаємо
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. Спільні файли
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Залежності й кеші збираються до перемикання
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Міграції — лише сумісні з поточним кодом (див. expand/contract)
php artisan migrate --force

# 5. Атомарне перемикання: rename(2) не має проміжного стану
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Скидання OPcache і м'який перезапуск воркерів
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Зберігаємо останні 5 релізів для відкату
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` не атомарний.** Під капотом це два системні виклики: видалити старе посилання і створити нове. Між ними є мить, коли `current` не існує, і nginx віддає 404. Атомарно працює лише `rename(2)`, тобто `mv -T` поверх наявного посилання.

Якщо налаштовувати це вручну не хочеться, той самий підхід реалізують Deployer (`deployer/deployer`) і Laravel Envoyer.

---

<a id="opcache"></a>
## PHP-FPM, OPcache і realpath: чому symlink «не перемикається»

Поширена скарга: «посилання перемкнули, а сайт показує стару версію». Причина — два кеші:

* **realpath cache** у PHP запам'ятовує, у що розв'язується шлях `/var/www/app/current/...`;
* **OPcache** зберігає скомпільовані файли за розв'язаним шляхом. У продакшені зазвичай стоїть `opcache.validate_timestamps=0`, і PHP узагалі не перевіряє, чи змінилися файли.

Рішення складається з двох частин. Спершу nginx має передавати в PHP уже розв'язаний шлях, а не шлях через симлінк:

```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` розв'язує симлінк на кожен запит. Нові запити надходять зі шляхом `releases/20260928-...`, і OPcache компілює їх як нові файли. Далі після перемикання потрібен `reload` PHP-FPM (він м'який: поточні запити доопрацьовують) або `opcache_reset()` через `cachetool`, щоб звільнити пам'ять від старого релізу.

---

<a id="assets"></a>
## Асети: збирання в CI і старі вкладки

Збирати фронтенд на продакшн-сервері — зайве навантаження і зайва залежність (Node.js). Краще зібрати в CI і завантажити в теку **нового релізу** до перемикання:

```yaml
# .github/workflows/deploy-prod.yml (фрагмент)
- 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 додає хеш до імен файлів (`app-TDO2OnyO.js`), а `manifest.json` лежить усередині релізу. Тому старий код посилається на старі файли, новий — на нові, і перемикання симлінка змінює їх разом із PHP-кодом.

Залишається одна тонкість: у користувача може бути відкрита вкладка зі старою сторінкою, яка за хвилину запросить чанк із лінивим завантаженням. Якщо його вже немає, виникне помилка завантаження модуля. Тому тримайте кілька попередніх релізів на диску або завантажуйте асети в спільний CDN-бакет, не видаляючи старих хешів.

---

<a id="migrations"></a>
## Міграції без блокувань: expand/contract

Міграції виконуються **до** перемикання, отже якийсь час старий код працює з новою схемою. Звідси правило: кожна міграція має бути сумісною з кодом попереднього релізу. Для цього зміни схеми розбивають на фази.

**Приклад: перейменувати `users.name` на `users.full_name`.**

| Реліз | Схема | Код |
|---|---|---|
| 1. Expand | Додати `full_name` (nullable) | Пише в обидві колонки, читає `name` |
| 2. Migrate | Фонова задача копіює дані | Читає `full_name`, пише в обидві |
| 3. Contract | Видалити `name` | Працює лише з `full_name` |

Одним релізом із `renameColumn` цього не зробити: старі воркери впадуть на колонці, якої не існує.

Для PostgreSQL важливо ще й те, **які блокування бере міграція**:

```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 не можна виконувати всередині транзакції
    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');
    }
};
```

Що тут важливо:

* **`CONCURRENTLY`** будує індекс без блокування запису. Звичайний `$table->index()` блокує таблицю на весь час побудови.
* **`lock_timeout`** не дає міграції нескінченно чекати на блокування. Інакше за нею вишикується черга з усіх інших запитів до таблиці. Краще впасти через 5 секунд і повторити, ніж покласти сайт.
* **Колонка з `DEFAULT`** у PostgreSQL 11+ додається миттєво, але `NOT NULL` на наявній колонці потребує повного сканування. Безпечний шлях: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, потім `VALIDATE CONSTRAINT`, потім `SET NOT NULL` (починаючи з PostgreSQL 12 він використовує вже перевірене обмеження і не сканує таблицю).

> [!WARNING]
> **Відкат коду не відкочує схему.** `migrate:rollback` на продакшені з даними майже завжди гірший за нову міграцію «вперед». Саме expand/contract робить відкат коду безпечним: попередня версія й далі працює з розширеною схемою.

---

<a id="queues"></a>
## Черги та планувальник: м'який перезапуск

Воркер `queue:work` — це довгоживучий процес, який завантажив код один раз. Після перемикання релізу він і далі виконуватиме задачі старим кодом, доки його не перезапустять.

```bash
php artisan queue:restart      # для queue:work під Supervisor
php artisan horizon:terminate  # для Horizon
```

Обидві команди м'які: воркер доопрацьовує поточну задачу й завершується, а Supervisor піднімає новий процес уже з `current`. Задачі не губляться.

Друга проблема — **сумісність задач**. Задача, поставлена в чергу старим кодом, серіалізована з його набором властивостей. Якщо новий код перейменував властивість або додав обов'язковий аргумент конструктора, десеріалізація зламається:

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

    public function __construct(
        public readonly int $orderId,
        // Нове поле — зі значенням за замовчуванням, щоб задачі
        // зі старого релізу успішно десеріалізувалися
        public readonly string $locale = 'en',
    ) {}
}
```

Планувальнику (`schedule:run` із cron) перезапуск не потрібен: cron щохвилини запускає новий процес, який уже бачить оновлений `current`.

---

<a id="health-and-rollback"></a>
## Перевірка здоров'я і відкат

Перемикання не завершує розгортання. Треба переконатися, що нова версія жива:

```bash
# Після перемикання: health-маршрут 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

# Не піднялася — повертаємо попередній реліз тим самим атомарним перемиканням
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
```

Відкат займає секунди, бо попередній реліз лежить на диску цілком, із `vendor/` і зібраними асетами. Схема БД при цьому лишається новою — і саме тому вона має бути зворотно сумісною.

---

<a id="limitations"></a>
## Де підхід перестає працювати

* **Один сервер — це не відмовостійкість.** Симлінк прибирає простій під час розгортання, але не під час падіння сервера. Для цього потрібні кілька нод за балансувальником і rolling- або blue-green-розгортання.
* **Деякі міграції неможливо виконати онлайн.** Зміна типу колонки з перезаписом таблиці, розділення великої таблиці, перенесення даних між базами. Іноді чесне вікно обслуговування вночі дешевше, ніж тижнева схема expand/contract.
* **Сесії та кеш.** Якщо нова версія змінює формат даних у сесії або ключі кешу, користувачі зі старими сесіями отримають помилки. Версіонуйте префікси кешу і не змінюйте формат сесії без зворотної сумісності.
* **Складність.** Для pet-проєкту з десятьма відвідувачами на годину `down`/`up` з ERR-trap може бути розумним компромісом. Головне — робити це свідомо і так, щоб збій скрипта не залишав сайт у maintenance.

---

<a id="common-mistakes"></a>
## Типові помилки

**1. `git pull` і `composer install` у теці, з якої обслуговуються запити.**
Паралельні запити бачать суміш версій. Збирайте реліз в окремій теці.

**2. `ln -sfn` для перемикання.**
Це не атомарно. Використовуйте `ln -s` у тимчасове ім'я та `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` у nginx.**
PHP кешує шлях через симлінк і далі віддає старий код. Використовуйте `$realpath_root`.

**4. Перейменування колонки в одному релізі з кодом.**
Старі воркери й запити в момент перемикання впадуть. Розбивайте на expand/contract.

**5. `$table->index()` на великій таблиці.**
Блокує запис на весь час побудови. Використовуйте `CREATE INDEX CONCURRENTLY` і `$withinTransaction = false`.

**6. Забути `queue:restart`.**
Воркери тижнями виконують задачі старим кодом проти нової схеми.

**7. `php artisan down` без гарантії `up`.**
Якщо скрипт упаде посередині, сайт залишиться в режимі обслуговування. Мінімум — `trap 'php artisan up' ERR`.

---

<a id="checklist"></a>
## Чеклист

1. Код, залежності, кеші й асети нового релізу готуються в окремій теці.
2. `.env` і `storage/` винесено в `shared/`.
3. Перемикання — `mv -T` симлінка, nginx використовує `$realpath_root`.
4. Після перемикання — `reload` PHP-FPM і `queue:restart` (або `horizon:terminate`).
5. Кожна міграція сумісна з кодом попереднього релізу; індекси — `CONCURRENTLY`; встановлено `lock_timeout`.
6. Нові аргументи задач мають значення за замовчуванням.
7. Після перемикання перевіряється `/up`, у разі невдачі — автоматичний відкат на попередній реліз.
8. На диску зберігаються кілька останніх релізів.

---

## Підсумок

Розгортання без простою — це не інструмент, а властивість системи: код уміє жити у двох версіях одночасно. Симлінки, `$realpath_root` і `queue:restart` розв'язують механіку перемикання. Але справжня робота — в дисципліні міграцій і сумісності задач, яка робить відкат нудною операцією на пару секунд, а не нічним інцидентом.

---

<a id="self-test-quiz"></a>
## Квіз для самоперевірки

### Питання 1: Чому `ln -sfn new current` не підходить для атомарного перемикання релізу?
- А) Команда не працює з абсолютними шляхами.
- Б) Вона виконує два системні виклики (unlink і symlink), і між ними посилання `current` не існує.
- В) Вона копіює файли релізу замість створення посилання.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
`ln -sfn` видаляє старе посилання і створює нове. У проміжку вебсервер може отримати запит і не знайти `current`. Атомарний лише `rename(2)`, тому нове посилання створюють під тимчасовим іменем і перейменовують через `mv -T`.
</details>

### Питання 2: Потрібно перейменувати колонку, з якою працюють воркери черги. Який підхід безпечний під час розгортання без простою?
- А) Одна міграція з `renameColumn` і одночасне оновлення коду.
- Б) Додати нову колонку, викотити код, що пише в обидві, перенести дані й видалити стару колонку окремим релізом.
- В) Перевести сайт у режим обслуговування лише на час міграції.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Схема expand/contract гарантує, що в будь-який момент і старий, і новий код знаходять потрібні колонки. Варіант В — це вже не zero-downtime, а варіант А ламає воркери й запити, що працюють на старому релізі.
</details>

### Питання 3: Навіщо в міграції з `CREATE INDEX CONCURRENTLY` вказують `public $withinTransaction = false;`?
- А) Щоб міграція виконувалася швидше.
- Б) PostgreSQL забороняє `CREATE INDEX CONCURRENTLY` всередині транзакційного блоку, а Laravel за замовчуванням обгортає міграцію в транзакцію.
- В) Щоб індекс створився на всіх репліках одночасно.

<details>
<summary>Показати правильну відповідь</summary>

**Правильна відповідь: Б**
Конкурентна побудова індексу складається з кількох фаз з окремими транзакціями, тому всередині `BEGIN ... COMMIT` вона неможлива. Властивість `$withinTransaction = false` вимикає обгортку Laravel для конкретної міграції.
</details>