Разработка/ PHP/ Быстрые решения

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.

Теги

Читать дальше