Миграции, сиды и фабрики: управление схемой и тестовыми данными

Миграции — это версия истории схемы базы данных в 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
php
public 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, никогда не прод
bash
php artisan migrate               # накатить новые
php artisan migrate:rollback      # откатить последнюю волну
php artisan migrate:fresh --seed  # с нуля + сиды (только локально!)
php artisan migrate:status        # что накатили, что нет

Сиды — базовые данные, без которых приложение не запустится: роли, города, налоговые ставки. Запуск — php artisan db:seed, классы в database/seeders:

php
class 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();

Состояния фабрики — именованные вариации вместо ручного переопределения:

php
public 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, потом чистить

Подводный камень

  1. Накатанную миграцию нельзя править — только переписывать катастрофой. Исключение одно: миграция ещё не попала никуда (пуш в общую ветку не сделан, базы коллег не обновлены) — тогда правку допускают по договорённости команды. Если не уверены — новая миграция.
  2. Добавление NOT NULL колонки на непустую таблицу. В таблице с существующими строками $table->string('code') без дефолта упадёт: базе нечего записать в старые строки. Решение — nullable() или ->default(), при необходимости добавлять в несколько шагов: колонка → заполнить данные → ужесточить.
  3. down() не проверяется до первого отката. Миграция «работает», а её down() падает с ошибкой — выяснится это в худший момент, когда откат нужен срочно. Локально гоняйте пару rollback → migrate перед пушем.
  4. Фабрика со случайными значениями ломает повторяемость тестов. Тест ожидает «заказ дороже 1000», фабрика генерирует price случайно — тест падает раз в N прогонов. В тестах важные поля всегда задавайте явно: Product::factory()->create(['price' => 1500]).
  5. 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();

Три вывода

  1. Миграция после наката — неизменяемый артефакт: любое изменение схемы — новая миграция; правка старой оставляет базы в невидимо разных состояниях.
  2. migrate:fresh удаляет все данные — его место только в разработке и CI; на проде существует только migrate.
  3. Сиды — обязательные справочные данные, фабрики — фейковые для разработки и тестов; в тестах ключевые для сценария поля задают явно, иначе тесты становятся недетерминированными.

Вопрос-ловушка

Спрашивают так: «Чем сиды отличаются от фабрик?» Ожидают не «сид тоже генерирует данные», а водораздел назначения: сид — данные, без которых прод не работает (роли, справочники), фабрика — правдоподобный мусор для разработки и тестов. Хитрый поворот: «В проде накатана миграция, нужно добавить колонку — что делаете?» Ответ — новая миграция с Schema::table, а не правка старой; уточнение «а если миграция ещё не попала на чужие базы — можно править» показывает, что человек понимает, когда правило можно нарушить. И ещё один: «Тест упадёт раз из десяти, причина не ясна. Причём тут фабрики?» — случайные значения в фабрике делают тест недетерминированным; лечение — фиксированные значения ключевых полей или fake() с фиксированным seed.