EthicsComputerCourses
Join training +91 90532 10052

PSR Standards in Practice

PHP · Lesson 55 of 63 Advanced 7 min read Updated

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:

PSRTopicWhere you meet it
PSR-1, PSR-12Basic and extended coding styleEvery code review; now continued as the PER Coding Style
PSR-4Autoloading: namespace prefix to folderEvery composer.json
PSR-3Logger interfaceMonolog, Laravel and Symfony logging
PSR-7HTTP request and response objectsGuzzle, Slim, many API libraries
PSR-17Factories that create PSR-7 objectsLibraries that need to build requests
PSR-15HTTP handlers and middlewareSlim, Mezzio and other middleware frameworks
PSR-18HTTP client interfaceAPI SDKs that let you choose the HTTP library
PSR-11Container interface (get(), has())Dependency injection containers
PSR-6, PSR-16Caching (full and simple)Symfony Cache and other cache libraries
PSR-14Event dispatcherEvent systems in frameworks
PSR-20Clock interfaceCode 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
<?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);
Output
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
<?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";
Output
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:

  • RequestHandlerInterface with handle(ServerRequestInterface $request): ResponseInterface, the thing that finally produces a response.
  • MiddlewareInterface with process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface. A middleware either returns a response itself (blocking the request) or passes it on with $handler->handle($request).
PHP
<?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";
}
Output
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
PHP
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";
Output
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?

2. What does $request->withHeader('X-Id', '7') do to $request?

3. Which standard replaced PSR-0?

4. A PSR-15 middleware wants to block a request. What does it do?

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