REST API на PHP
В уроке 10 мы собрали мини-роутер, в уроке 17 - middleware на PSR-15. Соединяем в полноценный REST API: метод + путь → контроллер, валидация ввода, HTTP-статус-коды, единый формат ошибок, версионирование.
Что такое «нормальный» REST
| Ресурс | Метод | Путь | Назначение | Успех | Если нет |
|---|---|---|---|---|---|
| Коллекция | GET | /users | Список (с пагинацией) | 200 | 200 (пустой массив) |
| Коллекция | POST | /users | Создание | 201 | - |
| Элемент | GET | /users/{id} | Один по id | 200 | 404 |
| Элемент | PUT | /users/{id} | Полная замена | 200/204 | 404 |
| Элемент | PATCH | /users/{id} | Частичное обновление | 200/204 | 404 |
| Элемент | DELETE | /users/{id} | Удаление | 204 | 404 |
Главное: методы - глаголы, пути - существительные. Никаких /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]);
}
Когда какой статус-код
| Код | Когда |
|---|---|
| 200 | GET успешный, PUT/PATCH с возвратом ресурса |
| 201 | POST создал ресурс. Обязательно заголовок Location: /users/N |
| 204 | DELETE / PUT без тела |
| 400 | Сломан синтаксис (плохой JSON, не те типы) |
| 401 | Не авторизован (требуется логин) |
| 403 | Авторизован, но не имеет прав |
| 404 | Не нашли ресурс по {id} |
| 405 | Метод не поддерживается для пути; обязательно заголовок Allow |
| 409 | Конфликт (email уже занят при INSERT) |
| 422 | Валидация бизнес-правил (типы ок, но email не email) |
| 429 | Rate 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.
Типичные ошибки
- 200 в ответ на ошибку.
{"success": false, "error": "..."}со статусом 200 - анти-REST. Используй настоящие коды (4xx/5xx). - POST для всего.
/getUserPOST'ом - это не REST, это RPC. Запомни таблицу методов и придерживайся её. - Пагинация без лимита.
GET /usersбез?limit=отдаёт 100K записей - кладёт БД и сеть. Дефолтный лимит (20-50) обязательно. - Утечка id в URL чужих ресурсов.
/orders/123без проверки, что заказ принадлежит залогиненному юзеру - IDOR-уязвимость. Каждый запрос с{id}фильтруй по правам. - 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, перепиши хотя бы один эндпоинт через атрибуты