PSR-стандарты

PSR (PHP Standard Recommendation) - это набор интерфейсов и соглашений, на которых построена вся современная экосистема PHP. Их выпускает PHP-FIG (Framework Interop Group), куда входят Symfony, Laravel, Drupal, Magento, PhpUnit и другие крупные игроки. Смысл прост: договориться о контрактах, чтобы библиотеки разных авторов работали друг с другом.

Самые ходовые на бэкенде - PSR-3 (логгер), PSR-7 (HTTP messages), PSR-15 (HTTP middleware), плюс уже знакомый PSR-4 (autoloading).

PSR-3: Logger

Это интерфейс из ~10 методов, который реализуют ВСЕ нормальные логгеры:

<?php
namespace Psr\Log;

interface LoggerInterface
{
    public function emergency(string|\Stringable $message, array $context = []): void;
    public function alert(string|\Stringable $message, array $context = []): void;
    public function critical(string|\Stringable $message, array $context = []): void;
    public function error(string|\Stringable $message, array $context = []): void;
    public function warning(string|\Stringable $message, array $context = []): void;
    public function notice(string|\Stringable $message, array $context = []): void;
    public function info(string|\Stringable $message, array $context = []): void;
    public function debug(string|\Stringable $message, array $context = []): void;
    public function log($level, string|\Stringable $message, array $context = []): void;
}

Что это даёт: твой код пишет $logger->info('something'), не зная, что под капотом - Monolog, Symfony Logger, файловый writer или мок в тестах. Можно поменять реализацию, не трогая бизнес-код.

composer require psr/log monolog/monolog
<?php
use Psr\Log\LoggerInterface;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

function chargeUser(LoggerInterface $log, int $userId, int $amount): void
{
    $log->info('Charging user', ['user_id' => $userId, 'amount' => $amount]);
    // ...
    $log->info('Charge complete', ['user_id' => $userId]);
}

$logger = new Logger('payments');
$logger->pushHandler(new StreamHandler('php://stdout'));

chargeUser($logger, 42, 1000);
// {"message":"Charging user","context":{"user_id":42,"amount":1000},"level":200,...}

Поле context - массив со структурированными данными. Не пиши info("user $id charged $amount") - потеряешь возможность фильтровать логи по полям. Пиши info('user charged', ['user_id' => $id, 'amount' => $amount]).

PSR-7: HTTP-сообщения

PSR-7 описывает immutable объекты для HTTP-запроса и ответа: RequestInterface, ResponseInterface, UriInterface, StreamInterface. Цель - стандартизировать представление HTTP, чтобы middleware и HTTP-клиенты работали между фреймворками.

composer require nyholm/psr7 nyholm/psr7-server
<?php
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;

$factory = new Psr17Factory();
$creator = new ServerRequestCreator($factory, $factory, $factory, $factory);

$request = $creator->fromGlobals();
// $request реализует Psr\Http\Message\ServerRequestInterface

echo $request->getMethod();          // GET / POST / ...
echo $request->getUri()->getPath();  // /api/users
$body = (string) $request->getBody();
$json = json_decode($body, true);

Immutability в PSR-7

<?php
$req2 = $request->withHeader('X-User', 'admin');

// $request НЕ изменился, $req2 - новый объект
$request->getHeader('X-User'); // []
$req2->getHeader('X-User');    // ['admin']

С каждым withX() возвращается новый объект - старый трогать нельзя. Так middleware-цепочки безопасны: один middleware не сломает другой случайным изменением общего стейта.

Создание ответа

<?php
$response = $factory->createResponse(200)
 ->withHeader('Content-Type', 'application/json')
 ->withBody($factory->createStream(json_encode(['ok' => true])));

http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
    foreach ($values as $value) {
        header("$name: $value", false);
    }
}
echo $response->getBody();

В реальных приложениях это делает PSR-7 emitter (например, laminas/httphandlerrunner) - одна строка вместо ручного http_response_code + header().

PSR-15: Middleware и Request Handler

Middleware - это «слой» вокруг обработки запроса: логирование, авторизация, CORS, обработка ошибок. Стандарт описывает два контракта:

<?php
namespace Psr\Http\Server;

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface;
}

Каждый middleware либо возвращает свой ответ, либо вызывает $handler->handle($request) и работает с ответом следующего звена. Цепочка похожа на луковицу: запрос идёт внутрь, ответ - наружу.

<?php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $token = $request->getHeaderLine('Authorization');

        if ($token !== 'Bearer secret') {
            return (new \Nyholm\Psr7\Response(401))
 ->withHeader('WWW-Authenticate', 'Bearer');
        }

        // авторизованы → пропускаем дальше
        return $handler->handle($request);
    }
}

final class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(private \Psr\Log\LoggerInterface $log) {}

    public function process($request, $handler): ResponseInterface
    {
        $start = microtime(true);
        $response = $handler->handle($request);
        $ms = (microtime(true) - $start) * 1000;
        $this->log->info('request', [
            'method'   => $request->getMethod(),
            'path'     => $request->getUri()->getPath(),
            'status'   => $response->getStatusCode(),
            'ms'       => round($ms, 1),
        ]);
        return $response;
    }
}

Простой runner

<?php
final class MiddlewareRunner implements RequestHandlerInterface
{
    /** @param MiddlewareInterface[] $middlewares */
    public function __construct(
        private array $middlewares,
        private RequestHandlerInterface $final,
    ) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($this->middlewares === []) {
            return $this->final->handle($request);
        }
        $current = array_shift($this->middlewares);
        return $current->process($request, $this);
    }
}

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

<?php
$runner = new MiddlewareRunner(
    [new LoggingMiddleware($log), new AuthMiddleware()],
    $finalHandler,  // твоё «дело» - отдать JSON
);
$response = $runner->handle($request);

LoggingMiddleware всегда снаружи (логирует и аутентичные, и отбитые 401), AuthMiddleware - внутри.

Прочие важные PSR

PSRТемаГде встречается
PSR-4Autoloadingcomposer.json (урок 16)
PSR-12Coding stylephpcs, статический анализ
PSR-11Container interfaceDI-контейнеры (следующий урок)
PSR-14Event dispatcherSymfony EventDispatcher
PSR-16Simple cacheredis/file/memory кэши
PSR-17HTTP factoriesпара к PSR-7
PSR-18HTTP clientGuzzle, Symfony HTTP Client

Запоминать наизусть номера не нужно - нужно знать, что для большинства тем уже есть стандартный интерфейс, и при выборе библиотеки спрашивать: «А она PSR-совместимая?». Если нет - это тревожный знак.

Как это в Symfony

Symfony - главный двигатель PSR. Многие его компоненты либо реализуют PSR напрямую, либо предоставляют адаптеры:

  • Symfony\Component\HttpFoundation - собственный HTTP-слой, исторически старше PSR-7; есть PSR-7 bridge в обе стороны (symfony/psr-http-message-bridge)
  • Symfony\Bridge\Monolog - PSR-3 поверх Monolog «из коробки»
  • Symfony\Component\DependencyInjection - PSR-11 совместимый контейнер
  • Symfony\Component\HttpClient - реализует PSR-18
  • Symfony Messenger / EventDispatcher - следуют идее PSR-14

Когда ты пишешь Symfony-контроллер с LoggerInterface $log в конструкторе - это именно Psr\Log\LoggerInterface. Autowiring подсунет реализацию.

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

  1. Конкретный логгер в типе аргумента. function f(Monolog\Logger $log) - плохо, ты привязался к Monolog. function f(Psr\Log\LoggerInterface $log) - хорошо: тесты могут подсунуть NullLogger, прод - Monolog.
  2. Запись интерполированных сообщений вместо контекста. info("user $id failed: $err") - теряешь структуру. info('user failed', ['user_id' => $id, 'error' => $err]) - можно искать по user_id в Kibana/Loki.
  3. Мутирование PSR-7 объектов. $request->headers['X'] = 'Y' - так нельзя, объекты immutable. Только withHeader(), возвращающий новый.
  4. Самописный middleware-runner, не соответствующий PSR-15. Если у тебя function (Request, callable $next) - это не PSR-15, и middleware из чужих пакетов не подключатся. Используй relay/relay или laminas/stratigility если не хочешь свой.

Best practices

  • Принимай зависимости через PSR-интерфейсы (LoggerInterface, не Monolog\Logger) - код не знает реализацию, тесты подсовывают мок.
  • Пиши структурированный контекст info('event', ['user_id' => $id]) вместо интерполяции info("user $id") - Loki и Kibana умеют фильтровать по полям.
  • Для middleware-стека используй PSR-15 совместимые пакеты (relay/relay, laminas/stratigility) - тогда чужие middleware подключатся без адаптеров.
  • Перед выбором любой PHP-библиотеки проверяй PSR-совместимость: несовместимый логгер или HTTP-клиент создают технический долг при замене.
  • Не мутируй PSR-7 объекты напрямую - только withHeader()/withBody(), возвращающие новый immutable экземпляр.

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

  • Установи psr/log и monolog/monolog. Функция function audit(LoggerInterface $log, string $action, array $ctx) - пишет уровень INFO
  • Установи nyholm/psr7 и nyholm/psr7-server. Создай $request = $creator->fromGlobals(), выведи метод, путь и тело
  • Создай ответ через PSR-17 factory с JSON-телом и заголовком Content-Type
  • Реализуй RequestIdMiddleware implements MiddlewareInterface - добавляет в request заголовок X-Request-Id (uuid), а в ответ - этот же заголовок
  • Собери MiddlewareRunner из 2 middleware (Logging + RequestId) и финального handler, который возвращает {ok: true} JSON

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