PSR Standards in Practice
Job descriptions for PHP developers often say "knowledge of PSR standards". The letters look mysterious, but the idea is simple. A PSR (PHP Standard Recommendation) is an agreement, published by a group of framework and library maintainers called PHP-FIG (the PHP Framework Interop Group), about how a common thing should look. Most PSRs are just a few small interfaces. When your code depends on those interfaces instead of one library's classes, you can swap libraries without rewriting your code.
In this lesson you will learn which PSRs matter in daily work, and use the most important ones in real code: a PSR-3 logger, immutable PSR-7 request objects and a PSR-15 middleware.
The PSRs worth knowing
Each PSR has a number. Numbers are given when work starts, so they are not in any order of importance, and some numbers were abandoned. These are the accepted ones you are likely to meet:
| PSR | Topic | Where you meet it |
|---|---|---|
| PSR-1, PSR-12 | Basic and extended coding style | Every code review; now continued as the PER Coding Style |
| PSR-4 | Autoloading: namespace prefix to folder | Every composer.json |
| PSR-3 | Logger interface | Monolog, Laravel and Symfony logging |
| PSR-7 | HTTP request and response objects | Guzzle, Slim, many API libraries |
| PSR-17 | Factories that create PSR-7 objects | Libraries that need to build requests |
| PSR-15 | HTTP handlers and middleware | Slim, Mezzio and other middleware frameworks |
| PSR-18 | HTTP client interface | API SDKs that let you choose the HTTP library |
| PSR-11 | Container interface (get(), has()) | Dependency injection containers |
| PSR-6, PSR-16 | Caching (full and simple) | Symfony Cache and other cache libraries |
| PSR-14 | Event dispatcher | Event systems in frameworks |
| PSR-20 | Clock interface | Code that needs the current time and must be testable |
Two older ones are deprecated: PSR-0 (the first autoloading standard, replaced by PSR-4) and PSR-2 (coding style, replaced by PSR-12). You will still see them mentioned in old projects. The full list is on php-fig.org.
The interface PSRs come as tiny Composer packages with no real code, only interfaces: psr/log, psr/http-message, psr/container, psr/clock and so on. Libraries require them, and your code type-hints against them.
PSR-4 and the coding style rules are covered in Namespaces & Composer and Best Practices & Tooling. PSR-11 is the container interface from Dependency Injection & Containers. The rest of this lesson focuses on the interfaces that shape how you design classes.
PSR-3: depend on a logger, not on Monolog
Psr\Log\LoggerInterface has one method per log level: emergency, alert, critical, error, warning, notice, info and debug, plus a general log(). Every method takes a message and a context array. Placeholders in braces, such as {sku}, may be replaced by context values.
A class that needs logging should ask for the interface:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;
final class StockChecker
{
public function __construct(private LoggerInterface $logger)
{
}
public function check(string $sku, int $left): void
{
if ($left < 5) {
$this->logger->warning('Low stock for {sku}: {left} left', ['sku' => $sku, 'left' => $left]);
}
}
}
// A tiny logger for this demo. AbstractLogger turns warning() etc. into log() calls.
final class ScreenLogger extends AbstractLogger
{
public function log($level, string|\Stringable $message, array $context = []): void
{
$replace = [];
foreach ($context as $key => $value) {
$replace['{' . $key . '}'] = (string) $value;
}
echo strtoupper((string) $level), ': ', strtr((string) $message, $replace), "\n";
}
}
$checker = new StockChecker(new ScreenLogger());
$checker->check('TEA-250', 3);
$checker->check('RICE-5KG', 40);WARNING: Low stock for TEA-250: 3 left
StockChecker never mentions a specific library. In production you pass a Monolog logger (see Logging with Monolog); in Laravel or Symfony the framework injects its own PSR-3 logger; in tests you can pass Psr\Log\NullLogger, which ignores everything. Keep the message text fixed and put changing values in the context: log tools can then group all "Low stock" lines together.
PSR-7: requests and responses as immutable objects
PHP's superglobals ($_GET, $_SERVER, header()) are global and changeable. PSR-7 instead describes an HTTP request or response as an object with methods such as getMethod(), getUri(), getHeaderLine() and getBody().
The most important rule of PSR-7 is immutability: an object never changes after creation. Methods that start with with return a new object with the change, and the original stays as it was. Guzzle's PSR-7 package shows this:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Psr7\Request;
$request = new Request('GET', 'https://api.example.in/trains?from=JP');
$withAuth = $request->withHeader('Authorization', 'Bearer demo-token');
var_dump($request->hasHeader('Authorization'));
var_dump($withAuth->hasHeader('Authorization'));
echo $withAuth->getMethod(), ' ', $withAuth->getUri()->getPath(), '?', $withAuth->getUri()->getQuery(), "\n";bool(false) bool(true) GET /trains?from=JP
The classic bug is writing $request->withHeader(...) on its own line and expecting $request to change. It does not; always assign the result. Immutability makes objects safe to pass around: a middleware cannot secretly change a request that another part of the code is still using.
PSR-17 adds factories (RequestFactoryInterface, ResponseFactoryInterface and others) so a library can create PSR-7 objects without knowing which PSR-7 package you installed.
PSR-15: middleware
A middleware is code that runs before and after the main handler of every request: checking an API key, adding security headers, logging timings, handling CORS. PSR-15 defines two interfaces:
RequestHandlerInterfacewithhandle(ServerRequestInterface $request): ResponseInterface, the thing that finally produces a response.MiddlewareInterfacewithprocess(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface. A middleware either returns a response itself (blocking the request) or passes it on with$handler->handle($request).
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class RequireApiKey implements MiddlewareInterface
{
public function __construct(private string $key)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (!hash_equals($this->key, $request->getHeaderLine('X-Api-Key'))) {
return new Response(401, ['Content-Type' => 'application/json'], '{"error":"Invalid API key"}');
}
return $handler->handle($request);
}
}
final class TrainList implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
return new Response(200, ['Content-Type' => 'application/json'], '{"trains":["12956","12904"]}');
}
}
/** Wraps a handler in one middleware, producing a new handler. */
final class Pipeline implements RequestHandlerInterface
{
public function __construct(
private MiddlewareInterface $middleware,
private RequestHandlerInterface $next,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->middleware->process($request, $this->next);
}
}
$app = new Pipeline(new RequireApiKey('k-2026-jaipur'), new TrainList());
foreach (['wrong-key', 'k-2026-jaipur'] as $key) {
$request = (new ServerRequest('GET', '/api/trains'))->withHeader('X-Api-Key', $key);
$response = $app->handle($request);
echo $response->getStatusCode(), ' ', $response->getBody(), "\n";
}401 {"error":"Invalid API key"}
200 {"trains":["12956","12904"]}Because Pipeline is itself a handler, you can wrap it again: new Pipeline(new SecurityHeaders(), new Pipeline(new RequireApiKey(...), new TrainList())). Each middleware is a small, testable class that works with any PSR-15 framework. (The interfaces come from composer require psr/http-server-middleware; this demo uses Guzzle's PSR-7 classes.)
PSR-18 and PSR-20 in one paragraph each
PSR-18 defines ClientInterface with one method, sendRequest(RequestInterface $request): ResponseInterface. An SDK for an SMS or payment provider can require "any PSR-18 client" instead of forcing a specific HTTP library. Guzzle's client implements it. HTTP Clients & Webhooks covers calling APIs in depth.
PSR-20 defines ClockInterface with a single method now(): DateTimeImmutable. Code that asks a clock for the time, instead of calling new DateTimeImmutable() directly, can be tested with a fixed time, for example to check that a library fine starts on the 15th day.
Common mistakes
- Thinking a PSR is a library. Most PSRs are interfaces; you still need a package that implements them, such as Monolog for PSR-3 or Guzzle for PSR-7 and PSR-18.
- Ignoring the return value of
with...()methods. PSR-7 objects are immutable. - Building log messages with string joining. Write
'Login failed for {email}'with context, not"Login failed for $email". - Following PSR-2 or PSR-0 in new code. Use PSR-12 / PER Coding Style and PSR-4.
Key points
- PSRs are PHP-FIG agreements, mostly small interfaces, that let libraries work together.
- Type-hint against PSR interfaces (
LoggerInterface,ClientInterface,ContainerInterface) to keep code swappable and testable. - PSR-3 logging uses levels, a fixed message and a context array.
- PSR-7 messages are immutable; every
with...()returns a new object. - PSR-15 middleware either returns a response or passes the request to the next handler.
- PSR-0 and PSR-2 are deprecated; use PSR-4 and PSR-12 / PER Coding Style.
Exercise
Write a PSR-15 middleware AddSecurityHeaders that lets the request pass through, then adds X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin to the response before returning it. Wrap the TrainList handler with it and print the two header values for one request.
Show solution
final class AddSecurityHeaders 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', 'strict-origin-when-cross-origin');
}
}
$app = new Pipeline(new AddSecurityHeaders(), new TrainList());
$response = $app->handle(new ServerRequest('GET', '/api/trains'));
echo $response->getHeaderLine('X-Content-Type-Options'), "\n";
echo $response->getHeaderLine('Referrer-Policy'), "\n";nosniff strict-origin-when-cross-origin
This middleware works after the handler: it receives the finished response and returns a new, changed copy, because PSR-7 responses are immutable.
Quick Quiz
1. What is PSR-3 about?
Psr\Log\LoggerInterface lets any class log without depending on a specific logging library.
2. What does $request->withHeader('X-Id', '7') do to $request?
PSR-7 objects are immutable, so you must use the returned object.
3. Which standard replaced PSR-0?
PSR-4 is the current autoloading standard used by Composer.
4. A PSR-15 middleware wants to block a request. What does it do?
Not calling the next handler stops the request, and the middleware's response is sent instead.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
