PHP / Архитектура
Простое веб-приложение на PHP: список задач с SQLite
Пошагово создаём небольшое PHP-приложение: публичная точка входа, PDO и SQLite, форма, CSRF, валидация и Post/Redirect/Get.
Содержание статьи
Создадим приложение, которое показывает задачи и добавляет новую через форму. На этом примере разберём полный цикл: браузер отправляет POST-запрос, PHP проверяет данные, сохраняет запись через PDO и перенаправляет пользователя на список.
Пример рассчитан на PHP 8.4+, использует SQLite и не требует фреймворка. Это учебный проект: в production создание таблицы следует вынести в версионированную миграцию, а обработку и шаблон — в отдельные слои.
Структура
task-list/├── public/│ └── index.php├── src/│ └── bootstrap.php└── storage/Веб-сервер должен смотреть только в public/. База находится вне web-root, поэтому её нельзя скачать по HTTP.
Подключение к базе и сессия
Создайте src/bootstrap.php:
<?phpdeclare(strict_types=1);$projectRoot = dirname(__DIR__);$storagePath = $projectRoot . DIRECTORY_SEPARATOR . 'storage';if (!is_dir($storagePath)) { throw new RuntimeException('Каталог storage не найден.');}$database = new PDO('sqlite:' . $storagePath . DIRECTORY_SEPARATOR . 'tasks.sqlite');$database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);$database->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);$database->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);$database->exec('PRAGMA foreign_keys = ON');// Для учебного примера. В реальном проекте используйте отдельную миграцию.$database->exec( 'CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, created_at TEXT NOT NULL )');session_start();if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));}return $database;PDO настроен на исключения, ассоциативные результаты и нативные prepared statements. Путь строится на сервере и не зависит от пользовательского ввода.
Для production до session_start() настройте cookie с Secure, HttpOnly и подходящим SameSite, а сайт обслуживайте только по HTTPS.
Обработка запроса
Начало public/index.php:
<?phpdeclare(strict_types=1);/** @var PDO $database */$database = require dirname(__DIR__) . '/src/bootstrap.php';$errors = [];$title = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : ''; if (!hash_equals($_SESSION['csrf_token'], $token)) { http_response_code(403); exit('Не удалось проверить запрос. Обновите страницу.'); } $title = is_string($_POST['title'] ?? null) ? trim($_POST['title']) : ''; if ($title === '') { $errors['title'] = 'Введите название задачи.'; } elseif (mb_strlen($title) > 200) { $errors['title'] = 'Название должно быть не длиннее 200 символов.'; } if ($errors === []) { $statement = $database->prepare( 'INSERT INTO tasks (title, created_at) VALUES (:title, :created_at)' ); $statement->execute([ 'title' => $title, 'created_at' => gmdate('Y-m-d H:i:s'), ]); $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); header('Location: /', true, 303); exit; }} elseif ($_SERVER['REQUEST_METHOD'] !== 'GET') { header('Allow: GET, POST'); http_response_code(405); exit('Метод не поддерживается.');}$tasks = $database ->query('SELECT id, title, created_at FROM tasks ORDER BY id DESC') ->fetchAll();Что здесь важно:
- обрабатываются только GET и POST;
- форма защищена CSRF-токеном;
- значение проверяется как строка до
trim(); - длина проверяется и в интерфейсе, и на сервере;
- запрос использует именованные параметры;
- после успеха выполняется redirect со статусом 303, поэтому обновление страницы не повторит POST.
Вывод страницы
Продолжите тот же файл HTML-шаблоном:
<!doctype html><html lang="ru"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Список задач</title></head><body> <main> <h1>Список задач</h1> <form action="/" method="post"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>" > <label for="title">Новая задача</label> <input id="title" name="title" type="text" maxlength="200" value="<?= htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>" <?= isset($errors['title']) ? 'aria-invalid="true" aria-describedby="title-error"' : '' ?> required > <?php if (isset($errors['title'])): ?> <p id="title-error"> <?= htmlspecialchars($errors['title'], ENT_QUOTES, 'UTF-8') ?> </p> <?php endif; ?> <button type="submit">Добавить</button> </form> <?php if ($tasks === []): ?> <p>Задач пока нет.</p> <?php else: ?> <ul> <?php foreach ($tasks as $task): ?> <li> <?= htmlspecialchars((string) $task['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?> </li> <?php endforeach; ?> </ul> <?php endif; ?> </main></body></html>Данные экранируются при выводе, а не перед записью в базу. База хранит исходное проверенное значение; htmlspecialchars() защищает конкретный HTML-контекст.
Запуск
Из корня проекта:
php -S 127.0.0.1:8000 -t publicОткройте http://127.0.0.1:8000/. Встроенный сервер PHP подходит только для локальной разработки, не для production.
Убедитесь, что процесс PHP может записывать в storage/, а сам каталог недоступен через HTTP.
Что проверить
- Пустое название показывает ошибку.
- Строка длиннее 200 символов отклоняется сервером.
- Текст
<script>alert(1)</script>отображается как текст, а не выполняется. - Повторное обновление после добавления не создаёт копию.
- POST без корректного CSRF-токена возвращает 403.
- Неизвестный HTTP-метод возвращает 405 и заголовок
Allow. - Файл базы нельзя открыть по URL.
Как развивать проект
Следующий безопасный шаг — добавить завершение и удаление задач отдельными POST-действиями. Каждое действие должно проверять метод, CSRF, идентификатор и права пользователя. Затем вынесите маршрутизацию, запросы к данным и шаблоны из точки входа и оформите схему forward-only миграциями.
Главный результат этого примера — не сам список, а понимание цепочки «запрос → валидация → prepared statement → redirect → безопасный вывод».