Миграции в Laravel: разбор на примере реального проекта
Как в Laravel устроен механизм миграций, почему добавление NOT NULL-колонки роняет деплой на большой таблице и что делать с down-методом, который никто не проверяет.

Деплой встал на 40 секунд, приложение вернуло 502, а в логе — General error: 1205 Lock wait timeout exceeded. Причина банальна: миграция добавила колонку status с NOT NULL и default в таблицу заказов на 4 миллиона строк, MySQL 8 заблокировал её на время операции, а входящие запросы уперлись в блокировку. Разберём, как Laravel исполняет миграции, где механизм ведёт себя не так, как ждёт разработчик, и как писать миграции, которые не кладут прод.
Всё ниже — для Laravel 11.x и 10.x (поведение батчей и schema:dump в этих версиях одинаковое). Драйвер по умолчанию — MySQL 8 / MariaDB 10.6; отличия для PostgreSQL отмечены отдельно.
Как Laravel исполняет миграции
Команда php artisan migrate делает три вещи. Сначала читает таблицу migrations — в ней хранится, какие файлы уже применены и в каком батче1. Потом сравнивает список файлов в каталоге database/migrations с этой таблицей и берёт те, которых там нет. Затем оборачивает каждый файл в вызов up() и, если драйвер это поддерживает, в транзакцию.
Ключевой момент, на котором спотыкаются: MySQL не откатывает DDL внутри транзакции. Операторы вроде CREATE TABLE и ALTER TABLE вызывают неявный commit. Поэтому если миграция для MySQL упала на середине — половина изменений применилась, а строки в migrations нет. Laravel считает миграцию непрогнанной, при повторном запуске снова пытается создать уже существующую таблицу и падает с Table already exists.
В PostgreSQL DDL транзакционный: та же миграция откатится целиком, состояние останется чистым. Это не мелочь, а разница в стратегии восстановления после сбоя.
Транзакция вокруг миграции защищает вас ровно настолько, насколько её поддерживает СУБД. На MySQL «атомарная миграция» — иллюзия: одна многошаговая миграция может оставить базу в половинчатом состоянии.
Разбор на примере: добавляем колонку в большую таблицу
Задача — добавить в orders поле status. Очевидное решение выглядит так:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
// Проблема: NOT NULL + default на 4 млн строк
// заставит MySQL переписать таблицу под блокировкой
$table->string('status')->default('new');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};
На пустой таблице это отработает за миллисекунды. На боевой таблице в несколько миллионов строк MySQL 8 в большинстве случаев выполнит ADD COLUMN как INSTANT-операцию (без переписывания), но это зависит от версии, движка и того, добавляется ли колонка в конец. Как только вы добавляете NOT NULL с вычисляемым значением или колонку не в конец таблицы, движок может свалиться в COPY — полную перезапись с блокировкой на запись.
Проверить, как именно пройдёт операция, до деплоя можно, сняв план на копии базы. Точный набор поддерживаемых ALGORITHM=INSTANT операций описан в документации MySQL — сверяйтесь с ней для вашей версии, а не с общими советами.
Как переписать безопасно
Разбейте одну рискованную миграцию на несколько дешёвых шагов:
- Добавьте колонку nullable, без default. Такая операция на MySQL 8 обычно проходит как INSTANT — без блокировки и без перезаписи.
- Заполните значение батчами — отдельной командой или очередью, кусками по несколько тысяч строк, а не одним
UPDATEна всю таблицу. - Только потом навесьте NOT NULL и default — отдельной миграцией, когда все строки уже заполнены.
Первый шаг тогда выглядит так:
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
// nullable без default — дешёвая операция на MySQL 8
$table->string('status')->nullable();
});
}
Заполнение выносится из миграции. Держать бэкфилл внутри up() — плохая идея: он выполняется синхронно в момент деплоя и блокирует его на всё время обхода таблицы.
down() — метод, который никто не проверяет
Разработчики пишут up(), запускают migrate, видят зелёный статус и идут дальше. down() при этом остаётся ненаписанным или неверным. А потом на стейджинге нужен migrate:rollback, и он падает.
Частая ошибка — dropColumn сразу нескольких колонок в SQLite или в старых версиях, где это не поддерживается одной командой. Вторая — забыть, что rollback откатывает весь последний батч, а не одну миграцию. Если вы прогнали пять файлов одной командой migrate, все они попали в один батч, и migrate:rollback откатит все пять.
| Команда | Что откатывает | Когда применять |
|---|---|---|
migrate:rollback | Последний батч целиком | Откат неудачного деплоя |
migrate:rollback --step=1 | N шагов от конца | Точечный откат |
migrate:reset | Все миграции | Только dev |
migrate:fresh | Дропает все таблицы и накатывает заново | Только dev, данные теряются |
Правило простое: если у миграции нет корректного down(), который проверен на копии базы, — считайте, что отката нет. На проде откат миграции, которая уже переписала данные, часто невозможен в принципе: dropColumn вернёт структуру, но не удалённые значения.
Порядок и зависимости между файлами
Laravel исполняет миграции в порядке имён файлов — по префиксу с датой. Если миграция А создаёт таблицу с внешним ключом на таблицу из миграции Б, а Б по алфавиту позже — вы получите Cannot add foreign key constraint. При make:migration фреймворк ставит timestamp с точностью до секунды, и два файла, созданные в одну секунду, могут отсортироваться непредсказуемо относительно друг друга.
Чтобы это не всплыло на чистой базе (CI гоняет миграции с нуля, а не на вашей уже мигрированной локальной), проверяйте деплой командой php artisan migrate:fresh на тестовой БД перед мержем.
schema:dump вместо сотни старых файлов
Когда файлов миграций накопились сотни, migrate:fresh в CI начинает занимать десятки секунд. Laravel умеет схлопнуть историю: php artisan schema:dump сохраняет текущую структуру в один SQL-файл в database/schema, а --prune удаляет схлопнутые файлы миграций.
# схлопнуть схему и удалить старые файлы миграций
php artisan schema:dump --prune
После этого migrate сначала загружает дамп, а потом накатывает только миграции, появившиеся после него. Подвох: дамп генерируется под конкретный драйвер. Если локально SQLite, а на проде MySQL — дамп под SQLite на проде не примется. Держите дамп под тот драйвер, что в CI и на проде.
Что делать дальше
Настройте в CI прогон migrate:fresh на той же СУБД, что в проде, — это ловит порядок файлов и несовместимость драйверов до деплоя. Для боевых баз возьмите за правило: любая миграция, меняющая большую таблицу, разбивается на nullable-колонку, батч-бэкфилл и финальное ограничение. Замерьте время выполнения каждой миграции на копии прод-базы того же объёма — именно объём, а не структура, определяет, ляжет деплой или нет.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Laravel — Database: Migrations, MySQL 8.0 Reference — Online DDL Operations
Частые вопросы
У меня Laravel 9, эти команды работают?
Миграция упала на середине, теперь migrate падает с 'Table already exists'. Что делать?
Грозит ли потеря данных при migrate:rollback на проде?
Можно ли класть бэкфилл данных внутрь up()?
Почему ADD COLUMN на проде заблокировал таблицу, хотя должен быть быстрым?
Как проверить порядок миграций до деплоя?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.