PSR-7: HTTP как объектная модель
В обычном PHP веб-запрос часто начинается с $_SERVER, $_GET, $_POST, $_FILES, а ответ собирается через header(), http_response_code() и echo. Это нормально для небольшого скрипта, но плохо масштабируется: код оказывается привязан к конкретному SAPI, его труднее тестировать, а middleware, роутер и HTTP-клиент начинают говорить на разных языках.
PSR-7 решает именно эту проблему. Это стандарт PHP-FIG для интерфейсов HTTP-сообщений: request, response, URI, body stream, server request и uploaded files. PSR-7 не заменяет SAPI и суперглобалы, а кладёт поверх них переносимый слой. На входе приложение получает объект ServerRequestInterface, на выходе возвращает ResponseInterface. Низкоуровневая часть уже сама превратит суперглобалы в request, а response — в реальные статус, заголовки и тело из HTTP-заголовки, ответы и редиректы.
flowchart LR
A[Web SAPI: $_SERVER, $_GET, $_POST, $_FILES] --> B[ServerRequestInterface]
B --> C[Exception middleware]
C --> D[Auth / CSRF / logging middleware]
D --> E[RequestHandler / controller]
E --> F[ResponseInterface]
F --> G[Emitter: status, headers, body]Сообщение: метод, URI, заголовки, тело
У PSR-7 есть несколько центральных интерфейсов:
RequestInterface— исходящий или общий HTTP-запрос: метод, URI, request target, заголовки, тело.ServerRequestInterface— входящий серверный запрос: всё изRequestInterfaceплюс cookies, query params, parsed body, uploads, server params и attributes.ResponseInterface— статус, reason phrase, заголовки и body.UriInterface— разобранный URI без ручного парсинга строк.StreamInterface— тело сообщения как поток, а не просто строка.UploadedFileInterface— нормализованное представление upload-файла.
Важная привычка: PSR-7 messages считаются immutable. Методы вида withHeader(), withStatus(), withBody() не меняют объект на месте, а возвращают новый экземпляр.
<?php
use Psr\Http\Message\ResponseInterface;
function asJson(ResponseInterface $response, array $payload): ResponseInterface
{
$response = $response
->withHeader('Content-Type', 'application/json; charset=UTF-8')
->withHeader('X-Content-Type-Options', 'nosniff');
$response->getBody()->write(json_encode(
$payload,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
));
return $response;
}Если забыть присвоить результат, заголовок потеряется:
$response->withHeader('Content-Type', 'application/json'); // ошибка мышления
return $response; // вернётся старый responseСами header names в HTTP case-insensitive: Content-Type и content-type — один заголовок. Но для многозначных заголовков есть нюанс: getHeader() возвращает массив значений, а getHeaderLine() склеивает их строкой. Для Set-Cookie склейка через запятую может быть неверной, поэтому с cookies лучше работать осторожно и помнить про Куки и сессии.
Streams: тело не обязано жить в памяти
Тело HTTP-сообщения может быть маленьким JSON, HTML-страницей, CSV на сотни мегабайт или файлом upload. Поэтому PSR-7 использует StreamInterface. Поток можно читать, писать, перематывать, проверять на seekable/readable/writable. Это близко к теме Файловая система и stream wrappers: в PHP body часто оказывается вокруг php://input, php://temp, временного файла или сетевого потока.
Практический вывод: не делайте бездумно (string) $request->getBody() на больших payload. Для JSON API это нормально, если размер ограничен. Для файлов, proxy и больших экспортов лучше читать поток порциями или передавать его дальше как stream.
$body = (string) $request->getBody();
$data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);Такой код уместен для контролируемого JSON endpoint, но не для произвольной загрузки файла. Uploads в PSR-7 живут отдельно: getUploadedFiles() возвращает дерево UploadedFileInterface, а не сырой $_FILES. Это убирает странную структуру $_FILES для полей вида avatar[] и даёт единый API: getClientFilename(), getSize(), getError(), getStream(), moveTo().
ServerRequest: не вся входная информация одинаковая
ServerRequestInterface специально разделяет источники данных:
$query = $request->getQueryParams(); // обычно из $_GET
$body = $request->getParsedBody(); // form body или разобранный JSON
$cookies = $request->getCookieParams(); // обычно из $_COOKIE
$files = $request->getUploadedFiles(); // нормализованные uploads
$server = $request->getServerParams(); // SAPI/CGI окружениеЭто не отменяет валидацию из GET, POST и фильтрация ввода. PSR-7 только аккуратно приносит данные в приложение. Доверенными они от этого не становятся.
Отдельное поле — attributes. Туда роутер или middleware кладут внутренний контекст: найденный route, userId, объект пользователя, результат парсинга локали. Attributes — не пользовательский input. Это канал общения между слоями приложения.
$user = $request->getAttribute('user');
$routeName = $request->getAttribute('routeName');PSR-15: middleware вокруг request handler
PSR-15 добавляет два интерфейса поверх PSR-7: MiddlewareInterface и RequestHandlerInterface. Handler получает ServerRequestInterface и возвращает ResponseInterface. Middleware может сделать что-то до handler, после него или вообще не вызывать следующий слой.
<?php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class SecurityHeadersMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$response = $handler->handle($request);
return $response
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('Referrer-Policy', 'same-origin');
}
}Такой слой хорошо подходит для кросс-срезов: security headers, логирование, request ID, auth, CSRF-проверка из CSRF и state-changing запросы, обработка исключений из Ошибки, исключения и Throwable. Middleware может и «закоротить» pipeline: например, вернуть 401 до контроллера, если пользователь не авторизован.
Обычно первым в pipeline ставят middleware, который ловит исключения и превращает их в response. Тогда приложение чаще возвращает корректный HTTP-ответ вместо голого fatal error. Это не замена логированию и настройкам из Конфигурация безопасности PHP: на проде детали исключений всё равно не должны утекать пользователю.
PSR-17 и PSR-18: создание сообщений и HTTP-клиенты
PSR-7 описывает форму объектов, но не говорит, как их создавать. Для этого есть PSR-17: фабрики request, response, server request, stream, URI и uploaded file. Благодаря фабрикам middleware и библиотеки не привязываются к конкретной реализации вроде Diactoros, Nyholm PSR-7, Slim PSR-7 или Guzzle PSR-7.
PSR-18 описывает общий интерфейс HTTP-клиента: ClientInterface::sendRequest(RequestInterface $request): ResponseInterface. Это полезно для библиотек: SDK может зависеть от интерфейса, а приложение само решит, использовать Guzzle, Symfony HttpClient с PSR-18 adapter или другой клиент.
<?php
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
final class RatesApi
{
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requests,
) {}
public function latest(): array
{
$request = $this->requests
->createRequest('GET', 'https://api.example.test/rates')
->withHeader('Accept', 'application/json');
$response = $this->http->sendRequest($request);
if ($response->getStatusCode() >= 400) {
throw new RuntimeException('Rates API returned HTTP ' . $response->getStatusCode());
}
return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
}
}Важная деталь PSR-18: 404 или 500 сами по себе не обязаны быть exception. Это валидный HTTP response. Exception — когда клиент вообще не смог отправить запрос или разобрать ответ. Поэтому проверка status code остаётся задачей вызывающего кода.
Где это встречается на практике
PSR-7/15 особенно заметны в microframework-экосистеме: Slim и Mezzio строят request/response flow вокруг этих интерфейсов. В крупных фреймворках вроде Symfony и Laravel могут быть свои основные HTTP-абстракции, но PSR-интерфейсы всё равно часто появляются в пакетах, middleware, SDK и интеграциях Composer-пакетов из Composer, Packagist и composer.json.
Практическое правило простое: внутри приложения можно жить на абстракциях фреймворка, но библиотечный код лучше писать против PSR-интерфейсов. Так он легче тестируется, проще переиспользуется и меньше тянет за собой конкретный HTTP stack.
См. также
- SAPI и суперглобалы — откуда берутся данные, которые PSR-7 упаковывает в
ServerRequestInterface. - HTTP-заголовки, ответы и редиректы — низкоуровневый вариант того, что в PSR-7 выражается через
ResponseInterface. - GET, POST и фильтрация ввода — почему PSR-7 не отменяет validation и безопасное преобразование input.
- Загрузка файлов — как
UploadedFileInterfaceнормализует работу с upload-файлами. - Куки и сессии — контекст для cookies,
Set-Cookieи персональных HTTP-ответов. - CSRF и state-changing запросы — типичный пример проверки, которую удобно вынести в middleware.
- Slim и Mezzio — фреймворки, где PSR-7/15 являются частью основной архитектуры.
- Autoloading и PSR-4 — другой стандарт PHP-FIG, который обычно идёт рядом с PSR HTTP-интерфейсами.