Миграции Laravel изнутри: как работает батчинг и откат
Что происходит внутри artisan migrate: как Laravel решает, какие файлы прогонять, как считает батчи и почему rollback иногда откатывает не то, что вы ждёте.

Вы выкатили релиз, запустили php artisan migrate, а через минуту заметили, что новая колонка не появилась в проде. В логах — тишина, команда отрапортовала Nothing to migrate. Или наоборот: rollback снёс сразу восемь таблиц вместо одной, потому что все они попали в один батч. Оба случая — не баги, а следствие того, как Laravel отслеживает состояние миграций. Разберём механизм: где хранится история, как считается номер батча, что именно происходит при migrate и rollback, и где это ломается. Ориентир — Laravel 10.x и 11.x, поведение репозитория миграций в этих версиях совпадает.
Где живёт состояние: таблица migrations
Laravel не хранит «текущую версию схемы» одним числом, как это делают, например, Flyway или системы с монотонным номером ревизии. Вместо этого есть таблица migrations — журнал уже применённых файлов. Её создаёт команда php artisan migrate:install, но обычно она появляется автоматически при первом migrate.
Структура таблицы минимальна: три поля.
| Поле | Тип | Что означает |
|---|---|---|
| id | autoincrement | Технический ключ строки |
| migration | varchar | Имя файла без расширения .php |
| batch | integer | Номер группы, в которой миграция была применена |
Ключевой момент: Laravel сравнивает имена файлов в каталоге database/migrations с записями в столбце migration. Файл, которого нет в таблице, считается новым и подлежит прогону. Отсюда следует неочевидное: если вы переименуете уже применённый файл миграции, Laravel посчитает его новым и попытается выполнить заново — со всеми ошибками вроде table already exists.
Порядок выполнения
Новые файлы сортируются не по дате изменения на диске, а по имени. Стандартный префикс 2024_05_11_093000_ — это временная метка в лексикографическом виде, поэтому сортировка по строке даёт хронологический порядок. Если вы создадите миграцию руками с именем без такого префикса, она может встать не туда, куда вы ожидаете, и упасть на зависимости от ещё не созданной таблицы.
Что делает migrate: пошагово
Когда вы запускаете php artisan migrate, внутри происходит примерно такая последовательность:
- Репозиторий миграций (
DatabaseMigrationRepository) читает все записи из таблицыmigrations— это список уже применённых файлов. - Мигратор сканирует каталог
database/migrationsи вычитает из него уже применённые. Остаётся список pending-файлов. - Если список пуст — вы видите
Nothing to migrate. Команда завершается успешно, код возврата 0. - Репозиторий берёт максимальный существующий номер батча и прибавляет единицу. Это будет номер батча для текущего запуска.
- Для каждого файла по порядку вызывается метод
up(), а после успешного выполнения в таблицу пишется строка с именем файла и общим номером батча.
Отсюда главное правило: все миграции, прогнанные за один вызов migrate, получают один и тот же номер батча. Прогнали пять новых файлов — они все в батче номер, скажем, 7. Это и есть корень истории с массовым откатом.
Батч — это не «одна миграция», а «всё, что уехало в одном релизе». Laravel откатывает батчами, а не файлами, и это меняет представление о том, что такое «откатить последнюю миграцию».
Транзакции: не гарантированы по умолчанию
Распространённое заблуждение — что весь migrate выполняется в одной транзакции и при падении откатывается целиком. Это не так. Оборачивание каждой миграции в транзакцию зависит от драйвера СУБД: Laravel оборачивает up() в транзакцию, только если драйвер поддерживает транзакционный DDL. PostgreSQL это умеет, а MySQL с движком InnoDB — нет: большинство DDL-операций там неявно коммитятся.
Практическое следствие: если на MySQL миграция упала на середине (например, добавила колонку, но упала на создании индекса), схема останется в промежуточном состоянии, а строка в migrations не запишется. Повторный migrate попробует прогнать этот же файл заново — и снова упадёт на «колонка уже существует». Разгребать придётся руками.
Что делает rollback
Команда php artisan migrate:rollback без аргументов откатывает последний батч целиком. Не последний файл — последний батч. Механизм:
- Репозиторий находит максимальный номер батча в таблице.
- Выбирает все записи с этим номером, сортирует в обратном порядке.
- Для каждой вызывает метод
down(). - После успешного отката удаляет строку из таблицы
migrations.
Если в последний релиз уехало восемь миграций, migrate:rollback вызовет down() у всех восьми. Именно поэтому «откатил одну лишнюю таблицу» на деле означает «откатил весь батч».
Чтобы ограничить объём, есть флаг --step. С php artisan migrate:rollback --step=1 Laravel откатывает файлы по одному, а не батчами. Флаг --batch= позволяет откатить конкретный номер батча — уточните его наличие для вашей версии в выводе php artisan help migrate:rollback, поведение уточнялось между релизами.
Опасность пустого down()
Откат зовёт down(). Если вы оставили этот метод пустым или сгенерировали миграцию без него, rollback отработает «успешно», удалит строку из журнала — но схему не тронет. Формально миграция откачена, фактически изменения в базе остались. При следующем migrate файл прогонится снова и упадёт на уже существующем объекте.
Замеры и диагностика: что смотреть
Прежде чем что-то откатывать в проде, посмотрите фактическое состояние журнала. Не полагайтесь на память о том, что именно уехало в релиз.
-- Сколько миграций в каждом батче (любая СУБД)
SELECT batch, COUNT(*) AS files
FROM migrations
GROUP BY batch
ORDER BY batch DESC;
-- Что именно лежит в последнем батче
SELECT migration, batch
FROM migrations
WHERE batch = (SELECT MAX(batch) FROM migrations)
ORDER BY migration DESC;
Так вы увидите, сколько файлов снесёт rollback без --step. Отдельно полезна встроенная команда: php artisan migrate:status покажет по каждому файлу, применён он или нет, и номер батча. Это безопасно и не меняет состояние.
Где механизм ломается
- Редактирование применённой миграции. Laravel сверяет имена файлов, а не их содержимое. Изменили
up()в уже прогнанном файле — повторного запуска не будет, и на новой машине схема разъедется с той, что в проде. Нужно новое изменение — заводите новый файл миграции. - Ручное удаление строк из migrations. Удалили запись, но объект в базе остался — следующий
migrateупадёт на дубликате. Обратный случай: добавили строку руками без реального изменения схемы — миграция «пропущена» тихо. - Разные окружения с расхождением по батчам. Номер батча локально и в проде не обязан совпадать: он зависит от истории применения на конкретной базе. Скрипт, который жёстко ссылается на «батч 5», сломается на другой машине.
- MySQL и незавершённый DDL. Разбито выше: падение на середине миграции оставляет схему в промежуточном состоянии без записи в журнал.
- Squash-миграции. Команда
schema:dumpсобирает текущую схему в один файл, и Laravel на пустой базе накатывает дамп вместо старых файлов. Если вы держите старые миграции для истории и одновременно дамп — на новой базе применится дамп, а старые файлы будут проигнорированы. Это ожидаемо, но новичка сбивает.
Что делать дальше
В проде перед откатом всегда снимайте migrate:status и проверяйте состав последнего батча запросом выше — так вы не снесёте лишнего. На CI включите проверку, что каждая новая миграция имеет непустой down(), если ваша политика допускает откаты. Для критичных релизов на PostgreSQL опирайтесь на транзакционный DDL; на MySQL разбивайте рискованные изменения на отдельные файлы, чтобы падение не оставляло половину схемы. Читайте раздел Database: Migrations в официальной документации под вашу версию — там же описаны флаги --pretend (показать SQL без выполнения) и --step, которые стоит держать в рабочем арсенале.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Laravel Documentation — Database: Migrations
Частые вопросы
У меня другая версия — Laravel 9. Механизм тот же?
Почему migrate пишет Nothing to migrate, хотя я добавил колонку в миграцию?
Как откатить только одну миграцию, а не весь релиз?
Миграция упала на середине на MySQL — что делать?
Можно ли редактировать уже применённую миграцию?
Что произойдёт, если down() пустой, а я вызвал rollback?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.