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-4 | Autoloading | composer.json (урок 16) |
| PSR-12 | Coding style | phpcs, статический анализ |
| PSR-11 | Container interface | DI-контейнеры (следующий урок) |
| PSR-14 | Event dispatcher | Symfony EventDispatcher |
| PSR-16 | Simple cache | redis/file/memory кэши |
| PSR-17 | HTTP factories | пара к PSR-7 |
| PSR-18 | HTTP client | Guzzle, 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 подсунет реализацию.
Типичные ошибки
- Конкретный логгер в типе аргумента.
function f(Monolog\Logger $log)- плохо, ты привязался к Monolog.function f(Psr\Log\LoggerInterface $log)- хорошо: тесты могут подсунутьNullLogger, прод - Monolog. - Запись интерполированных сообщений вместо контекста.
info("user $id failed: $err")- теряешь структуру.info('user failed', ['user_id' => $id, 'error' => $err])- можно искать поuser_idв Kibana/Loki. - Мутирование PSR-7 объектов.
$request->headers['X'] = 'Y'- так нельзя, объекты immutable. ТолькоwithHeader(), возвращающий новый. - Самописный 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