Сидер по умолчанию считается инструментом для пустой базы. Запустил один раз при развёртывании, забыл. Всё, что нужно менять потом, — через админку.
Эта модель ломается на первом же обновлении, где нужно добавить рубрику. Или поправить описание раздела. Или выкатить новую статью, которая лежит в репозитории. И вот тут выясняется, что сидер запускать страшно: он либо упадёт на уникальном ключе, либо затрёт то, что правил редактор.
Дальше речь о развёртывании, которое сохраняет существующую базу между релизами. Если процесс каждый раз делает 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;Флаг существует, потому что демо-стенд формально работает в продакшн-режиме. Убрать возможность совсем — значит однажды не суметь наполнить стенд перед показом.
Итог
Три правила, которые я бы записал на стену.
Сидер должен быть безопасен для повторного запуска — иначе им не будут пользоваться, и данные начнут расползаться между кодом и базой.
Поля надо поделить на те, что принадлежат коду, и те, что принадлежат редактору. Без этого деления любой автоматический сидер рано или поздно кого-нибудь разозлит.
И тест на второй запуск пишется до сидера, а не после. Он занимает шесть строк и ловит именно ту ошибку, которую невозможно заметить на локальной пустой базе.