---
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), след това пуснете код, който пише и на двете места, и едва в следващия релийз премахнете старото. Преименуването или изтриването на колона в същия релийз, в който се променя кодът, чупи заявките на старите worker-и.' }
    - { question: 'Как да рестартирате worker-ите на опашките, без да губите задачи?', answer: 'С командата php artisan queue:restart (или horizon:terminate за Horizon). Worker-ите довършват текущата задача и спират, а 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` е генериран от стария код, а маршрутите или service provider-ите вече са нови. Докато не се изпълни `php artisan optimize`, приложението работи в разсинхрон.
* **Миграции със заключвания.** `ALTER TABLE` върху голяма таблица взема ексклузивно заключване и всички заявки към нея чакат на опашка. За потребителя това изглежда като забил сайт.
* **Worker-и със стар код.** 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/` и се свързва със symlink-ове. Уеб сървърът гледа към `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 и плавен рестарт на worker-ите
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 вече разрешения път, а не пътя през 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` разрешава symlink-а при всяка заявка. Новите заявки идват с пътя `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` се намира вътре в релийза. Затова старият код сочи към старите файлове, новият — към новите, и превключването на symlink-а ги сменя заедно с PHP кода.

Остава една тънкост: потребителят може да има отворен таб със стара страница, която след минута ще поиска lazy-зареждан chunk. Ако него вече го няма, ще се получи грешка при зареждане на модула. Затова пазете няколко предишни релийза на диска или качвайте асетите в общ 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`: старите worker-и ще паднат на несъществуваща колона.

При 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 той използва вече проверения constraint и не сканира таблицата).

> [!WARNING]
> **Връщането на кода назад не връща схемата.** `migrate:rollback` в продукция с реални данни почти винаги е по-лош вариант от нова миграция „напред“. Именно expand/contract прави връщането на кода безопасно: предишната версия продължава да работи с разширената схема.

---

<a id="queues"></a>
## Опашки и планировчик: плавен рестарт

Worker-ът `queue:work` е дълготраен процес, който е заредил кода веднъж. След превключването на релийза той ще продължи да изпълнява задачите със стария код, докато не бъде рестартиран.

```bash
php artisan queue:restart      # за queue:work под Supervisor
php artisan horizon:terminate  # за Horizon
```

И двете команди са плавни: worker-ът довършва текущата задача и спира, а 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>
## Кога подходът спира да работи

* **Един сървър не е отказоустойчивост.** Symlink-ът премахва прекъсването при деплой, но не и при срив на сървъра. За това са нужни няколко нода зад балансьор и 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 кешира пътя през symlink-а и продължава да сервира стария код. Използвайте `$realpath_root`.

**4. Преименуване на колона в един релийз с кода.**
Старите worker-и и заявките в момента на превключване ще паднат. Разделяйте на expand/contract.

**5. `$table->index()` върху голяма таблица.**
Блокира записа за цялото време на изграждане. Използвайте `CREATE INDEX CONCURRENTLY` и `$withinTransaction = false`.

**6. Забравен `queue:restart`.**
Worker-ите със седмици изпълняват задачи със стария код срещу новата схема.

**7. `php artisan down` без гаранция за `up`.**
Ако скриптът се провали по средата, сайтът остава в режим на поддръжка. Минимумът е `trap 'php artisan up' ERR`.

---

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

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

---

## Обобщение

Деплоят без прекъсване не е инструмент, а свойство на системата: кодът може да съществува в две версии едновременно. Symlink-овете, `$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: Трябва да преименувате колона, с която работят worker-ите на опашката. Кой подход е безопасен при деплой без прекъсване?
- А) Една миграция с `renameColumn` и едновременно обновяване на кода.
- Б) Добавяне на нова колона, пускане на код, който пише в двете, пренос на данните и премахване на старата колона в отделен релийз.
- В) Превключване на сайта в режим на поддръжка само за времето на миграцията.

<details>
<summary>Покажи правилния отговор</summary>

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

### Въпрос 3: Защо в миграция с `CREATE INDEX CONCURRENTLY` се задава `public $withinTransaction = false;`?
- А) За да се изпълнява миграцията по-бързо.
- Б) PostgreSQL забранява `CREATE INDEX CONCURRENTLY` в транзакционен блок, а Laravel по подразбиране обвива миграцията в транзакция.
- В) За да се създаде индексът едновременно на всички реплики.

<details>
<summary>Покажи правилния отговор</summary>

**Правилен отговор: Б**
Конкурентното изграждане на индекс се състои от няколко фази с отделни транзакции, затова вътре в `BEGIN ... COMMIT` то е невъзможно. Свойството `$withinTransaction = false` изключва обвивката на Laravel за конкретната миграция.
</details>