almdev Разработка с ИИ

Идемпотентные сидеры: демо-данные и контент без потерь
Сидер, который можно запускать на проде, и почему это не оксюморон.

Три роли сидера, выбор ключа идемпотентности, разница между созданием и перезаписью, контролируемое переименование рубрики и тесты, которые проверяют повторный запуск.

Подкатегория: Бэкенд

Чтение
6 мин
Технологии / версии
Deployment · Laravel · Seed
27 сен 2026 · 6 мин · 11 просмотров · Архив
Добавление новых записей без потери существующих данных
Бэкенд 09/2026

Сидер по умолчанию считается инструментом для пустой базы. Запустил один раз при развёртывании, забыл. Всё, что нужно менять потом, — через админку.

Эта модель ломается на первом же обновлении, где нужно добавить рубрику. Или поправить описание раздела. Или выкатить новую статью, которая лежит в репозитории. И вот тут выясняется, что сидер запускать страшно: он либо упадёт на уникальном ключе, либо затрёт то, что правил редактор.

Дальше речь о развёртывании, которое сохраняет существующую базу между релизами. Если процесс каждый раз делает migrate:fresh --seed, правки из админки по определению не переживают релиз: источником истины остаются файлы в репозитории, и нужна другая редакционная договорённость.

Три роли сидера

Разные наборы данных требуют разного поведения, и смешивать их — главная ошибка.

Справочники. Разделы блога, роли, статусы, типы документов. Их состав определяет разработчик, они выкатываются с релизом. Обновлять при каждом запуске правильно: если я поменял описание раздела в коде, оно должно доехать до прода.

Демо-данные. Наполнение для показа и для локальной разработки. На проде их быть не должно вообще. Запускаются отдельной командой с явным флагом.

Редакционный контент. Статьи, страницы, правовые документы. Создаются кодом, потом правятся человеком в админке. Вот здесь перезапись — катастрофа: она молча уничтожает работу редактора.

Три роли — три разных правила. Один класс, который делает всё сразу, неизбежно применит неправильное правило к какой-нибудь из групп.

Ключ идемпотентности

Первый вопрос к любому сидеру: по какому полю искать существующую запись.

Идентификатор не годится — он генерируется. Название не годится — его правят. Остаётся slug или код: короткая стабильная строка, которая задаётся разработчиком и не меняется просто так.

Category::query()->firstOrCreate(['slug' => $data['slug']], $data);

firstOrCreate — это «создай, если нет, иначе оставь как есть». Для справочника этого мало: правку описания в коде он не довезёт. Для редакционного контента — ровно то, что нужно.

updateOrCreate перезаписывает всегда. Для справочников подходит, для статей — нет.

Повторный запуск — не то же самое, что два одновременных запуска. Последней защитой от дубля служит уникальное ограничение на slug. Если параллельный запуск реален, после конфликта уникальности запись надо прочитать повторно либо сериализовать весь запуск блокировкой; одна обычная транзакция гонку не устраняет.

Промежуточный вариант, который я использую чаще всего: обновлять только те поля, которые редактор не трогает.

$category = Category::query()->firstOrNew(['slug' => $data['slug']]);

// эти поля — зона ответственности кода
$category->fill([
    'theme_class' => $data['theme_class'],
    'sort_order' => $data['sort_order'],
]);

// а эти заполняем только при создании
if (!$category->exists) {
    $category->fill([
        'title' => $data['title'],
        'description' => $data['description'],
        'intro' => $data['intro'],
    ]);
}

$category->save();

Разделение полей на «мои» и «редакторские» приходится делать руками, зато оно снимает главный страх: сидер на проде больше не может стереть текст, который писали два часа.

Контролируемое переименование

Отдельный случай, с которым сталкивается каждый живой проект: рубрику надо переименовать, а slug поменять.

Наивный сидер создаст новую рубрику и оставит старую. Статьи останутся в старой, на сайте появятся две. Ручное вмешательство в проде — то, чего сидер как раз и должен избегать.

Решение — явно описать переход:

if ($data['slug'] === 'ai' && !Category::query()->where('slug', 'ai')->exists()) {
    $legacy = Category::query()->where('slug', 'cicd')->first();

    if ($legacy) {
        // сохраняем id и связи, но не перезаписываем редакторские поля
        $legacy->update([
            'slug' => $data['slug'],
            'theme_class' => $data['theme_class'],
            'sort_order' => $data['sort_order'],
        ]);

        return;
    }
}

Category::query()->firstOrCreate(['slug' => $data['slug']], $data);

Условие читается так: если целевой рубрики ещё нет, но есть старая — обновить её на месте, сохранив идентификатор. Связи статей и тегов по идентификатору останутся на месте. Старый публичный URL при этом изменится, поэтому прежний slug надо сохранить в таблице перенаправлений или явно добавить постоянный редирект.

Проверка на отсутствие целевой рубрики обязательна. Без неё повторный запуск после успешного переименования найдёт уже переименованную рубрику и попытается сделать это ещё раз — или, хуже, найдёт заново созданную старую и склеит их.

Такой блок живёт в сидере ровно один релизный цикл. Через версию он мёртвый код, и его надо убрать. Я оставляю комментарий с датой, иначе не убираю никогда.

Контент во внешних файлах

Статья в PHP-массиве — это тысяча строк HTML внутри кода, который невозможно ни прочитать, ни продиффить.

Разделение получилось таким: метаданные в JSON, тело статьи в отдельном HTML-файле рядом.

{
  "version": 2,
  "posts": [
    {
      "slug": "ustanovka-i-nastroyka-redis-dlya-laravel-proektov",
      "title": "Установка и настройка Redis для Laravel-проектов",
      "category_slug": "ops",
      "subcategory": { "slug": "ispmanager", "title": "ISPManager" },
      "tags": [{ "slug": "redis", "title": "Redis" }],
      "published_at": "2026-06-17T20:10:05+00:00"
    }
  ]
}

Тело подтягивается по слагу из соседнего каталога. Diff по статье становится читаемым, редактировать её можно в нормальном редакторе, а JSON остаётся компактным.

Поле version — не декорация. Сидер сверяет его с ожидаемым и отказывается работать, если формат новее того, что он понимает. Это защита от ситуации, когда на прод выкатили новый файл контента со старым кодом.

Порядок и связи

Сидер контента зависит от сидера рубрик. Рубрики — ни от чего. Тесты и разработчики запускают их по отдельности.

Я не полагаюсь на порядок вызовов в главном сидере. Каждый сидер, которому нужна рубрика, обеспечивает её существование сам:

private function subcategory(Category $category, ?array $source): ?Subcategory
{
    if (!$source) {
        return null;
    }

    return Subcategory::query()->firstOrCreate(
        ['category_id' => $category->id, 'slug' => $source['slug']],
        $source + ['category_id' => $category->id],
    );
}

Подкатегория создаётся из данных статьи, если её ещё нет. Это дублирует ответственность, зато сидер статей можно запустить на чистой базе и он не упадёт.

Тесты повторного запуска

Сидер без теста на второй запуск — это сидер, который однажды удвоит данные.

public function test_seeder_is_idempotent(): void
{
    $this->seed(EditorialPostSeeder::class);
    $firstRun = Post::query()->orderBy('slug')->pluck('id', 'slug')->all();

    $this->seed(EditorialPostSeeder::class);
    $secondRun = Post::query()->orderBy('slug')->pluck('id', 'slug')->all();

    $this->assertSame($firstRun, $secondRun);
    $this->assertSame(
        Post::query()->count(),
        Post::query()->distinct()->count('slug'),
    );
}

Второй тест — про сохранность правок:

public function test_seeder_keeps_editor_changes(): void
{
    $this->seed(EditorialPostSeeder::class);

    $post = Post::query()->firstOrFail();
    $post->update(['title' => 'Заголовок, который правил редактор']);

    $this->seed(EditorialPostSeeder::class);

    $this->assertSame('Заголовок, который правил редактор', $post->refresh()->title);
}

Этот тест поймал у меня две регрессии. Оба раза я «немного упростил» сидер, поменяв firstOrCreate на updateOrCreate, потому что так короче.

Третий тест проверяет переименование: создаю рубрику со старым слагом, запускаю сидер, убеждаюсь, что идентификатор не изменился и статьи остались на месте.

Демо-данные отдельно

Наполнение для показа живёт в другом классе и никогда не вызывается из основного сидера. Запуск — явной Artisan-командой, и первым делом она проверяет окружение. Это фрагмент метода handle() класса, наследующего Command, а не метода сидера:

if (app()->isProduction() && !$this->option('force')) {
    $this->error('Демо-данные на проде. Нужен --force.');

    return self::FAILURE;
}

$this->call('db:seed', ['--class' => DemoDataSeeder::class]);

return self::SUCCESS;

Флаг существует, потому что демо-стенд формально работает в продакшн-режиме. Убрать возможность совсем — значит однажды не суметь наполнить стенд перед показом.

Итог

Три правила, которые я бы записал на стену.

Сидер должен быть безопасен для повторного запуска — иначе им не будут пользоваться, и данные начнут расползаться между кодом и базой.

Поля надо поделить на те, что принадлежат коду, и те, что принадлежат редактору. Без этого деления любой автоматический сидер рано или поздно кого-нибудь разозлит.

И тест на второй запуск пишется до сидера, а не после. Он занимает шесть строк и ловит именно ту ошибку, которую невозможно заметить на локальной пустой базе.

#deployment #laravel #seed