Laravel: сохранить контент из БД в JSON-снапшот
Контент из админки удобно хранить в БД, но без снапшота чистая установка получает пустой сайт. Решение — отдельная Artisan-команда, которая выгружает записи в стабильный JSON под версионным контролем.
Команда экспорта
<?php
namespace App\Console\Commands;
use App\Models\Article;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class ExportContent extends Command
{
protected $signature = 'content:export {--force}';
public function handle(): int
{
$path = database_path('content/articles.json');
if (File::exists($path) && !$this->option('force')) {
$this->error('Snapshot exists. Review it and repeat with --force.');
return self::FAILURE;
}
$articles = Article::query()
->with(['section:id,slug', 'tags:id,slug'])
->orderBy('slug')
->get()
->map(fn (Article $article): array => [
'slug' => $article->slug,
'title' => $article->title,
'preview' => $article->preview,
'content_html' => $article->content_html,
'section' => $article->section->slug,
'tags' => $article->tags->pluck('slug')->sort()->values()->all(),
'published_at' => $article->published_at?->toAtomString(),
])
->all();
if ($articles === []) {
throw new RuntimeException('Refusing to replace snapshot with empty data.');
}
$json = json_encode(
['version' => 1, 'articles' => $articles],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
).PHP_EOL;
File::ensureDirectoryExists(dirname($path));
$temporary = $path.'.tmp';
File::put($temporary, $json);
if (!rename($temporary, $path)) {
File::delete($temporary);
throw new RuntimeException('Could not replace content snapshot.');
}
$this->info('Exported '.count($articles).' article(s).');
return self::SUCCESS;
}
}
Сортировка по slug и сортировка тегов убирают случайный шум в diff. Временный файл создаётся рядом с итоговым: на одном filesystem вызов rename() заменяет снапшот атомарно.
Сидер без потери ручных правок
$snapshot = json_decode(
File::get(database_path('content/articles.json')),
true,
512,
JSON_THROW_ON_ERROR,
);
if (($snapshot['version'] ?? null) !== 1) {
throw new RuntimeException('Unsupported content snapshot version.');
}
foreach ($snapshot['articles'] as $item) {
Article::query()->firstOrCreate(
['slug' => $item['slug']],
[
'title' => $item['title'],
'preview' => $item['preview'],
'content_html' => $item['content_html'],
'published_at' => $item['published_at'],
],
);
}
firstOrCreate() восстанавливает отсутствующие записи, но не затирает контент, который позже поправили через админку. Если нужен принудительный restore, это должна быть отдельная команда с явным флагом, а не обычный db:seed.
Рабочий порядок
php artisan content:export
git diff -- database/content/articles.json
php -r 'json_decode(file_get_contents("database/content/articles.json"), true, 512, JSON_THROW_ON_ERROR);'
# Только после просмотра существующего снапшота
php artisan content:export --force
Первый запуск без --force намеренно падает, если файл уже есть. Это страховка от случайного экспорта из пустой, тестовой или устаревшей базы.
Что не класть в снапшот
- пользователей, заявки форм и любые персональные данные;
- пароли, токены, SMTP-реквизиты и значения secret settings;
- счётчики просмотров, runtime-статусы и временные cache-поля;
- абсолютные пути контейнера и URL локального окружения.
Медиа лучше хранить отдельными seed-assets, а в JSON оставлять только относительные пути. Тогда снапшот остаётся читаемым и не раздувает Git.