В репозитории проекта лежит 146 YAML-файлов с контентом: персонажи, сюжеты, настройки моделей, описания провайдеров. На каждом деплое они импортируются в базу — прямо там же, где выполняются миграции схемы.
Этот механизм работает второй год и меня полностью устраивал. Потом я сел писать о нём статью, полез перечитывать собственный код импортёра и нашёл в нём сценарий, при котором опечатка в одном файле удаляет сущность из базы. Не архивирует — удаляет.
Статья получилась из двух частей: как это устроено и что в этом сломано. Проект под NDA, поэтому сущности переименованы, а примеры обобщены; код и механика настоящие.
Постановка задачи
Контент раньше жил в админке. Почему он оттуда уехал, я подробно писал в другой статье, поэтому здесь коротко: прод и стейдж разъезжались, истории изменений не было, ревью не было, откатиться можно было только по памяти.
Сейчас источник правды — файлы. Выглядит это так:
// в пайплайне деплоя invoke('artisan:migrate'); // схема invoke('artisan:sync:import'); // контент
Команда импорта читает реестр сущностей из конфига:
'entities' => [ 'stories' => [ 'importer' => StoryImporter::class, 'path' => 'stories', 'mode' => 'flat', ], 'characters' => [ 'importer' => CharacterImporter::class, 'path' => 'characters', 'mode' => SyncModeEnum::HIERARCHICAL, ], ],
Два режима раскладки: flat — все файлы сущности в одной директории, hierarchical — директория на сущность, внутри YAML и связанные файлы. Первый удобнее для простых сущностей, второй — когда к записи прилагаются медиа.
Отдельная деталь, которая окупилась неожиданно: корень контента выбирается переменной окружения.
'source_path' => resource_path(env('APP_BOT', 'example')),
Один и тот же код запускает несколько ботов с разным контентом. Набор файлов работает как тема оформления: меняется папка — меняется всё наполнение, код остаётся тот же.

Две ступени сравнения
Импорт гоняется на каждом деплое по всем файлам. Если каждый раз перезаписывать всё, то каждый деплой — это полная перезапись контента, новые версии на пустом месте и мусор в базе.
Поэтому сравнение двухступенчатое.
Ступень первая — хэш файла. Рядом с сущностью хранится md5_file() последнего импортированного файла. Совпал — файл не трогаем вообще, дальше не идём.
if ($currentVersion->file_hash === $fileHash) { $stats['skipped']++; return true; }
Ступень вторая — сравнение по существу. Хэш изменился, но это ещё не значит, что изменился контент: можно поправить комментарий, переставить ключи местами, добавить перенос строки. Поэтому собирается объект новой версии и сравнивается с текущей:
if ($currentVersion->isIdenticalTo($newVersion)) { $currentVersion->file_hash = $fileHash; $currentVersion->save(); return true; // обновили только хэш, новую версию не плодим }
Смысл второй ступени в том, что версия — это запись в истории, и засорять её переформатированием нельзя. Смысл первой — в том, что вторая ступень стоит дорого: собрать объект версии со всеми переводами и связями только чтобы выбросить его, на сотне файлов заметно.
Версии, которых становится много
Изменение сущности не переписывает текущую запись, а создаёт новую версию. Сущность ссылается на активную, а уже начатые пользовательские сессии продолжают жить на той версии, с которой начинались, — иначе деплой посреди дня менял бы контекст у всех активных диалогов одновременно.
Здесь меня ждала первая находка из категории «конфиг, который никто не читает». В настройках сущности есть ключ:
'version_retention' => env('CHARACTER_VERSION_RETENTION', 10),
Выглядит как ограничение глубины истории: храним десять версий, старые убираем. На деле строка version_retention встречается в проекте ровно один раз — вот в этом самом объявлении. Ни один класс её не читает. Версии копятся без ограничений с первого дня.
Само по себе это не страшно — версий у активно правящегося контента набирается десятки, не миллионы. Страшно другое: конфиг выглядит рабочим. Человек, который его увидит, будет уверен, что ротация есть, и не станет проверять.
Медиа: файлы, которых нет в репозитории
К контенту прилагаются картинки, и вот с ними интереснее. В YAML лежат ссылки:
media: catalog: characters/aisha/aisha.catalog gallery: - filename: characters/aisha/aisha.gallery.1 access: public - filename: characters/aisha/aisha.gallery.2 access: premium
Обратите внимание: путь без расширения, и самих файлов в репозитории нет — каталог с ассетами в .gitignore. То есть «контент в git» на самом деле означает «описание контента в git, а бинарники отдельно».
Это осознанный выбор, и я бы его повторил. Репозиторий с несколькими сотнями мегабайт картинок неприятен всем: клонируется долго, диффы бессмысленны, история раздувается навсегда. Цена — то, что YAML может ссылаться на несуществующий файл, и узнаете вы об этом на импорте.
Сравнение медиа устроено так же, как и всё остальное, — по хэшу:
foreach ($character->getMedia($collection) as $media) { $fileHash = $this->calculateFileHash($media->getPath()); // ... сравниваем с хэшем файла-источника }
Совпал — переиспользуем уже загруженный файл, не трогаем. Не совпал — загружаем заново. Отдельно синхронизируется порядок: галерея — это список, и его перестановка должна приводить к перестановке, а не к перезаливке всех картинок.
Самое интересное — удаление. Картинку, которой больше нет в YAML, нельзя просто удалить: на неё ссылаются уже отправленные сообщения, и пользователь, листающий историю чата, увидит битые превью. Поэтому файл не удаляется, а переезжает в отдельную коллекцию:
$media->collection_name = Character::MEDIA_COLLECTION_PENDING_DELETION; $media->save();
Из активных выборок он пропадает, из старых сообщений — нет. Реальное удаление — отдельная осознанная операция, а не побочный эффект импорта.
А теперь про то, как файл с опечаткой удаляет сущность
Дальше — разбор находки. Все три куска кода по отдельности выглядят разумно.
Кусок первый. Файл, который не прошёл валидацию, пропускается:
$data = $this->loadAndValidateFile($filePath); if (!$data) { return null; // в лог написали, едем дальше }
Логика понятная: один битый файл не должен ронять весь импорт на проде.
Кусок второй. Ошибки при обработке файла тоже не прерывают цикл:
foreach ($files as $filePath) { try { $entity = $this->processSingleFile($filePath, $stats); if ($entity) { $parsedIds[] = $entity->id; // запомнили, что этот файл видели } } catch (\Throwable $e) { \Log::error("Failed to process file: {$filePath}", [...]); } }
Тоже разумно. Обратите внимание на $parsedIds: туда попадают идентификаторы только успешно обработанных файлов.
Кусок третий. Всё, чего не было в файлах, считается удалённым:
protected function archiveMissingEntities(array $existingIds): int { $entities = $this->getModelQuery() ->whereNotIn('id', $existingIds) ->get(); foreach ($entities as $entity) { if ($entity instanceof Archivable) { $entity->{$entity->getArchivedField()} = true; $entity->save(); } else { $entity->delete(); // вот здесь } } return $entities->count(); }
Идея правильная: удалили файл из репозитория — сущность уходит из активных. Именно так и должно работать, когда файлы — источник правды.
А теперь сложим три куска вместе.
Сущность, файл которой не прошёл валидацию, не попадает в $parsedIds. С точки зрения третьего куска она ничем не отличается от сущности, файл которой удалили осознанно. Поэтому она архивируется. А если модель не реализует Archivable — удаляется из базы.
То есть:
лишняя кавычка, съехавший отступ, забытое обязательное поле — и сущность пропадает из бота;
в логе будет
Validation failed, но деплой при этом зелёный;для сущностей без архивирования (у нас это описания провайдеров) — не «скрылась», а
DELETE.
Всё это происходит внутри одной транзакции, то есть атомарно и с концами.
Вероятность не нулевая: файлы правит человек, YAML к опечаткам чувствителен, а валидация у сущностей строгая — там и uuid-формат, и enum’ы, и обязательные многоязычные поля. Ровно та ситуация, где «когда-нибудь совпадёт».
Лечится тремя строками. Считать ошибки и не архивировать ничего, если хоть одна была:
DB::transaction(function () use ($path, &$stats) { $files = $this->getFilesToProcess($path); $parsedIds = $this->processFiles($files, $stats); if ($stats['failed'] > 0) { throw new ImportFailedException("Не удалось обработать {$stats['failed']} файлов"); } $stats['archived'] += $this->archiveMissingEntities($parsedIds); });
Транзакция откатится, контент останется прежним, деплой покраснеет. Именно то поведение, которого ждёшь от импорта.
Статистика, которая врёт
Второе, что нашлось по дороге. Команда импорта печатает красивую сводку:
- Created: 0 - Updated: 0 - Unchanged: 0 - Skipped: 41 - Archived: 0
Проблема в том, что created, updated и unchanged не инкрементируются нигде. Совсем. Это три счётчика, которые всегда равны нулю, но исправно печатаются на каждом деплое.
А skipped — единственный живой из них, кроме archived, — означает не то, что кажется. В голове у читателя «skipped» — это «пропущено из-за проблемы». В коде это «хэш совпал, файл не изменился», то есть нормальный и самый частый исход.
Получается сводка, которая читается ровно наоборот: сорок один файл «пропущен», ноль «создано» и ноль «обновлено» — выглядит как сломанный импорт, хотя это штатная картина. А настоящая проблема — битый файл — в сводке не отражается никак.
Мораль скучная, но повторю: счётчик, который печатается, но не считается, хуже отсутствующего счётчика. Отсутствующий заставит посмотреть в лог, а этот — успокоит.
Две правды: файлы и админка
Админка никуда не делась — она нужна, чтобы посмотреть, что реально лежит в базе. Но редактировать там технически можно, и вот что при этом происходит.
Кто-то правит запись в админке. Файл при этом не менялся, его хэш прежний — значит, на следующем деплое импортёр до сущности просто не дойдёт: первая ступень сравнения отсечёт файл как неизменившийся.
То есть правка из админки не затирается. Звучит хорошо, а на деле это худший из вариантов: база и файл разошлись, никто об этом не знает, и расхождение живёт до тех пор, пока кто-нибудь не поправит YAML. Вот тогда изменения из админки исчезнут — через недели после того, как их внесли, и без всякой видимой связи с причиной.
Честный выход — обратный экспорт: из базы в файлы, чтобы правку из админки можно было выгрузить и закоммитить. В конфиге он даже описан:
'exporter' => \App\Sync\Services\Exporters\StoryExporter::class,
Директории app/Sync/Services/Exporters в проекте не существует. Интерфейс экспортёра есть, реестр есть, ссылки в конфиге есть, классов нет. Обратный путь был запланирован и не написан, а конфиг об этом умалчивает.
Пока экспорта нет, правильное решение простое и организационное: закрыть редактирование контента в админке, оставив только просмотр. Две системы записи в один источник правды — это не компромисс, а отложенный инцидент.
Что я меняю после этой статьи
Порядок примерно такой:
Счётчик ошибок и отказ от архивирования, если он ненулевой. Плюс красный деплой.
Убрать
delete()из ветки для неархивируемых сущностей. Удаление строки не должно быть побочным эффектом импорта.Починить или выпилить счётчики. Скорее выпилить лишние, оставив
changed / unchanged / failed / archived.Либо реализовать экспортёры, либо убрать их из конфига и закрыть редактирование в админке.
Либо читать
version_retention, либо удалить ключ.
Ни один пункт не тянет больше чем на вечер. Все пять лежали в коде месяцами, потому что механизм работал: файлы импортировались, контент обновлялся, деплой был зелёным. Такие вещи не находятся в эксплуатации — они находятся, когда садишься объяснять свой код кому-то постороннему.
Это, пожалуй, единственный аргумент за написание статей про собственный прод, который меня по-настоящему убеждает.
Чеклист
Если делаете контент файлами в репозитории:
Деплой контента — такая же серьёзная операция, как миграция схемы. Рядом в пайплайне и с тем же отношением.
Сравнение в две ступени: хэш файла как быстрый фильтр, сравнение по существу — чтобы переформатирование не плодило версии.
Изменение создаёт версию, начатые пользовательские сессии остаются на своей.
Удаление файла означает архивирование, а не
DELETE. Никогда не удаляйте строку из импорта.Ошибка при обработке файла запрещает архивирование в этом прогоне. Иначе битый файл неотличим от удалённого.
Ненулевой счётчик ошибок — красный деплой.
Счётчики либо считают, либо их нет.
Бинарники — вне репозитория, в YAML только ссылки.
Медиа, на которые могут ссылаться старые данные, не удаляются, а уходят в отдельную коллекцию.
Одна система записи. Если есть админка — она только на чтение, пока нет обратного экспорта.
Конфиг не должен описывать возможности, которых нет в коде.
Периодически перечитывайте собственный импортёр. Желательно вслух кому-нибудь.
Если у вас контент тоже живёт в репозитории — расскажите, как решён обратный путь из админки в файлы. Это единственная часть задачи, которую я пока не видел решённой красиво ни у себя, ни у других.

