Простое веб-приложение на PHP: список задач с SQLite

PHP / Архитектура

Простое веб-приложение на PHP: список задач с SQLite

Пошагово создаём небольшое PHP-приложение: публичная точка входа, PDO и SQLite, форма, CSRF, валидация и Post/Redirect/Get.

Опубликовано 04.09.2026 6 минут чтения
Содержание статьи

Создадим приложение, которое показывает задачи и добавляет новую через форму. На этом примере разберём полный цикл: браузер отправляет POST-запрос, PHP проверяет данные, сохраняет запись через PDO и перенаправляет пользователя на список.

Пример рассчитан на PHP 8.4+, использует SQLite и не требует фреймворка. Это учебный проект: в production создание таблицы следует вынести в версионированную миграцию, а обработку и шаблон — в отдельные слои.

Структура

Код
task-list/├── public/│   └── index.php├── src/│   └── bootstrap.php└── storage/​

Веб-сервер должен смотреть только в public/. База находится вне web-root, поэтому её нельзя скачать по HTTP.

Подключение к базе и сессия

Создайте src/bootstrap.php:

PHP
<?php​declare(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:

PHP
<?php​declare(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-шаблоном:

PHP
<!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-контекст.

Запуск

Из корня проекта:

Shell
php -S 127.0.0.1:8000 -t public​

Откройте http://127.0.0.1:8000/. Встроенный сервер PHP подходит только для локальной разработки, не для production.

Убедитесь, что процесс PHP может записывать в storage/, а сам каталог недоступен через HTTP.

Что проверить

  1. Пустое название показывает ошибку.
  2. Строка длиннее 200 символов отклоняется сервером.
  3. Текст <script>alert(1)</script> отображается как текст, а не выполняется.
  4. Повторное обновление после добавления не создаёт копию.
  5. POST без корректного CSRF-токена возвращает 403.
  6. Неизвестный HTTP-метод возвращает 405 и заголовок Allow.
  7. Файл базы нельзя открыть по URL.

Как развивать проект

Следующий безопасный шаг — добавить завершение и удаление задач отдельными POST-действиями. Каждое действие должно проверять метод, CSRF, идентификатор и права пользователя. Затем вынесите маршрутизацию, запросы к данным и шаблоны из точки входа и оформите схему forward-only миграциями.

Главный результат этого примера — не сам список, а понимание цепочки «запрос → валидация → prepared statement → redirect → безопасный вывод».

Сообщить об ошибке в статье

Рекомендации редакции

Другие публикации из этого раздела.

Новые материалы

Получайте практические разборы без информационного шума

Одна общая рассылка о новых статьях и инструментах. Адрес будет активироваться только после подтверждения.

Подписка появится после подключения сервиса рассылок и Yandex SmartCaptcha.