Мини-проект: API «Список задач»

Время собрать мини-проект. Не «стартап на миллиард», а аккуратный учебный API, который не стыдно показать.

Цель

Сделаем REST API для задач (соберём в одном месте всё из уроков PDO, JSON, роутера и тестов):

  • GET /tasks - список всех задач
  • POST /tasks - создать задачу
  • PATCH /tasks/{id} - отметить выполненной
  • DELETE /tasks/{id} - удалить

Структура проекта

todo-api/
├── public/
│   └── index.php           # точка входа + роутер
├── src/
│   ├── Db.php              # подключение к БД
│   ├── TaskRepository.php  # работа с таблицей tasks
│   └── helpers.php         # утилиты (jsonResponse, readJsonBody)
├── tests/
│   └── TaskRepositoryTest.php
├── composer.json
├── phpunit.xml
└── schema.sql              # SQL для создания таблицы

Шаг 1: База данных

schema.sql:

CREATE TABLE IF NOT EXISTS tasks (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    done  TINYINT NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Шаг 2: Подключение к БД

src/Db.php:

<?php
declare(strict_types=1);

function getConnection(): PDO {
    static $pdo = null;

    if ($pdo === null) {
        $host = getenv('DB_HOST') ?: '127.0.0.1';
        $name = getenv('DB_NAME') ?: 'todo';
        $user = getenv('DB_USER') ?: 'root';
        $pass = getenv('DB_PASS') ?: '';

        $dsn = "mysql:host={$host};dbname={$name};charset=utf8mb4";

        $pdo = new PDO($dsn, $user, $pass, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ]);
    }

    return $pdo;
}
Не хардкодь пароли. `getenv()` читает переменные окружения - их можно задать в `.env` файле или в конфиге сервера.

Шаг 3: Хелперы

src/helpers.php:

<?php
declare(strict_types=1);

function jsonResponse(mixed $data, int $status = 200): never {
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    exit;
}

function jsonError(string $message, int $status = 400): never {
    jsonResponse(['ok' => false, 'error' => $message], $status);
}

function readJsonBody(): array {
    $raw = file_get_contents('php://input');
    if ($raw === '' || $raw === false) {
        jsonError('Empty request body');
    }

    try {
        $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        jsonError('Invalid JSON: ' . $e->getMessage());
    }

    if (!is_array($data)) {
        jsonError('JSON body must be an object');
    }

    return $data;
}

Шаг 4: Репозиторий задач

src/TaskRepository.php:

<?php
declare(strict_types=1);

final class TaskRepository {
    public function __construct(private readonly PDO $pdo) {}

    public function findAll(): array {
        $stmt = $this->pdo->query('SELECT id, title, done, created_at FROM tasks ORDER BY id DESC');
        $tasks = $stmt->fetchAll();

        // done: 0/1 → bool
        return array_map(function (array $task): array {
            $task['done'] = (bool)$task['done'];
            return $task;
        }, $tasks);
    }

    public function findById(int $id): ?array {
        $stmt = $this->pdo->prepare('SELECT id, title, done, created_at FROM tasks WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $task = $stmt->fetch();

        if ($task === false) {
            return null;
        }

        $task['done'] = (bool)$task['done'];
        return $task;
    }

    public function create(string $title): int {
        $stmt = $this->pdo->prepare('INSERT INTO tasks (title) VALUES (:title)');
        $stmt->execute(['title' => $title]);
        return (int)$this->pdo->lastInsertId();
    }

    public function markDone(int $id): bool {
        $stmt = $this->pdo->prepare('UPDATE tasks SET done = 1 WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return $stmt->rowCount() > 0;
    }

    public function delete(int $id): bool {
        $stmt = $this->pdo->prepare('DELETE FROM tasks WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return $stmt->rowCount() > 0;
    }
}
Передаём PDO через конструктор - это [dependency injection](./20-di.md). Код тестируемый: в тестах можно подставить SQLite in-memory вместо MySQL.

Шаг 5: Роутер

public/index.php:

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../src/Db.php';
require __DIR__ . '/../src/helpers.php';
require __DIR__ . '/../src/TaskRepository.php';

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';

// CORS заголовки (для фронтенда на другом порту)
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');

if ($method === 'OPTIONS') {
    http_response_code(204);
    exit;
}

try {
    $repo = new TaskRepository(getConnection());

    // GET /tasks
    if ($method === 'GET' && $path === '/tasks') {
        jsonResponse(['ok' => true, 'tasks' => $repo->findAll()]);
    }

    // POST /tasks
    if ($method === 'POST' && $path === '/tasks') {
        $body = readJsonBody();
        $title = trim($body['title'] ?? '');

        if ($title === '') {
            jsonError('title is required');
        }
        if (mb_strlen($title) > 255) {
            jsonError('title must be 255 characters or less');
        }

        $id = $repo->create($title);
        $task = $repo->findById($id);
        jsonResponse(['ok' => true, 'task' => $task], 201);
    }

    // PATCH /tasks/{id}
    if ($method === 'PATCH' && preg_match('#^/tasks/(\d+)$#', $path, $m)) {
        $id = (int)$m[1];

        if (!$repo->markDone($id)) {
            jsonError('Task not found', 404);
        }

        $task = $repo->findById($id);
        jsonResponse(['ok' => true, 'task' => $task]);
    }

    // DELETE /tasks/{id}
    if ($method === 'DELETE' && preg_match('#^/tasks/(\d+)$#', $path, $m)) {
        $id = (int)$m[1];

        if (!$repo->delete($id)) {
            jsonError('Task not found', 404);
        }

        http_response_code(204);
        exit;
    }

    // 404
    jsonError('Not Found', 404);

} catch (PDOException $e) {
    error_log('DB error: ' . $e->getMessage());
    jsonError('Internal Server Error', 500);
} catch (Throwable $e) {
    error_log('Error: ' . $e->getMessage());
    jsonError('Internal Server Error', 500);
}

Шаг 6: Запуск

# Создай базу и таблицу
mysql -u root -e "CREATE DATABASE IF NOT EXISTS todo"
mysql -u root todo < schema.sql

# Запусти сервер
php -S localhost:8000 -t public/

Тестируем curl-ом:

# Список (пустой)
curl http://localhost:8000/tasks

# Создать задачу
curl -X POST http://localhost:8000/tasks \
 -H "Content-Type: application/json" \
 -d '{"title":"Купить молоко"}'

# Отметить выполненной
curl -X PATCH http://localhost:8000/tasks/1

# Удалить
curl -X DELETE http://localhost:8000/tasks/1

Шаг 7: Тесты

tests/TaskRepositoryTest.php:

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class TaskRepositoryTest extends TestCase {
    private PDO $pdo;
    private TaskRepository $repo;

    protected function setUp(): void {
        // SQLite in-memory - быстро и не нужен MySQL
        $this->pdo = new PDO('sqlite::memory:', null, null, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);

        $this->pdo->exec('
            CREATE TABLE tasks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                done INTEGER NOT NULL DEFAULT 0,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ');

        $this->repo = new TaskRepository($this->pdo);
    }

    public function testCreateReturnsId(): void {
        $id = $this->repo->create('Test task');
        $this->assertSame(1, $id);
    }

    public function testFindAllReturnsCreatedTasks(): void {
        $this->repo->create('Task 1');
        $this->repo->create('Task 2');

        $tasks = $this->repo->findAll();
        $this->assertCount(2, $tasks);
    }

    public function testFindByIdReturnsTask(): void {
        $id = $this->repo->create('Find me');
        $task = $this->repo->findById($id);

        $this->assertNotNull($task);
        $this->assertSame('Find me', $task['title']);
        $this->assertFalse($task['done']);
    }

    public function testFindByIdReturnsNullForMissing(): void {
        $task = $this->repo->findById(999);
        $this->assertNull($task);
    }

    public function testMarkDoneSetsFlag(): void {
        $id = $this->repo->create('Complete me');

        $result = $this->repo->markDone($id);
        $this->assertTrue($result);

        $task = $this->repo->findById($id);
        $this->assertTrue($task['done']);
    }

    public function testDeleteRemovesTask(): void {
        $id = $this->repo->create('Delete me');

        $result = $this->repo->delete($id);
        $this->assertTrue($result);

        $task = $this->repo->findById($id);
        $this->assertNull($task);
    }

    public function testDeleteReturnsFalseForMissing(): void {
        $result = $this->repo->delete(999);
        $this->assertFalse($result);
    }
}
./vendor/bin/phpunit
# OK (7 tests, 10 assertions)
Вместо MySQL в тестах используем SQLite в памяти. Это быстро, не требует настройки БД, и каждый тест начинает с чистой таблицы (setUp создаёт заново).

Что можно улучшить

Это минимальный проект. Дальше можно добавить: пагинацию (GET /tasks?page=1&limit=10), фильтрацию по статусу (?done=true), обновление заголовка (PUT /tasks/{id}), аутентификацию (JWT или сессии), автозагрузку классов через Composer PSR-4.

Типичные ошибки

  • Файлы PHP лежат в корне проекта, а не в public/. Веб-сервер отдаёт наружу vendor/, src/, .env - любой видит исходники и секреты. Перенеси точку входа в public/index.php (как в Symfony skeleton), а DocumentRoot укажи на public/.
  • .env закоммичен в git, пароли БД захардкожены в Db.php. Утечка ключей при первом же git push в публичный репозиторий. Добавь .env в .gitignore, секреты держи в .env.local (как в Symfony) или в переменных окружения CI/CD.
  • Нет composer.json - зависимости не управляются. PHPUnit ставится «вручную», обновлений не будет, autoload через require_once для каждого класса. Запусти composer init, добавь phpunit/phpunit в require-dev, autoload - через psr-4.
  • Классы в src/ без namespace. PSR-4 autoloader не подхватит TaskRepository - приходится require вручную, а конфликт имён сломает проект на второй фиче. Добавь namespace App; (или App\Repository) и пропиши "App\\": "src/" в composer.json.
  • Один index.php со всей логикой: роутинг, валидация, БД. Добавление пятого эндпоинта превратит файл в 500 строк свалки без тестов. Раздели на router → controller → service/repository, контроллер бери в src/Controller/, маршруты - в отдельном файле или через атрибуты.

Best practices

  • Точка входа - единственный public/index.php как front controller. DocumentRoot веб-сервера указывает строго на public/.
  • Секреты - только через getenv() или $_ENV из .env.local. В Symfony - Symfony Secrets (bin/console secrets:set).
  • composer.json с PSR-4 autoload и composer require для каждой зависимости. Никаких require_once 'src/Foo.php';.
  • Каждый класс в своём файле с namespace, имя файла = имени класса. Так PSR-4 находит классы без ручных include.
  • .gitignore с первой минуты: /vendor/, /node_modules/, .env.local, .idea/, .phpunit.cache/. Чистый репозиторий - быстрый clone и понятные diff.

Чек-лист готовности

  • Все запросы используют prepared statements
  • Ошибки возвращаются JSON-ом с правильным HTTP-кодом (400, 404, 500)
  • Входные данные валидируются (пустой title, длина, тип)
  • PDO передаётся через конструктор (dependency injection)
  • Есть минимум 7 тестов на TaskRepository
  • Тесты проходят: ./vendor/bin/phpunit - OK
Даже в учебном проекте: prepared statements для всех запросов, валидация ввода, никаких паролей в коде. Клиент всегда найдёт способ прислать тебе «котик» вместо числа.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.