<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Як робити міграції в Laravel без простою і чому не редагувати вже виконані?

Таблиця `migrations` зберігає лише імена вже виконаних файлів, тому правка старої міграції на проді не виконується ніколи і дає розʼїзд схеми між середовищами: зміну оформлюють новим файлом. Без простою це три окремі кроки в різних релізах - nullable-колонка, бекфіл порціями поза міграцією, і аж потім NOT NULL через `CHECK ... NOT VALID` та індекс `->online()` у міграції з `$withinTransaction = false`.

Виправив друкарську помилку в міграції, яка вже пішла на прод. Чому після деплою в базі нічого не змінилось?
Треба додати NOT NULL колонку в таблицю на 40 мільйонів рядків. Скільки лежатиме сайт?
Міграція падає з `CREATE INDEX CONCURRENTLY cannot run inside a transaction block`, хоча на MySQL той самий код працював. Що відбувається?
Що робить `php artisan schema:dump --prune` і чи не зламає він прод, де половина цих міграцій уже виконана?
міграції zero-downtime PostgreSQL schema:dump деплой

У таблиці migrations лежать усього два стовпці: migration (імʼя файлу без .php) і batch. Коли ви запускаєте php artisan migrate, Migrator читає теку database/migrations, віднімає від неї вже записані імена і виконує різницю в порядку сортування за іменем. Вмісту файлу він не хешує й не звіряє. Тож виправлення в міграції, яка вже виконана на проді, там не виконається ніколи. Локально після migrate:fresh усе виглядатиме правильно, у колеги, який щойно клонував репозиторій, теж, а на проді лишиться стара структура, і різниця виявиться тижнем пізніше через SQLSTATE[42703]: Undefined column. Звідси правило: змерджену міграцію не редагують, а дописують наступну. Правити файл можна хіба доти, доки гілка не влита в main і ніде не застосована.

Друга половина питання про те, чому «швидкий ALTER» кладе сайт. У PostgreSQL майже будь-який ALTER TABLE бере ACCESS EXCLUSIVE lock, і біда не в самому очікуванні, а в черзі, яка за ним вишиковується: якщо в цю мить якийсь SELECT тримає таблицю двадцять секунд, ваш ALTER чекає на нього, а всі наступні читання чекають уже на ALTER. Таблиця виглядає мертвою, хоча жодна операція ще не почалась. Запобіжник простий: SET lock_timeout перед DDL. Міграція, яка чесно впала за три секунди й перезапустилась, обходиться дешевше, ніж хвилина глухих таймаутів у застосунку. Ціна самих операцій теж дуже різна. Додати nullable-колонку без default у PostgreSQL 11+ і MySQL 8.0.12+ означає запис у метадані (у MySQL це ALGORITHM=INSTANT, доступний у Laravel як ->instant() на визначенні колонки), а от ALTER COLUMN ... SET NOT NULL тягне за собою повний скан таблиці під важким локом. MySQL при перетворенні колонки на NOT NULL перебудовує таблицю, хоч і дозволяє паралельний DML з ALGORITHM=INPLACE, LOCK=NONE (у Laravel це модифікатор ->lock('none')).

Тому одна логічна зміна розкладається на три релізи. Перший додає $table->string('currency', 3)->nullable(): старий код, який ще крутиться на половині подів, про колонку не знає, новий уже вміє її писати. Другий заповнює дані, і робити це треба поза міграцією, артизан-командою або queued job з chunkById() по 1-5 тисяч рядків і паузою між пачками. По-перше, деплой не має стояти годину. По-друге, такий процес треба вміти зупинити й продовжити з того ж місця, чого міграція не вміє за визначенням. Третій реліз затягує гайки: CHECK (currency IS NOT NULL) NOT VALID створюється миттєво, VALIDATE CONSTRAINT сканує таблицю під легким SHARE UPDATE EXCLUSIVE, не блокуючи запис, а SET NOT NULL у PostgreSQL 12+ бачить валідний CHECK і повторно таблицю не читає. У зворотний бік, коли колонку треба прибрати, порядок той самий: спершу код перестає її згадувати, і лише наступним релізом іде dropColumn.

Для індексів на живій таблиці в Laravel є окремий явний API. $table->index('currency')->online() компілюється в create index concurrently для PostgreSQL, тобто індекс будується без блокування запису, ціною двох проходів по таблиці. Пастка в тому, що PostgresGrammar::supportsSchemaTransactions() повертає true, Laravel за замовчуванням загортає кожну міграцію в транзакцію, а CONCURRENTLY всередині транзакції заборонений. Лікується властивістю public $withinTransaction = false; у класі міграції. За це доведеться заплатити: без транзакції перервана міграція лишає половину роботи застосованою, а невдалий concurrent-індекс осідає в каталозі як невалідний (pg_index.indisvalid = false), планувальник його не бере, і перед повторною спробою потрібен DROP INDEX CONCURRENTLY. У MySQL такої проблеми немає з іншої причини: DDL там і так не транзакційний, кожен ALTER фіксується сам по собі, тож недовиконана міграція за замовчуванням лишає базу в проміжному стані. Саме тому одна міграція має робити одну зміну.

Коли файлів у database/migrations набирається кілька сотень, migrate:fresh у тестах починає коштувати помітних хвилин. Тут допомагає php artisan schema:dump --prune: Laravel викликає pg_dump чи mysqldump, кладе схему в database/schema/{connection}-schema.sql разом із рядками таблиці migrations і видаляє старі файли. Прод при цьому не постраждає, бо MigrateCommand::prepareDatabase() підвантажує дамп лише тоді, коли hasRunAnyMigrations() повертає false: на базі, де міграції вже виконувались, файл просто ігнорується. Squash економить час на чистих базах і нічого не змінює для наявних. І останнє, що чути у відповіді людини, яка сама чергувала на релізі: down() на проді не рятує. Дані, видалені в up(), не повернуться, migrate:rollback без --step відкотить усю останню пачку, а не одну міграцію, і кожна міграція з DB::statement та вимкненою транзакцією взагалі не має чесного зворотного шляху. Пишіть down() заради локальної роботи й CI, а на проді плануйте forward fix, ще одну міграцію вперед.

// Реліз 1: 2026_03_02_090000_add_currency_to_orders.php
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table): void {
            // nullable, без default: PostgreSQL 11+ і MySQL 8.0.12+ роблять це
            // записом у метадані. Старі поди колонки не бачать і не падають.
            $table->string('currency', 3)->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('orders', fn (Blueprint $table) => $table->dropColumn('currency'));
    }
};

// Реліз 2: бекфіл живе в artisan-команді, а не в міграції.
Order::query()->whereNull('currency')->orderBy('id')
    ->chunkById(2_000, function (Collection $orders): void {
        Order::whereKey($orders->modelKeys())->update(['currency' => 'UAH']);
        usleep(50_000); // пауза під реплікацію й autovacuum
    });

// Реліз 3: 2026_03_09_090000_require_order_currency.php,
// коли бекфіл завершено й новий код пише currency в кожен INSERT.
return new class extends Migration
{
    // Без цього CREATE INDEX CONCURRENTLY впаде: PostgreSQL-грамматика
    // загортає міграцію в транзакцію (supportsSchemaTransactions() === true).
    public $withinTransaction = false;

    public function up(): void
    {
        DB::statement("SET lock_timeout = '3s'"); // не тримати чергу запитів

        // NOT VALID не сканує таблицю, VALIDATE бере лише SHARE UPDATE EXCLUSIVE,
        // а PG 12+ використає валідний CHECK і поставить NOT NULL без скану.
        DB::statement('ALTER TABLE orders ADD CONSTRAINT orders_currency_nn CHECK (currency IS NOT NULL) NOT VALID');
        DB::statement('ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn');
        DB::statement('ALTER TABLE orders ALTER COLUMN currency SET NOT NULL');
        DB::statement('ALTER TABLE orders DROP CONSTRAINT orders_currency_nn');

        // ->online() дає "create index concurrently" (PostgreSQL/SQL Server).
        Schema::table('orders', fn (Blueprint $table) => $table->index('currency')->online());
    }
};
Що `Migrator` порівнює імена файлів із рядками таблиці `migrations` і запускає лише те, чого там немає: відредагований файл уже записаний, тож на проді він мертвий, а на свіжій базі колеги виконається в новій редакції - це і є розʼїзд схеми.
Що довга міграція блокує не сама себе, а чергу за собою: `ALTER TABLE` бере ACCESS EXCLUSIVE, і поки він чекає на відкриту транзакцію, усі наступні `SELECT` до тієї ж таблиці стають у чергу за ним; звідси `SET lock_timeout` перед DDL.
Розбиття на три релізи: nullable-колонка без default → бекфіл батчами окремою командою → NOT NULL і індекс, коли новий код уже пише значення в кожен INSERT.
Що в Laravel міграції загортаються в транзакцію лише там, де грамматика це підтримує (PostgreSQL і SQL Server; для MySQL `supportsSchemaTransactions()` повертає false), і що `CREATE INDEX CONCURRENTLY` вимагає `public $withinTransaction = false`.
Що бекфіл на мільйони рядків не місце в міграції: деплой не повинен чекати годину, а команду треба вміти зупинити й продовжити з того ж місця.
Що `down()` на проді не повертає дані, а `migrate:rollback` відкочує всю останню пачку, тому стандартна реакція на зламану міграцію - forward fix новим файлом.
Правити вже змерджену міграцію «щоб історія була чиста»: локально після `migrate:fresh` усе гаразд, на проді стара схема, і різницю ніхто не бачить, поки не впаде запит до неіснуючої колонки.
Додавати колонку одразу `NOT NULL` з `default` і вважати, що це безпечно всюди: PostgreSQL 11+ і MySQL 8.0.12+ впораються метаданими, а MySQL 5.7 перепише таблицю цілком.
Робити бекфіл одним `DB::table('orders')->update([...])` усередині міграції: один UPDATE на всю таблицю тримає блокування, роздуває WAL і кладе реплікаційний лаг.
Створювати індекс звичайним `$table->index('currency')` на живій таблиці в PostgreSQL: запис у таблицю стоїть, поки індекс будується.
Ставити `->online()` і не прибрати транзакцію: міграція падає з `cannot run inside a transaction block`, бо PostgreSQL-грамматика загортає її в транзакцію за замовчуванням.
Запускати `php artisan migrate` в entrypoint кожного контейнера: три репліки стартують одночасно, і дві падають на дублікаті або на вже створеній таблиці.
Дропати колонку в тому ж релізі, що й код: старі поди ще живі й досі пишуть у неї в `INSERT`.
Після `schema:dump --prune` дописувати зміни у сам `pgsql-schema.sql` замість нової міграції: дамп застосовується лише до порожньої бази, тож прод його ніколи не побачить.
ПОРАДА

Скажіть вголос просте правило: міграція - це запис в журналі, а не файл конфігурації, тому її не редагують, а доповнюють новою. Далі покажіть трикрокову схему (nullable → бекфіл окремою командою → NOT NULL і `->online()`-індекс) і згадайте `lock_timeout`: інтервʼюер чує, що ви вже ловили прод, який ліг не від самого `ALTER`, а від черги за ним.

оновлено 20 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`Migrator` порівнює імена файлів із рядками таблиці `migrations`, жодного хешування вмісту там немає: відредагований файл уже позначений виконаним і більше не запуститься, а на порожній базі виконається в новій редакції - звідси різні схеми на проді й локально. Транзакцію Laravel не знімає автоматично: рішення приймає `supportsSchemaTransactions()` грамматики (true для PostgreSQL і SQL Server) плюс властивість `$withinTransaction` самої міграції, тому `concurrently` без `public $withinTransaction = false` падає. Дамп зі `schema:dump` підвантажується в `prepareDatabase()` лише тоді, коли `hasRunAnyMigrations()` повертає false, тобто на проді з непорожньою таблицею `migrations` він не застосовується взагалі.