REST API на PHP

В уроке 10 мы собрали мини-роутер, в уроке 17 - middleware на PSR-15. Соединяем в полноценный REST API: метод + путь → контроллер, валидация ввода, HTTP-статус-коды, единый формат ошибок, версионирование.

Что такое «нормальный» REST

РесурсМетодПутьНазначениеУспехЕсли нет
КоллекцияGET/usersСписок (с пагинацией)200200 (пустой массив)
КоллекцияPOST/usersСоздание201-
ЭлементGET/users/{id}Один по id200404
ЭлементPUT/users/{id}Полная замена200/204404
ЭлементPATCH/users/{id}Частичное обновление200/204404
ЭлементDELETE/users/{id}Удаление204404

Главное: методы - глаголы, пути - существительные. Никаких /getUser?id=1, /createUser, /users/delete/1.

Маршрутизатор для нескольких методов

Расширяем роутер из урока 10:

<?php
final class Router
{
    /** @var array<string, array<string, callable>> */
    private array $routes = [];

    public function add(string $method, string $pattern, callable $handler): void
    {
        $this->routes[strtoupper($method)][$pattern] = $handler;
    }

    public function dispatch(string $method, string $path): mixed
    {
        $method = strtoupper($method);
        foreach ($this->routes[$method] ?? [] as $pattern => $handler) {
            $regex = '#^' . preg_replace('/\{(\w+)\}/', '(?<$1>[^/]+)', $pattern) . '$#';
            if (preg_match($regex, $path, $m)) {
                $args = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
                return $handler($args);
            }
        }

        // метод не подошёл, но путь существует в другом методе → 405
        foreach ($this->routes as $m => $list) {
            foreach ($list as $pattern => $_) {
                $regex = '#^' . preg_replace('/\{(\w+)\}/', '[^/]+', $pattern) . '$#';
                if (preg_match($regex, $path)) {
                    http_response_code(405);
                    header('Allow: ' . implode(', ', array_keys(array_filter($this->routes, fn($r) => isset($r[$pattern])))));
                    return null;
                }
            }
        }

        http_response_code(404);
        return null;
    }
}

Использование:

<?php
$router = new Router();
$router->add('GET',    '/users',         fn() => listUsers());
$router->add('POST',   '/users',         fn() => createUser());
$router->add('GET',    '/users/{id}',    fn(array $p) => showUser((int)$p['id']));
$router->add('PATCH',  '/users/{id}',    fn(array $p) => patchUser((int)$p['id']));
$router->add('DELETE', '/users/{id}',    fn(array $p) => deleteUser((int)$p['id']));

$router->dispatch($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

Парсинг тела запроса

Для JSON-API не используй $_POST (урок про формы) - он работает только с form-urlencoded. Читай php://input:

<?php
function readJsonBody(): array
{
    $raw = file_get_contents('php://input');
    if ($raw === '' || $raw === false) {
        return [];
    }
    try {
        $data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
    } catch (\JsonException) {
        sendJson(400, ['error' => 'invalid_json']);
        exit;
    }
    if (!is_array($data)) {
        sendJson(400, ['error' => 'json_must_be_object']);
        exit;
    }
    return $data;
}

Единый ответ

<?php
function sendJson(int $status, mixed $body): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    header('X-Content-Type-Options: nosniff');
    echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}

function sendError(int $status, string $code, array $details = []): void
{
    sendJson($status, ['error' => $code, ...$details]);
}

Валидация ввода

Самописная валидация - это нормально для маленьких API. Главное - единый формат ошибок.

<?php
final class Validator
{
    private array $errors = [];

    public function require(array $data, string $key, string $rule = 'string'): mixed
    {
        if (!array_key_exists($key, $data) || $data[$key] === '' || $data[$key] === null) {
            $this->errors[$key] = 'required';
            return null;
        }
        $value = $data[$key];
        return match ($rule) {
            'string'  => is_string($value)  ? $value : ($this->errors[$key] = 'must_be_string' && null),
            'int'     => is_int($value) || ctype_digit((string)$value) ? (int)$value : ($this->errors[$key] = 'must_be_int' && null),
            'email'   => (is_string($value) && filter_var($value, FILTER_VALIDATE_EMAIL)) ? $value : ($this->errors[$key] = 'must_be_email' && null),
            default   => $value,
        };
    }

    public function failed(): bool { return $this->errors !== []; }
    public function errors(): array { return $this->errors; }
}

Контроллер создания пользователя:

<?php
function createUser(): void
{
    $body = readJsonBody();
    $v = new Validator();

    $email = $v->require($body, 'email', 'email');
    $name  = $v->require($body, 'name', 'string');
    $age   = $v->require($body, 'age', 'int');

    if ($v->failed()) {
        sendJson(422, ['error' => 'validation_failed', 'fields' => $v->errors()]);
        return;
    }

    $pdo = getConnection();
    $stmt = $pdo->prepare('INSERT INTO users (email, name, age) VALUES (:e, :n, :a)');
    $stmt->execute(['e' => $email, 'n' => $name, 'a' => $age]);

    $id = (int)$pdo->lastInsertId();

    http_response_code(201);
    header("Location: /users/$id");
    sendJson(201, ['id' => $id, 'email' => $email, 'name' => $name, 'age' => $age]);
}

Когда какой статус-код

КодКогда
200GET успешный, PUT/PATCH с возвратом ресурса
201POST создал ресурс. Обязательно заголовок Location: /users/N
204DELETE / PUT без тела
400Сломан синтаксис (плохой JSON, не те типы)
401Не авторизован (требуется логин)
403Авторизован, но не имеет прав
404Не нашли ресурс по {id}
405Метод не поддерживается для пути; обязательно заголовок Allow
409Конфликт (email уже занят при INSERT)
422Валидация бизнес-правил (типы ок, но email не email)
429Rate limit
500Незапланированная ошибка сервера
503Сервис временно недоступен (даунтайм, миграции)

400 vs 422 - спорно, но полезно различать: 400 = «я не могу даже распарсить», 422 = «распарсил, но данные не проходят бизнес-правила».

Content negotiation

Сервер может уметь несколько форматов и выбирать по Accept:

<?php
function bestFormat(): string
{
    $accept = $_SERVER['HTTP_ACCEPT'] ?? 'application/json';
    if (str_contains($accept, 'application/xml')) {
        return 'xml';
    }
    return 'json';
}

В реальных API почти всегда только JSON - Accept проверяют, чтобы вернуть 406 на странные запросы.

Версионирование

Через URL - простой и наглядный:

/v1/users
/v2/users

Через заголовок - «чище» в URL, но менее заметно:

GET /users
Accept: application/vnd.myapp.v2+json

В Symfony чаще через URL - проще роутить, проще тестировать в браузере.

Идемпотентность

  • GET, HEAD, OPTIONS - никогда не меняют состояние
  • PUT, DELETE - идемпотентны: повторный вызов даёт тот же результат (DELETE уже удалённого ресурса должен возвращать 404, не 500)
  • POST - не идемпотентен
  • PATCH - обычно не идемпотентен

Это важно для ретраев в проде: GET/PUT/DELETE можно безопасно перепосылать при таймауте, POST - нет.

Кэширование и ETag

Для GET имеет смысл слать ETag:

<?php
$data = listUsers();
$etag = '"' . md5(json_encode($data)) . '"';

if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) {
    http_response_code(304);
    exit;
}

header("ETag: $etag");
header('Cache-Control: private, max-age=60');
sendJson(200, $data);

При совпадении ETag - 304 без тела. Экономит трафик и время.

CORS

Если фронт на другом домене, нужен CORS:

<?php
$allowed = ['https://app.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';

if (in_array($origin, $allowed, true)) {
    header("Access-Control-Allow-Origin: $origin");
    header('Access-Control-Allow-Credentials: true');
    header('Vary: Origin');
}

// preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE');
    header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Csrf-Token');
    http_response_code(204);
    exit;
}

Как это в Symfony

В Symfony REST - это родной паттерн:

<?php
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints as Assert;

final class UserController
{
    public function __construct(private readonly UserRepository $repo) {}

    #[Route('/users', methods: ['GET'])]
    public function list(): JsonResponse
    {
        return new JsonResponse($this->repo->all());
    }

    #[Route('/users/{id<\d+>}', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        $user = $this->repo->find($id) ?? throw new NotFoundHttpException();
        return new JsonResponse($user);
    }

    #[Route('/users', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateUserRequest $req,
    ): JsonResponse {
        $user = $this->repo->create($req->email, $req->name);
        return new JsonResponse($user, 201, ['Location' => "/users/{$user['id']}"]);
    }
}

final class CreateUserRequest
{
    #[Assert\Email]                  public string $email;
    #[Assert\NotBlank][Assert\Length(min: 2)] public string $name;
    #[Assert\GreaterThan(0)]         public int $age;
}
  • #[Route] атрибуты - маршруты
  • #[MapRequestPayload] - десериализация JSON в DTO + валидация (с PHP-атрибутами Assert\*)
  • Если валидация падает - Symfony автоматически возвращает 422 с детализацией
  • NotFoundHttpException → 404
  • DI подсовывает UserRepository

Своих 200 строк роутера и валидатора больше нет - есть конфиг через атрибуты и DI.

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

  1. 200 в ответ на ошибку. {"success": false, "error": "..."} со статусом 200 - анти-REST. Используй настоящие коды (4xx/5xx).
  2. POST для всего. /getUser POST'ом - это не REST, это RPC. Запомни таблицу методов и придерживайся её.
  3. Пагинация без лимита. GET /users без ?limit= отдаёт 100K записей - кладёт БД и сеть. Дефолтный лимит (20-50) обязательно.
  4. Утечка id в URL чужих ресурсов. /orders/123 без проверки, что заказ принадлежит залогиненному юзеру - IDOR-уязвимость. Каждый запрос с {id} фильтруй по правам.
  5. PATCH = PUT. PATCH принимает diff, PUT - полную замену. Если у тебя API «положи объект целиком», метод - PUT.

Best practices

  • Методы - глаголы (GET, POST, DELETE), пути - существительные (/users, /orders); никаких /getUser, /createOrder, /users/delete/1.

  • Возвращай правильные статус-коды: 201 + Location при создании, 422 при ошибке валидации, 404 при отсутствии ресурса, 405 с заголовком Allow при неверном методе.

  • Устанавливай дефолтный лимит пагинации (20-50) для всех коллекций - GET /users без лимита, отдающий 100K строк, положит БД и сеть.

  • Проверяй права доступа к ресурсу по {id} при каждом запросе - IDOR-уязвимость возникает, когда /orders/123 не проверяет, что заказ принадлежит текущему пользователю.

  • Различай идемпотентные методы (GET, PUT, DELETE можно безопасно повторить при таймауте) и неидемпотентные (POST нельзя).

  • General - Проектирование REST API - content negotiation, версионирование API, HATEOAS, балансировка простоты и функциональности

Мини-задание

  • Создай эндпоинты для CRUD ресурса Task (id, title, status, created_at) с маршрутами GET /tasks, POST /tasks, GET /tasks/{id}, PATCH /tasks/{id}, DELETE /tasks/{id}
  • Используй Validator для проверки title (non-empty), status (in ['open', 'done'])
  • Верни 201 + Location при создании, 404 при отсутствии, 405 при неверном методе, 422 при невалидных полях
  • Реализуй пагинацию ?limit=20&offset=0 для GET /tasks с заголовком X-Total-Count
  • Добавь CORS для https://localhost:5173 (для фронта на Vite) и обработай OPTIONS
  • Установи symfony/routing и symfony/http-foundation, перепиши хотя бы один эндпоинт через атрибуты

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