Пока каталог существует только в переменной. Перезапустите PHP — и список исчезнет. Добавим сохранение в JSON-файл: формат читаем человеком и подходит для маленького личного каталога. Для сайта с несколькими пользователями и параллельными изменениями позже выберем базу данных.

Храним файл вне публичной папки

Создайте папку storage в корне проекта рядом с public, а не внутри неё. Тогда посетитель не сможет запросить файл напрямую через браузер. У PHP-процесса должны быть права на чтение и запись в этот каталог.

Преобразуем объекты в простой формат

JSON умеет хранить строки, числа, логические значения, массивы и null. Переведём объект книги в массив скаляров:

<?php
declare(strict_types=1);

use App\Book;

function bookToArray(Book $book): array
{
    return [
        'title' => $book->title(),
        'author' => $book->author(),
        'year' => $book->year(),
        'status' => $book->status()->value,
    ];
}

Статус enum сохраняется как его строковое значение. Файл не содержит PHP-объектов или исполняемого кода, только данные.

Сохраняем с проверкой ошибки

function saveBooks(array $books, string $path): void
{
    $rows = array_map('bookToArray', $books);
    $json = json_encode(
        $rows,
        JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
    );

    if (file_put_contents($path, $json, LOCK_EX) === false) {
        throw new RuntimeException('Не удалось сохранить каталог.');
    }
}

JSON_UNESCAPED_UNICODE сохраняет кириллицу читаемой, а JSON_THROW_ON_ERROR превращает ошибку кодирования в исключение. LOCK_EX просит PHP взять исключительную блокировку на время записи, чтобы две записи не перезаписывали файл одновременно.

Это всё ещё простое файловое хранилище, а не транзакционная база данных. Блокировка защищает только конкретную операцию записи; файл не заменяет SQLite или серверную СУБД, если несколько запросов одновременно меняют сложные данные.

Читаем и восстанавливаем книги

use App\Book;
use App\ReadingStatus;

function loadBooks(string $path): array
{
    if (!is_file($path)) {
        return [];
    }

    $json = file_get_contents($path);
    if ($json === false) {
        throw new RuntimeException('Не удалось прочитать каталог.');
    }

    $rows = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
    if (!is_array($rows)) {
        throw new UnexpectedValueException('Файл каталога имеет неверный формат.');
    }

    return array_map(
        static fn (array $row): Book => new Book(
            title: $row['title'],
            author: $row['author'],
            year: $row['year'],
            status: ReadingStatus::from($row['status']),
        ),
        $rows
    );
}

json_decode() с флагом исключения сообщает о повреждённом JSON. При этом синтаксически корректный файл всё ещё может содержать неподходящие поля: для данных, которым нельзя доверять, проверьте наличие и тип каждого ключа, прежде чем создавать объект.

Используем постоянный путь

Не собирайте путь из данных запроса. Для учебного проекта задайте его в конфигурации приложения:

$storagePath = dirname(__DIR__) . '/storage/books.json';

Проверьте, что каталог существует, доступен на запись и остаётся вне public. Если файла ещё нет, loadBooks() возвращает пустой каталог — это нормальное состояние нового проекта.

В следующей части перестанем запускать код только из терминала и разберём, что веб-запрос приносит PHP и какой ответ получает браузер.

Часть 10 из 16.

← Часть 9: Валидация и исключения PHP: отклоняем неверные данные · Часть 11: HTTP на PHP: разбираем запрос и формируем ответ →

Документация