Практическое руководство по созданию REST API на PHP с аутентификацией и маршрутизацией

Не используйте массивы напрямую в ответах – заворачивайте их в объекты. Это не просто прихоть. Такой подход упростит масштабирование, особенно при внедрении версионирования и фильтрации. Например, возвращая список пользователей, поместите его в ключ data. А рядом добавьте meta – количество, страницы, лимиты. Без этого структура быстро выйдет из-под контроля.

Избегайте жёсткой связки с конкретным фреймворком. Laravel – удобен, но не вечен. Выносите логику взаимодействия в отдельные сервисы. Классы запроса, валидаторы, трансформеры – это не мода, а необходимость, если не хотите утонуть в коллбэках и промежуточных слоях.

Убедитесь, что все входящие данные валидируются до попадания в контроллер. Никогда не доверяйте тому, что приходит извне. Даже если это внутренний клиент. Даже если вы «знаете, кто это отправил». Добавьте строгую схему. Используйте Symfony Validator или кастомные правила. Пусть код сам вас защищает.

При возврате ошибок всегда придерживайтесь одного формата. message, code, errors – и никаких «по настроению». Отладка становится в разы проще, когда фронтенд точно знает, чего ожидать. Добавьте единый класс ответа на исключения и не забывайте про HTTP-коды. Не 200, если что-то пошло не так. Никогда.

И не храните логику маршрутизации в одном файле. Разбивайте на модули, группируйте по префиксам и назначайте middleware адресно. Чем явнее структура – тем легче читать код через полгода. Особенно, если автор этого кода – вы сами, но с другим настроением и опытом.

Реализация аутентификации через JWT в многоуровневой архитектуре

Сначала отключи автоматическую сессию в конфиге – никаких `$_SESSION`. Только заголовки, только токены. Без этого не получится добиться независимости слоёв и масштабируемости. Каждый запрос должен быть самодостаточным.

Генерируй JWT строго в слое бизнес-логики. Никаких ключей в контроллерах – они не должны знать ничего о методе шифрования. Пример: пользователь вводит логин, контроллер передаёт его в сервис авторизации, сервис – в репозиторий, который достаёт хеш пароля. Только после успешной сверки – генерация токена через подписанный HMAC-SHA256. Секрет – в `.env`, доступен только DI-контейнеру.

Не смешивай ответственность. Контроллер проверяет заголовок `Authorization`, передаёт токен в AuthService. Там верификация: срок действия, подпись, структура payload. Нет подписи – 401. Протухший – 403. Подделка – лог в Sentry и сразу `access denied`. Без раздумий.

Никогда не пихай весь payload в JWT. Хватает `sub`, `exp`, `iat`, `role`. Всё остальное – из базы. Авторизация по роли – отдельный middleware, не в сервисе и не в контроллере. Иначе начнётся каша. Если роль `admin`, пускай, если `user` – 403. Никаких магических значений, всё через enum.

Обновление токена – через отдельную конечную точку. Refresh-токен живёт дольше, хранится HttpOnly, генерируется при логине. Время жизни access-токена – 15 минут. Не больше. Потеря – минимальный риск. Зато никакой перехват не даст злоумышленнику долго сидеть под чужим аккаунтом.

При выходе – инвалидируй refresh. Можно через Redis с TTL. UID токена – ключ, срок – TTL. Проверка: есть в Redis – запрещён. Нет – пускаем. Да, немного памяти, но безопасность важнее. Хочешь масштабировать – кластер Redis с pub/sub и аннулированием по событию.

Никакой магии. Только логика, изоляция слоёв и строгий контроль ответственности. Архитектура должна дышать независимо от механизма авторизации. JWT – инструмент, не цель.

Организация маршрутизации с использованием FastRoute и контроллеров

Не пытайтесь обрабатывать маршруты вручную – используйте FastRoute. Это минималистичная, быстрая и чистая библиотека маршрутизации, которая не привязывает к какому-либо фреймворку. Начните с подключения её через Composer:

composer require nikic/fast-route

Определите маршруты в одном месте. Не размазывайте логику по коду. Пример – файл routes.php:


use FastRoute\RouteCollector;
return function (RouteCollector $r) {
$r->addRoute('GET', '/users', ['App\Controllers\UserController', 'index']);
$r->addRoute('POST', '/users', ['App\Controllers\UserController', 'store']);
$r->addRoute('GET', '/users/{id:\d+}', ['App\Controllers\UserController', 'show']);
};

Не оборачивайте это в лишние классы или обёртки. Один файл – одна ответственность.

Далее – точка входа. Скрипт index.php обрабатывает входящие запросы, мапит их на нужный контроллер и вызывает метод:


$dispatcher = FastRoute\simpleDispatcher(require 'routes.php');
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
http_response_code(404);
echo 'Not Found';
break;
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
http_response_code(405);
echo 'Method Not Allowed';
break;
case FastRoute\Dispatcher::FOUND:
[$class, $method] = $routeInfo[1];
$vars = $routeInfo[2];
(new $class)->$method($vars);
break;
}

Не добавляйте промежуточных слоёв без нужды. Контроллер – только для логики работы с входящими данными. Пример:


namespace App\Controllers;
class UserController {
public function index() {
echo json_encode(['users' => []]);
}
public function store() {
$input = json_decode(file_get_contents('php://input'), true);
// логика сохранения
echo json_encode(['status' => 'ok']);
}
public function show($params) {
$id = $params['id'];
echo json_encode(['user' => ['id' => $id]]);
}
}

Каждый метод контроллера – конкретное действие. Не связывайте его с представлением или моделью напрямую. Вынесите бизнес-логику в отдельные сервисы, если она громоздкая. Но на первом этапе достаточно строго следовать принципу: запрос – маршрут – контроллер – ответ. Всё остальное – позже.

Обработка ошибок и возврат структурированных ответов с HTTP-кодами

Всегда возвращайте чёткие ответы с корректным HTTP-статусом. Не 200 для всего подряд. Если у клиента нет доступа – 403 Forbidden. Если переданы некорректные данные – 422 Unprocessable Entity. Не найдено? Только 404. Это не просто формальность – так клиент сразу понимает, что пошло не так и где искать ошибку.

Формируйте ответы в одном формате – JSON. Без HTML-обёрток, без мусора. Вот базовая структура:

{
"status": "error",
"code": 422,
"message": "Поле email обязательно",
"errors": {
"email": "Поле не должно быть пустым"
}
}

В случае успешного выполнения логики используйте 200 или 201, и добавляйте ключ dataне просто строку «успех», а конкретные данные, которые клиент может использовать.

Ошибки должны обрабатываться централизованно – через middleware или глобальный обработчик. Не надо дублировать try-catch в каждом методе. Например, используйте PSR-15 middleware, чтобы ловить исключения и конвертировать их в JSON с нужным кодом. Вот шаблон:

try {
// логика
} catch (ValidationException $e) {
return new JsonResponse([
'status' => 'error',
'message' => $e->getMessage(),
'errors' => $e->getErrors(),
], 422);
} catch (UnauthorizedException $e) {
return new JsonResponse([
'status' => 'error',
'message' => 'Доступ запрещён',
], 403);
}

Для автоматизации можно использовать библиотеки вроде Symfony HttpFoundation или встроенные классы из Laminas Diactoros, если проект на основе PSR-7. Они уже умеют всё – коды, заголовки, тело ответа.

Совет напоследок

Никогда не пишите в теле ответа: «ошибка на сервере». Это бесполезно. Уточняйте: где именно, что ожидалось, что пришло. И не забывайте: JSON с ошибкой – это такой же продукт, как и основной функционал. Его тоже читают. Часто – в первую очередь.