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-4 |
Autoloader |
namespace prefix to directory mapping |
PSR-6 |
Caching Interface |
|
PSR-7 |
HTTP Message Interfaces |
|
PSR-11 |
Container Interface |
|
PSR-12 |
Extended Coding Style |
layout rules; superseded by PER Coding Style |
PSR-13 |
Link Definition Interfaces |
|
PSR-14 |
Event Dispatcher |
|
PSR-15 |
HTTP Server Request Handlers |
|
PSR-16 |
Common Interface for Caching Libraries |
|
PSR-17 |
HTTP Factories |
|
PSR-18 |
HTTP Client |
|
PSR-20 |
Clock |
|
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.
Source: PSR-1, PSR-12 and PER Coding Style
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
Source: PSR-3: Logger Interface
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.
Source: PSR-7: HTTP message interfaces and PSR-17: HTTP Factories
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)
Source: PSR-11: Container interface
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));
Source: PSR-14: Event Dispatcher
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
Source: PSR-18: HTTP Client
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";
Source: PSR-13: Link definition interfaces and PSR-20: Clock
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):
phpcsreports violations of a standard (PSR12, or custom rule sets inphpcs.xml),phpcbffixes what it can. -
PHP-CS-Fixer (3.x): rewrites code according to rule sets such as
@PER-CSand@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'));
Source: PHP_CodeSniffer, PHP-CS-Fixer and Laravel Pint
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/log3,psr/container2,psr/http-message2); implementations written for the 1.x interfaces need updated signatures. -
Squiz Labs PHP_CodeSniffer moved to the PHPCSStandards organisation; version 4 is current.