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]
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]
Как суперглобалы превращаются в PSR-7 request, проходят через PSR-15 middleware и возвращаются как HTTP response.
Quick recall
Зачем в PHP-приложении вводят PSR-7 слой поверх `$_SERVER`, `$_GET`, `$_POST`, `$_FILES`, `header()` и `echo`?

Сообщение: метод, 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 лучше работать осторожно и помнить про Куки и сессии.

Quick recall
Что происходит с PSR-7 response после вызова `$response->withHeader('Content-Type', 'application/json')`, если результат не присвоить?

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().

Quick recall
Когда `(string) $request->getBody()` уместен, а когда это плохая идея?

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.

См. также

Sources

  1. PSR-7: HTTP message interfaces — PHP-FIG
  2. PSR-15: HTTP Server Request Handlers — PHP-FIG
  3. PSR-17: HTTP Factories — PHP-FIG
  4. PSR-18: HTTP Client — PHP-FIG
  5. Guzzle and PSR-7 — Guzzle Documentation
  6. Supported Protocols and Wrappers — PHP Manual