Миграции, сиды и фабрики: управление схемой и тестовыми данными
Миграции — это версия истории схемы базы данных в PHP-коде: каждая миграция описывает одно изменение структуры (создать таблицу, добавить колонку, индекс) и умеет его откатить. Сиды — это наполнение базы базовыми данными, без которых приложение не работает: справочники, роли администратора. Фабрики — генераторы фейковых, но правдоподобных данных для разработки и тестов. Понимать связку нужно, потому что именно здесь чаще всего теряют данные в проде: миграция правится после того, как её накатили на боевую базу, или migrate:fresh выполняется не на той среде. А фабрики и сиды — то, что отличает проект с автоматизированными тестами от проекта, где данные для проверки создают руками.
Зачем это нужно
Боль из практики: в проекте менеджер попросил добавить поле «комментарий к заказу». Разработчик открыл уже накатанную миграцию и дописал в неё строку:
php// ПЛОХО: правим миграцию, которая уже накатана в проде
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
$table->text('comment')->nullable(); // дописали «на ходу»
$table->timestamps();
});
}
Локально всё работало — там миграцию выполнили заново. В проде миграция уже выполнялась месяц назад, migrate не запустит её второй раз — и колонка просто не появится, а деплой «успешно» пройдёт. Через неделю замечают: заказы падают, потому что код обращается к несуществующей колонке. Исправление — schema-изменения делаются новой миграцией:
php// ХОРОШО: изменение схемы — отдельная миграция
php artisan make:migration add_comment_to_orders_table
phppublic function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->text('comment')->nullable();
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('comment');
});
}
Плюс вторая мина: если миграцию, которая создаёт таблицу, всё-таки правят, то у старых сред и деплой-серверов она уже отработала в старой версии — и разные базы оказываются в разных состояниях, что не видно до первого падения.
Как это работает
Миграции — парные методы up() (изменение) и down() (откат):
php// php artisan make:migration create_orders_table
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained(); // orders.user_id → users.id
$table->string('status', 20)->index();
$table->decimal('total', 10, 2);
$table->text('comment')->nullable();
$table->timestamps(); // created_at, updated_at
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
Устаревший синтаксис колонок (Laravel до 11) требовал ->change() с doctrine/dbal; с Laravel 11 ->change() работает нативно, но у него есть ограничения — не каждая правка колонки им поддерживается, сложные случаи честнее делать новой колонкой.
Три команды — и между ними вся разница:
| Команда | Что делает | Когда применять |
|---|---|---|
migrate | выполняет ещё не накатанные миграции | каждый деплой |
migrate:rollback | откатывает последнюю волну (--step=N) | откатить недавнее изменение |
migrate:fresh | удалит все таблицы и накатит всё с нуля | только dev/CI, никогда не прод |
bashphp artisan migrate # накатить новые
php artisan migrate:rollback # откатить последнюю волну
php artisan migrate:fresh --seed # с нуля + сиды (только локально!)
php artisan migrate:status # что накатили, что нет
Сиды — базовые данные, без которых приложение не запустится: роли, города, налоговые ставки. Запуск — php artisan db:seed, классы в database/seeders:
phpclass RoleSeeder extends Seeder
{
public function run(): void
{
Role::insert([
['name' => 'admin'],
['name' => 'manager'],
['name' => 'user'],
]);
}
}
Важный водораздел: сиды — то, что обязано существовать в проде (роли, справочники), фабрики — то, что существует только в разработке и тестах (фейковые пользователи, товары, заказы). Смешивать нельзя: фабрика с рандомными товарами в проде — это мусор в базе.
Фабрики — генераторы тестовых данных с правдоподобными значениями:
php// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'price' => fake()->numberBetween(100, 5000),
'category_id' => Category::factory(),
];
}
}
php// Использование
Product::factory()->count(10)->create(); // 10 товаров
Product::factory()->create(['price' => 999]); // один с переопределённым полем
Product::factory()->hidden()->create(); // если определены состояния
// Со связями
User::factory()
->has(Order::factory()->count(3)) // у пользователя 3 заказа
->create();
Состояния фабрики — именованные вариации вместо ручного переопределения:
phppublic function hidden(): static
{
return $this->state(fn (array $attributes) => ['is_visible' => false]);
}
Как это выглядит в Laravel
php artisan make:model Order -mf— модель + миграция + фабрика одной командой; флаги комбинируются (-mfcдобавит ещё контроллер).php artisan db:seed --class=RoleSeeder— точечный запуск одного седера вместо всех.- Тесты с
RefreshDatabase— перед каждым тестом база пересоздаётся через миграции и фабрики; фабрика без сидов даёт изолированные тесты. Schema::createв свежем проекте уже показывает пример — стандартные миграции Laravel (users, cache, jobs) — готовые образцы синтаксиса.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
| Править накатанную миграцию | Новая миграция с изменением схемы |
migrate:fresh на проде «для чистоты» | migrate:fresh только в dev/CI |
| Фейковые данные фабриками в проде | Фабрики — dev и тесты, сиды — справочники |
down() не описан («потом напишу») | Откат описан сразу и проверен |
| Ручной INSERT в базу для тестовых данных | Фабрики и сиды |
Миграция без nullable там, где данные уже есть | nullable() + default, потом чистить |
Подводный камень
- Накатанную миграцию нельзя править — только переписывать катастрофой. Исключение одно: миграция ещё не попала никуда (пуш в общую ветку не сделан, базы коллег не обновлены) — тогда правку допускают по договорённости команды. Если не уверены — новая миграция.
- Добавление NOT NULL колонки на непустую таблицу. В таблице с существующими строками
$table->string('code')без дефолта упадёт: базе нечего записать в старые строки. Решение —nullable()или->default(), при необходимости добавлять в несколько шагов: колонка → заполнить данные → ужесточить. down()не проверяется до первого отката. Миграция «работает», а еёdown()падает с ошибкой — выяснится это в худший момент, когда откат нужен срочно. Локально гоняйте паруrollback→migrateперед пушем.- Фабрика со случайными значениями ломает повторяемость тестов. Тест ожидает «заказ дороже 1000», фабрика генерирует
priceслучайно — тест падает раз в N прогонов. В тестах важные поля всегда задавайте явно:Product::factory()->create(['price' => 1500]). migrate:rollbackоткатывает волну, а не одну миграцию. После объединённых деплоев последняя волна может содержать несколько чужих миграций — откат вернёт все разом. Точечно —--step=1или черезmigrate:statusсмотрите, что накатили.
Мини-шпаргалка
bash# Миграции
php artisan make:migration create_orders_table
php artisan make:migration add_comment_to_orders_table
php artisan migrate && php artisan migrate:status
php artisan migrate:rollback --step=1
php artisan migrate:fresh --seed # ТОЛЬКО dev/CI
# Модели + фабрика
php artisan make:model Product -mf
# Сиды
php artisan db:seed --class=RoleSeeder
php// Фабрики
Product::factory()->count(5)->create(['category_id' => 1]);
User::factory()->has(Order::factory()->count(3))->create();
Три вывода
- Миграция после наката — неизменяемый артефакт: любое изменение схемы — новая миграция; правка старой оставляет базы в невидимо разных состояниях.
migrate:freshудаляет все данные — его место только в разработке и CI; на проде существует толькоmigrate.- Сиды — обязательные справочные данные, фабрики — фейковые для разработки и тестов; в тестах ключевые для сценария поля задают явно, иначе тесты становятся недетерминированными.
Вопрос-ловушка
Спрашивают так: «Чем сиды отличаются от фабрик?» Ожидают не «сид тоже генерирует данные», а водораздел назначения: сид — данные, без которых прод не работает (роли, справочники), фабрика — правдоподобный мусор для разработки и тестов. Хитрый поворот: «В проде накатана миграция, нужно добавить колонку — что делаете?» Ответ — новая миграция с Schema::table, а не правка старой; уточнение «а если миграция ещё не попала на чужие базы — можно править» показывает, что человек понимает, когда правило можно нарушить. И ещё один: «Тест упадёт раз из десяти, причина не ясна. Причём тут фабрики?» — случайные значения в фабрике делают тест недетерминированным; лечение — фиксированные значения ключевых полей или fake() с фиксированным seed.