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

Проверьте драйвер и создайте таблицу

Для подключения нужны PDO и драйвер PDO_SQLITE. Если конструктор PDO сообщает, что драйвер не найден, включите соответствующее расширение в используемой установке PHP и проверьте список расширений командой php -m.

Подключите PDO-драйвер SQLite и один раз выполните миграцию:

CREATE TABLE IF NOT EXISTS books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    year INTEGER NOT NULL,
    status TEXT NOT NULL
);

NOT NULL запрещает отсутствие важных значений, а первичный ключ id отличает записи. Схему создают миграцией при подготовке проекта, а не на каждом запросе.

Открываем SQLite через PDO

<?php
declare(strict_types=1);

$databasePath = dirname(__DIR__) . '/storage/reading-log.sqlite';
$pdo = new PDO('sqlite:' . $databasePath, options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

Путь находится вне публичной папки. Режим исключений помогает обнаружить сбой соединения или запроса. Хранилище получает путь из конфигурации, а не из URL.

Подготавливаем запрос на добавление

$statement = $pdo->prepare(
    'INSERT INTO books (title, author, year, status)
     VALUES (:title, :author, :year, :status)'
);

$statement->execute([
    'title' => $book->title(),
    'author' => $book->author(),
    'year' => $book->year(),
    'status' => $book->status()->value,
]);

Плейсхолдеры отделяют SQL-команду от значений. Не собирайте запрос конкатенацией с пользовательским вводом: подготовленные запросы не дают значениям стать частью SQL-кода.

Читаем список

$statement = $pdo->query(
    'SELECT id, title, author, year, status
     FROM books
     ORDER BY year DESC'
);
$rows = $statement->fetchAll();

Здесь SQL статический, поэтому подходит query(). Для пользовательского фильтра применяйте подготовленный запрос:

$statement = $pdo->prepare(
    'SELECT id, title, author, year, status FROM books WHERE status = :status'
);
$statement->execute(['status' => ReadingStatus::Read->value]);
$rows = $statement->fetchAll();

Имя столбца не является значением

Плейсхолдер подходит для года или статуса, но не для имени таблицы, столбца и направления сортировки. Если посетитель выбирает сортировку, сопоставьте его выбор со списком заранее заданных SQL-фрагментов — не вставляйте параметр напрямую.

SQLite или серверная база

SQLite подходит для небольшого проекта и локальной разработки. Если приложение работает на нескольких серверах, активно меняет данные одновременно или требует расширенного управления доступом, подберите серверную базу и настройте резервные копии. PDO упрощает интерфейс, но не делает базы данных одинаковыми.

Передайте операции отдельному репозиторию: контроллеру не нужно знать SQL. В следующей части добавим тесты, чтобы после замены хранилища поведение не изменилось незаметно.

Часть 14 из 16.

← Часть 13: PHP-формы POST: проверка данных и защита CSRF · Часть 15: Тестирование PHP: первые тесты PHPUnit →

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