Мини-проект: 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;
}
Шаг 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;
}
}
Шаг 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)
Что можно улучшить
Это минимальный проект. Дальше можно добавить: пагинацию (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
- Go - Работа с базами данных - те же принципы: prepared statements, транзакции и Repository pattern в Go