PSR Standards and Coding Style

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 and the PSR interface packages current on 2026-10-05 (psr/log 3, psr/http-message 2, psr/container 2, psr/simple-cache 3, …); the examples were run with nyholm/psr7 1.8 as PSR-7/PSR-17 implementation. The PHP Framework Interop Group (PHP-FIG) publishes PHP Standard Recommendations so that libraries from different projects work together: a logger, a cache or an HTTP client written against a PSR interface works with every framework that accepts that interface.

The accepted PSRs at a glance

PSR Name Package / what it defines

PSR-1

Basic Coding Standard

file, naming and side-effect rules

PSR-3

Logger Interface

psr/log: LoggerInterface, eight log levels

PSR-4

Autoloader

namespace prefix to directory mapping

PSR-6

Caching Interface

psr/cache: pools and items

PSR-7

HTTP Message Interfaces

psr/http-message: immutable requests, responses, URIs, streams

PSR-11

Container Interface

psr/container: get() and has()

PSR-12

Extended Coding Style

layout rules; superseded by PER Coding Style

PSR-13

Link Definition Interfaces

psr/link: hypermedia links

PSR-14

Event Dispatcher

psr/event-dispatcher: dispatcher, listener provider, stoppable events

PSR-15

HTTP Server Request Handlers

psr/http-server-handler, psr/http-server-middleware

PSR-16

Common Interface for Caching Libraries

psr/simple-cache: a simpler key/value cache

PSR-17

HTTP Factories

psr/http-factory: factories for PSR-7 objects

PSR-18

HTTP Client

psr/http-client: sendRequest()

PSR-20

Clock

psr/clock: now()

PSR-0 (autoloading) and PSR-2 (coding style) are deprecated; PSR-12 is superseded by the PER Coding Style. Other numbers were abandoned or never accepted. The PHP-FIG now also publishes PERs (PHP Evolving Recommendations), which are revised as the language changes.

Style and structure: PSR-1, PSR-4, PSR-12 and PER Coding Style

PSR-1 sets the basics: only <?php and <?= tags, UTF-8 without BOM, a file either declares symbols or causes side effects, classes in StudlyCaps, constants in UPPER_CASE, methods in camelCase. PSR-4 defines autoloading by namespace prefix (see Namespaces and Autoloading).

PSR-12 (2019) extended the old PSR-2 with rules for PHP 7 syntax; the PER Coding Style replaces it and is updated for new syntax. Version 3.0 covers, among others, attributes, enums, match, readonly and property hooks, short closures and the pipe operator. The layout of the examples in this section follows it: four-space indentation, braces on their own line for classes and methods, declare(strict_types=1); after the opening tag, one blank line after the namespace and import blocks, and no closing tag.

PSR-3: Logger Interface

Psr\Log\LoggerInterface has one method per RFC 5424 level (emergency, alert, critical, error, warning, notice, info, debug) plus log($level, …). Messages may contain {placeholders} replaced from the context array; an exception goes in the context under the exception key. Monolog is the reference implementation, used by Laravel and Symfony.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;

final class StderrLogger extends AbstractLogger
{
    public function log($level, string|Stringable $message, array $context = []): void
    {
        $replace = [];
        foreach ($context as $key => $value) {
            if (is_scalar($value) || $value instanceof Stringable) {
                $replace['{' . $key . '}'] = (string) $value;
            }
        }
        fwrite(STDOUT, sprintf("[%s] %s\n", strtoupper($level), strtr((string) $message, $replace)));
    }
}

function lend(LoggerInterface $logger, string $isbn, string $member): void   // depends on the interface only
{
    $logger->info('Book {isbn} lent to {member}', ['isbn' => $isbn, 'member' => $member]);
}

lend(new StderrLogger(), '978-0-00-000001-1', 'M-17');   // [INFO] Book 978-0-00-000001-1 lent to M-17

PSR-6 and PSR-16: caching

PSR-6 models a cache as a pool of items (getItem(), save(), deferred saves, expiration per item); it suits libraries that need fine control. PSR-16 ("simple cache") is a plain key/value API: get, set with a TTL, delete, clear, has and the *Multiple variants. Most applications use PSR-16 or their framework’s cache; Symfony Cache implements both, and Laravel’s cache repository implements PSR-16.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\SimpleCache\CacheInterface;

/** A minimal in-memory PSR-16 cache (for tests). */
final class ArrayCache implements CacheInterface
{
    private array $items = [];

    public function get(string $key, mixed $default = null): mixed
    {
        [$value, $expires] = $this->items[$key] ?? [null, 0];
        return ($expires === null || $expires > time()) && array_key_exists($key, $this->items) ? $value : $default;
    }
    public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
    {
        $seconds = $ttl instanceof DateInterval ? (new DateTimeImmutable())->add($ttl)->getTimestamp() - time() : $ttl;
        $this->items[$key] = [$value, $seconds === null ? null : time() + $seconds];
        return true;
    }
    public function delete(string $key): bool { unset($this->items[$key]); return true; }
    public function clear(): bool { $this->items = []; return true; }
    public function getMultiple(iterable $keys, mixed $default = null): iterable
    {
        foreach ($keys as $key) { yield $key => $this->get($key, $default); }
    }
    public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
    {
        foreach ($values as $key => $value) { $this->set($key, $value, $ttl); }
        return true;
    }
    public function deleteMultiple(iterable $keys): bool { foreach ($keys as $key) { $this->delete($key); } return true; }
    public function has(string $key): bool { return $this->get($key, $this) !== $this; }
}

function popularTitles(CacheInterface $cache): array
{
    $titles = $cache->get('popular');
    if ($titles === null) {
        $titles = ['Kindred', 'Solaris'];             // imagine an expensive query
        $cache->set('popular', $titles, 600);         // ten minutes
    }
    return $titles;
}

$cache = new ArrayCache();
print_r(popularTitles($cache));
var_dump($cache->has('popular'));                     // bool(true)

PSR-7 and PSR-17: HTTP messages and factories

PSR-7 models HTTP requests, server requests (with query, body, cookies, uploaded files and attributes), responses, URIs and body streams as immutable objects: every with…() method returns a modified copy. PSR-17 defines factories to create them, so code does not depend on a particular implementation (Nyholm, Guzzle, Laminas Diactoros, Slim).

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Nyholm\Psr7\Factory\Psr17Factory;

$factory = new Psr17Factory();                                // implements every PSR-17 factory interface

$request = $factory->createServerRequest('GET', 'https://library.example/books?author=Butler')
    ->withHeader('Accept', 'application/json');

$response = $factory->createResponse(200)
    ->withHeader('Content-Type', 'application/json')
    ->withBody($factory->createStream(json_encode(['titles' => ['Kindred', 'Dawn']], JSON_THROW_ON_ERROR)));

echo $request->getMethod(), ' ', $request->getUri()->getPath(), ' author=', $request->getQueryParams()['author'] ?? '-', "\n";
echo $response->getStatusCode(), ' ', $response->getHeaderLine('Content-Type'), ' ', $response->getBody(), "\n";

$notFound = $response->withStatus(404);                      // immutable: a new object
echo $response->getStatusCode(), ' ', $notFound->getStatusCode(), "\n";   // 200 404

Laravel uses Symfony’s HttpFoundation rather than PSR-7 internally, but can convert to PSR-7 objects on demand.

PSR-15: middleware and request handlers

PSR-15 defines RequestHandlerInterface (handle(ServerRequestInterface): ResponseInterface) and MiddlewareInterface (process($request, $handler)): a middleware can inspect or change the request, short-circuit with its own response, or delegate to the next handler and modify the result. Slim and Mezzio pipelines are built on it (see Templating, Routing and Middleware by Hand).

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Nyholm\Psr7\Factory\Psr17Factory;
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 Psr17Factory $factory, private string $expectedKeyHash) {}

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $given = hash('sha256', $request->getHeaderLine('X-Api-Key'));
        if (!hash_equals($this->expectedKeyHash, $given)) {
            return $this->factory->createResponse(401);              // short-circuit
        }
        return $handler->handle($request)->withHeader('X-Checked', 'yes');   // delegate, then decorate
    }
}

final class CatalogueHandler implements RequestHandlerInterface
{
    public function __construct(private Psr17Factory $factory) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        return $this->factory->createResponse(200)->withBody($this->factory->createStream('Kindred, Dawn'));
    }
}

$f = new Psr17Factory();
$middleware = new RequireApiKey($f, hash('sha256', 'demo-key'));
$handler = new CatalogueHandler($f);

foreach (['demo-key', 'wrong'] as $key) {
    $response = $middleware->process($f->createServerRequest('GET', '/books')->withHeader('X-Api-Key', $key), $handler);
    echo $response->getStatusCode(), ' ', $response->getHeaderLine('X-Checked'), ' ', $response->getBody(), "\n";
}
// 200 yes Kindred, Dawn
// 401

PSR-11: Container Interface

Psr\Container\ContainerInterface exposes get(string $id) and has(string $id), with NotFoundExceptionInterface for missing entries. It lets libraries read services from any container without caring how the container is configured. Laravel’s and Symfony’s containers implement it; how a container builds objects is shown in Reflection.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;

final class ServiceNotFound extends RuntimeException implements NotFoundExceptionInterface {}

final class MapContainer implements ContainerInterface
{
    /** @param array<string, Closure(ContainerInterface): mixed> $factories */
    public function __construct(private array $factories, private array $instances = []) {}

    public function get(string $id): mixed
    {
        return $this->instances[$id] ??= ($this->factories[$id] ?? throw new ServiceNotFound("No service {$id}"))($this);
    }

    public function has(string $id): bool { return isset($this->factories[$id]); }
}

$c = new MapContainer([
    'loan_days' => fn (): int => 21,
    'due_date' => fn (ContainerInterface $c): Closure => fn (string $from): string =>
        (new DateTimeImmutable($from))->modify('+' . $c->get('loan_days') . ' days')->format('Y-m-d'),
]);

echo $c->get('due_date')('2026-10-05'), "\n";     // 2026-10-26
var_dump($c->has('mailer'));                       // bool(false)

PSR-14: Event Dispatcher

PSR-14 separates the dispatcher (dispatch(object $event): object) from a listener provider (getListenersForEvent(object $event): iterable). Events are plain objects; an event implementing StoppableEventInterface can stop propagation. Symfony EventDispatcher and Laravel’s event system follow the same idea (see Events and Listeners).

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;

final class BookReturned { public function __construct(public readonly string $isbn, public readonly int $daysLate) {} }

final class ByClassProvider implements ListenerProviderInterface
{
    /** @param array<class-string, list<callable>> $listeners */
    public function __construct(private array $listeners) {}

    public function getListenersForEvent(object $event): iterable
    {
        return $this->listeners[$event::class] ?? [];
    }
}

final class Dispatcher implements EventDispatcherInterface
{
    public function __construct(private ListenerProviderInterface $provider) {}

    public function dispatch(object $event): object
    {
        foreach ($this->provider->getListenersForEvent($event) as $listener) {
            if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
                break;
            }
            $listener($event);
        }
        return $event;
    }
}

$dispatcher = new Dispatcher(new ByClassProvider([
    BookReturned::class => [
        fn (BookReturned $e) => print("check in {$e->isbn}\n"),
        fn (BookReturned $e) => $e->daysLate > 0 ? print("fine for {$e->daysLate} days\n") : null,
    ],
]));

$dispatcher->dispatch(new BookReturned('978-0-00-000001-1', 3));

PSR-18: HTTP Client

Psr\Http\Client\ClientInterface::sendRequest(RequestInterface): ResponseInterface is the common interface of HTTP clients (Guzzle, Symfony HttpClient’s Psr18Client, Buzz). Code that depends on it can be tested with a fake client and run with any implementation:

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

final class FakeClient implements ClientInterface
{
    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $f = new Psr17Factory();
        return $f->createResponse(200)->withBody($f->createStream('{"title":"Kindred"}'));
    }
}

function fetchTitle(ClientInterface $client, RequestFactoryInterface $requests, string $isbn): string
{
    $response = $client->sendRequest($requests->createRequest('GET', "https://books.example/api/{$isbn}"));
    return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)['title'];
}

echo fetchTitle(new FakeClient(), new Psr17Factory(), '978-0-00-000001-1'), "\n";   // Kindred

PSR-13 and PSR-20

PSR-13 defines LinkInterface and LinkProviderInterface for hypermedia links (an href, relations and attributes), used for HTTP Link headers and HAL/JSON:API-style responses; Symfony WebLink implements it. PSR-20 defines ClockInterface::now(): DateTimeImmutable, the standard way to make time-dependent code testable (full example in Date and Time).

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Psr\Clock\ClockInterface;
use Psr\Link\LinkInterface;

final class Link implements LinkInterface
{
    public function __construct(private string $href, private array $rels) {}
    public function getHref(): string { return $this->href; }
    public function isTemplated(): bool { return str_contains($this->href, '{'); }
    public function getRels(): array { return $this->rels; }
    public function getAttributes(): array { return []; }
}

final class FrozenClock implements ClockInterface
{
    public function now(): DateTimeImmutable { return new DateTimeImmutable('2026-10-05T09:00:00Z'); }
}

$next = new Link('/books?page=2', ['next']);
printf("Link: <%s>; rel=\"%s\"\n", $next->getHref(), implode(' ', $next->getRels()));   // Link: </books?page=2>; rel="next"
echo (new FrozenClock())->now()->format(DATE_ATOM), "\n";

Enforcing style: PHP_CodeSniffer, PHP-CS-Fixer and Pint

Three tools check and fix coding style automatically; run one in CI and in a pre-commit hook rather than reviewing whitespace by hand:

  • PHP_CodeSniffer (4.x, maintained by the PHPCSStandards organisation): phpcs reports violations of a standard (PSR12, or custom rule sets in phpcs.xml), phpcbf fixes what it can.

  • PHP-CS-Fixer (3.x): rewrites code according to rule sets such as @PER-CS and @PHP84Migration, configured in .php-cs-fixer.dist.php.

  • Laravel Pint: a zero-configuration wrapper around PHP-CS-Fixer with presets (laravel, per, psr12), included in new Laravel applications.

composer require --dev squizlabs/php_codesniffer friendsofphp/php-cs-fixer

vendor/bin/phpcs --standard=PSR12 src/            # report
vendor/bin/phpcbf --standard=PSR12 src/           # fix
vendor/bin/php-cs-fixer fix --rules=@PER-CS --dry-run --diff src/
vendor/bin/pint --preset per                      # in a Laravel project
<?php

declare(strict_types=1);

// .php-cs-fixer.dist.php
return (new PhpCsFixer\Config())
    ->setRiskyAllowed(true)
    ->setRules([
        '@PER-CS' => true,
        '@PHP84Migration' => true,
        'declare_strict_types' => true,
        'ordered_imports' => true,
    ])
    ->setFinder(PhpCsFixer\Finder::create()->in(__DIR__ . '/src'));

Where older books and tutorials are outdated

  • PSR-2 (Kromann ch. 21) was deprecated in 2019; PSR-12 is itself superseded by PER Coding Style 3.0.

  • PSR-0 autoloading was deprecated in 2014 in favour of PSR-4.

  • The PSRs that matter most for application code today — PSR-11 (2017), PSR-14 and PSR-15 (2018/2019), PSR-17 and PSR-18 (2018/2019) and PSR-20 (2022) — are newer than, or barely mentioned in, the books consulted.

  • PSR interface packages now use native type declarations (psr/log 3, psr/container 2, psr/http-message 2); implementations written for the 1.x interfaces need updated signatures.

  • Squiz Labs PHP_CodeSniffer moved to the PHPCSStandards organisation; version 4 is current.