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