The PSR Ecosystem: PHP-FIG Standards One by One

Understand PHP-FIG's role in the PHP ecosystem and work through each major PSR — PSR-1, 3, 4, 6, 7, 11, 12, 14, 15, 16, 17, 18, and 20 — covering the problem it solves, its interface, a usage example, and its relationship to real frameworks and packages.

Before PHP-FIG (the PHP Framework Interoperability Group) existed, every framework had its own logger contract, its own autoloading convention, its own cache abstraction — which meant a library built for one framework was often unusable in another without a compatibility shim, even when the underlying logic had nothing framework-specific about it. PHP-FIG's PSRs (PHP Standard Recommendations) fix this not by dictating a framework, but by standardizing narrow, individual interfaces and conventions that any framework or library can adopt independently — a PSR-3 logger from one vendor works with any code that only depends on Psr\Log\LoggerInterface, regardless of which framework either side was built for. This lesson goes through the standards one at a time: what problem each one solves, what its interface actually looks like, and where you'll encounter it in real code.

PSR-1 — Basic Coding Standard

Problem: without any shared baseline, two PHP codebases can differ so much in basic conventions (tag style, file structure, naming) that reading one after the other requires re-learning conventions from scratch. Interface/contract: PSR-1 isn't an interface but a small set of structural rules — files must use only <?php and short-echo <?php echo tags (never the deprecated short-open-tag <?), a file should either declare symbols (classes, functions, constants) or cause side effects (output, requires, etc.) but never both, class names use StudlyCaps, method names use camelCase, and class constants use UPPER_CASE_WITH_UNDERSCORES.

Example: a file named UserRepository.php declaring only class UserRepository { public function findById(int $id) {} const DEFAULT_LIMIT = 20; } and nothing else (no top-level echo, no side-effecting code) is PSR-1 compliant purely by following the naming and declare-vs-effect split. Real-world usage: virtually every modern PHP package assumes PSR-1 as a baseline without discussing it explicitly — it's the substrate PSR-12 builds detailed formatting rules on top of. Framework relationship: no framework enforces PSR-1 directly; static analysis tools (PHP_CodeSniffer, PHP-CS-Fixer) enforce it as part of enforcing PSR-12, which supersedes and subsumes it in practice.

PSR-3 — Logger Interface

Problem: every framework had its own logger with its own method names (log()->err(), ->writeError(), etc.), so a library wanting to log something had to either pick one framework's logger and lose portability, or invent its own tiny logging abstraction duplicated across every library. Interface: Psr\Log\LoggerInterface defines eight level-specific methods matching RFC 5424 severity levels — emergency(), alert(), critical(), error(), warning(), notice(), info(), debug() — each taking (string|\Stringable $message, array $context = []), plus a generic log($level, $message, array $context = []).

Example: class OrderProcessor { public function __construct(private LoggerInterface $logger) {} public function process(Order $o): void { $this->logger->info('Processing order {id}', ['id' => $o->id]); } } — note the {id} placeholder-style interpolation convention (PSR-3 recommends, doesn't strictly require, this context-array placeholder style). Real-world usage: Monolog is the dominant concrete implementation almost every framework wires in behind this interface. Framework relationship: Symfony, Laravel, and Slim all type-hint LoggerInterface internally and let you swap the concrete logger; a library that type-hints LoggerInterface instead of \Monolog\Logger directly works unmodified in any of them.

PSR-4 — Autoloading

Problem: PHP has no built-in mapping from a class name to a file path — before PSR-4 (and its predecessor PSR-0), every project either required every file manually or invented its own autoloading convention, making shared packages unable to agree on a common autoload mechanism. Interface/contract: PSR-4 specifies that a namespace prefix maps to a base directory, and everything in a fully-qualified class name after that prefix maps one-to-one onto a subdirectory path plus a ClassName.php file (case-sensitive, exact match).

Example: composer.json's {"autoload": {"psr-4": {"App\\": "src/"}}} means App\Http\Controllers\OrderController resolves to src/Http/Controllers/OrderController.php. Real-world usage: this is the single most universally adopted PSR — essentially every Composer package published today uses PSR-4 autoloading. Framework relationship: Composer's generated autoloader (vendor/autoload.php) is a direct, literal implementation of the PSR-4 spec — there's no framework-specific variation here at all.

PSR-6 and PSR-16 — Caching Interfaces

Problem: caching abstractions varied wildly across frameworks (get/set signatures, TTL handling, key validation rules), so a library needing to cache something couldn't do so without depending on one specific framework's cache layer. PSR-6 interface: a structured, object-oriented contract built around CacheItemPoolInterface (fetch/save against a pool) and CacheItemInterface (an individual item exposing get(), set(), isHit(), expiration). Example: $item = $pool->getItem('user.42'); if (!$item->isHit()) { $item->set($computedValue)->expiresAfter(3600); $pool->save($item); } return $item->get();.

PSR-16 interface: a flatter, simpler contract — Psr\SimpleCache\CacheInterface with direct get($key, $default = null), set($key, $value, $ttl = null), delete($key), plus *Multiple() batch variants. Example: $value = $cache->get('user.42'); if ($value === null) { $value = computeExpensively(); $cache->set('user.42', $value, 3600); }. Real-world usage: Symfony Cache implements both; many simpler libraries only bother implementing PSR-16 since it covers the common case with far less ceremony. Framework relationship: PSR-6 suits libraries needing fine control (deferred/batched saves); PSR-16 suits the common 'just get/set a value' case — they coexist by design rather than one superseding the other.

PSR-7 and PSR-17 — HTTP Messages and Factories (brief; covered in depth elsewhere)

Problem: HTTP request/response handling was framework-specific (Symfony's Request/Response objects differ entirely from Laravel's, which differ from a raw $_SERVER/$_POST approach), making HTTP-layer code (middleware, HTTP clients) non-portable across frameworks. PSR-7 interface (brief cross-reference — the HTTP-layer lessons in this course cover PSR-7 message construction and middleware pipelines in full depth): immutable value objects — RequestInterface, ResponseInterface, ServerRequestInterface, UriInterface, StreamInterface — where every mutator (withHeader(), withStatus(), withBody()) returns a new instance rather than mutating in place.

PSR-17 interface: RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, and UriFactoryInterface — each with a single creation method (e.g. ResponseFactoryInterface::createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface). It exists because PSR-7 standardized the message *interfaces* but deliberately left object *construction* unstandardized, so a library needing to build a new PSR-7 response (middleware generating an error response, for instance) had no portable way to do so without PSR-17's factory contracts. Real-world usage: Guzzle, Nyholm/psr7, and Laminas Diactoros all ship PSR-17 factories alongside their PSR-7 implementations. Framework relationship: any PSR-15 middleware needing to construct a fresh response type-hints ResponseFactoryInterface rather than a concrete class, staying implementation-agnostic.

PSR-11 — Container Interface

Problem: every DI container (Symfony's, Laravel's, PHP-DI, Pimple) had its own lookup API, so a library wanting to accept 'some container' as a dependency (rare, but real for framework-agnostic middleware dispatchers, for instance) had no common interface to type-hint against. Interface: Psr\Container\ContainerInterface — exactly two methods, get(string $id) (retrieve an entry or throw NotFoundExceptionInterface) and has(string $id): bool (check existence without triggering instantiation).

Example: function dispatch(ContainerInterface $c, string $handlerId) { $handler = $c->get($handlerId); return $handler->handle(); } works unmodified regardless of whether $c is Symfony's container, PHP-DI, or Laravel's. Real-world usage: middleware dispatchers and generic 'resolve this class name from wherever the app's container is' utilities are the main consumers. Framework relationship: deliberately read-only and minimal — PSR-11 does not standardize configuration/binding syntax, only lookup, which is why Symfony's and Laravel's containers remain very different to configure while both satisfying PSR-11 for lookup.

PSR-12 — Extended Coding Style

Problem: PSR-1 is deliberately minimal and leaves most formatting questions (indentation, brace placement, line length) unanswered, so teams and tools still disagreed on the details even after adopting it. Interface/contract: PSR-12 (superseding the older PSR-2) is a detailed formatting spec — 4-space indentation (never tabs), a 120-character soft line-length guideline, opening braces for classes/methods on their own line but for control structures on the same line, declare(strict_types=1) placement rules, and formatting conventions for modern syntax (union types, attributes, match).

Example: PHP-CS-Fixer's @PSR12 ruleset, run via php-cs-fixer fix --rules=@PSR12, will reformat a file's brace placement and indentation to comply automatically. Real-world usage: this is the de facto style essentially all modern PHP tooling (PHPStorm's default PHP formatter, PHP_CodeSniffer's PSR12 sniff set, PHP-CS-Fixer's @PSR12 rule set) targets by default. Framework relationship: not framework-specific — it's a tooling-level standard that keeps diffs and code review focused on logic rather than formatting bikeshedding across any codebase.

PSR-14 — Event Dispatcher

Problem: publish/subscribe (event) systems varied per framework (Symfony's EventDispatcher, Laravel's Event facade, WordPress's hooks — all incompatible APIs for the same underlying pattern), so a library wanting to fire an extensibility event had no portable way to do it. Interface: two small contracts — EventDispatcherInterface with a single dispatch(object $event): object method, and ListenerProviderInterface with getListenersForEvent(object $event): iterable, queried by the dispatcher to find which listeners apply to a given event's type.

Example: final class OrderPlaced { public function __construct(public readonly Order $order) {} } $dispatcher->dispatch(new OrderPlaced($order)); — any registered listener whose provider matches OrderPlaced runs, and since dispatch() returns the (possibly listener-mutated) event object, listeners can communicate back to the dispatching code by mutating public properties on the event. Real-world usage: Symfony's EventDispatcher component implements PSR-14. Framework relationship: notably, PSR-14 does not standardize a base Event class (any plain object works) or how listeners get registered to a provider — that registration mechanism is left fully to each implementation, keeping the interoperable surface deliberately narrow.

PSR-15 — HTTP Server Request Handlers / Middleware (brief; covered in depth elsewhere)

Problem: middleware pipelines (auth checks, logging, CORS handling wrapped around a request) were framework-specific, so middleware written for one PSR-7-based framework couldn't run in another. Interface (brief cross-reference — full middleware-pipeline construction is covered in this course's HTTP/routing lessons): RequestHandlerInterface (handle(ServerRequestInterface): ResponseInterface — produces a final response) and MiddlewareInterface (process(ServerRequestInterface, RequestHandlerInterface): ResponseInterface — can inspect/modify the request, delegate to the next handler, then inspect/modify the resulting response on the way back out).

Real-world usage: Slim, Mezzio (formerly Zend Expressive), and many microframeworks build their entire request lifecycle around PSR-15 middleware stacks. Framework relationship: this is the standard that makes a rate-limiting or CORS middleware package usable, completely unmodified, across any PSR-15-compliant framework — a level of portability middleware never had before this PSR.

PSR-18 — HTTP Client (brief; covered in depth elsewhere)

Problem: every HTTP client library (Guzzle, cURL wrappers, framework-bundled clients) had its own API for making an outbound request, so a library needing to call an external API internally had to hard-depend on one specific client package, forcing that dependency onto every consumer even if they'd rather use a different client. Interface (brief cross-reference — HTTP client usage patterns are covered in depth in this course's networking/API-integration lessons): ClientInterface with one method, sendRequest(RequestInterface $request): ResponseInterface, throwing ClientExceptionInterface on failure — deliberately reusing PSR-7's request/response objects rather than inventing new ones.

Real-world usage: Guzzle ships a PSR-18 adapter (GuzzleHttp\Psr18Client); Symfony HttpClient also implements it. Framework relationship: a library needing to call an external API can type-hint ClientInterface and RequestFactoryInterface (PSR-17) instead of hard-depending on Guzzle specifically, letting the consuming application supply whichever PSR-18 client it already uses.

PSR-20 — Clock

Problem: code that calls new \DateTime() or time() directly to get 'the current time' is difficult to unit test deterministically — a test asserting 'this token expires 1 hour from now' has to either sleep for real time or use fragile time-window assertions, because there was no standard way to inject 'what time is it' as a replaceable dependency. Interface: Psr\Clock\ClockInterface defines exactly one method, now(): \DateTimeImmutable, returning the current time as an immutable DateTime.

Example: final class TokenService { public function __construct(private ClockInterface $clock) {} public function issue(): Token { $expiresAt = $this->clock->now()->modify('+1 hour'); return new Token($expiresAt); } } — a test injects a FrozenClock implementation whose now() always returns a fixed, known \DateTimeImmutable, making the expiry assertion exact and deterministic instead of a fuzzy time-window check. Real-world usage: this is the newest and least universally adopted PSR on this list (published considerably after most of the others) — adoption is growing but many codebases still call new \DateTimeImmutable() directly rather than injecting a clock. Framework relationship: Symfony added a PSR-20-compatible Clock component; libraries that adopt it gain the testability benefit described above at the cost of one extra constructor dependency almost everywhere time is read.

Representative interface signatures across the PSRs covered

Every one of these is published as a tiny standalone Composer package under the psr/ vendor namespace (psr/log, psr/cache, psr/container, psr/event-dispatcher, psr/simple-cache, psr/http-factory, psr/clock) — you require the interface package and code against it, never a concrete implementation. PHP-FIG continues to evolve this catalogue; always check the current PHP-FIG site for the authoritative, up-to-date status of each PSR.

// PSR-3
interface LoggerInterface {
    public function error(string|\Stringable $message, array $context = []): void;
    public function log($level, string|\Stringable $message, array $context = []): void;
}

// PSR-6
interface CacheItemPoolInterface {
    public function getItem(string $key): CacheItemInterface;
    public function save(CacheItemInterface $item): bool;
}

// PSR-11
interface ContainerInterface {
    public function get(string $id);
    public function has(string $id): bool;
}

// PSR-14
interface EventDispatcherInterface {
    public function dispatch(object $event): object;
}

// PSR-16
interface CacheInterface {
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool;
}

// PSR-17 (one of several factories)
interface ResponseFactoryInterface {
    public function createResponse(int $code = 200, string $reasonPhrase = ''): \Psr\Http\Message\ResponseInterface;
}

// PSR-20
interface ClockInterface {
    public function now(): \DateTimeImmutable;
}

Code Examples

This class doesn't know or care whether it's running inside Symfony, Laravel, or a bare Slim app — every collaborator it depends on is a PSR interface (PSR-3 logging, PSR-16 caching, PSR-20 clock, PSR-14 events), so it can be unit-tested with four hand-built fakes and dropped into any framework whose container can satisfy those four interfaces. Notice the clock dependency specifically: without PSR-20, testing 'the token expires in 30 minutes' would require either a real 30-minute wait or a fragile time-window assertion; injecting a fake clock makes the expiry time exact and deterministic in a test.

private readonly ClockInterface $clock,

<?php

declare(strict_types=1);

use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface;
use Psr\Clock\ClockInterface;
use Psr\EventDispatcher\EventDispatcherInterface;

final class PasswordResetTokenIssued
{
    public function __construct(public readonly string $userId, public readonly \DateTimeImmutable $expiresAt) {}
}

final class PasswordResetService
{
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly CacheInterface $cache,
        private readonly ClockInterface $clock,
        private readonly EventDispatcherInterface $events,
    ) {}

    public function issueToken(string $userId): string
    {
        $token = bin2hex(random_bytes(16));
        $expiresAt = $this->clock->now()->modify('+30 minutes');

        $this->cache->set("reset_token:{$token}", $userId, 1800);
        $this->logger->info('Password reset token issued for user {id}', ['id' => $userId]);
        $this->events->dispatch(new PasswordResetTokenIssued($userId, $expiresAt));

        return $token;
    }
}

Important Rules

PSR-1 and PSR-12 are style/structure standards — PSR-12 supersedes PSR-2 and is what tooling actually enforces day to day.

PSR-4 is the autoloading standard Composer's own autoloader directly implements.

PSR-6 and PSR-16 both cover caching; pick PSR-16 for simple get/set needs, PSR-6 for fine-grained item control.

PSR-7, PSR-15, and PSR-18 form the HTTP layer (messages, middleware, client) and PSR-17 supplies the factories PSR-7 itself doesn't standardize — all covered in full depth in this course's HTTP-focused lessons.

PSR-11 standardizes container lookup only, deliberately not configuration — every framework's container remains free to configure however it wants.

PSR-14 standardizes event dispatch and listener lookup, but not how listeners get registered.

PSR-20 is the newest and least universally adopted standard here — it exists purely to make 'the current time' an injectable, testable dependency.

Why PSR compliance is a selling point for packages

A Composer package advertising 'PSR-3 compatible logger' or 'PSR-15 middleware' is making a concrete, verifiable promise: it will work with whatever concrete implementation of that interface the consuming application already has, with zero adapter code. This is why Packagist search results and package READMEs frequently lead with PSR compliance — it's shorthand for 'this will not force you to also adopt our framework of choice.'

What is the core purpose of PHP-FIG's PSRs in the PHP ecosystem? A. To define a single official PHP framework that all projects must use B. To standardize narrow, individual interfaces and conventions so packages from different authors and frameworks can interoperate without adapters C. To replace Composer as the package manager D. To set performance benchmarks PHP implementations must meet

B. To standardize narrow, individual interfaces and conventions so packages from different authors and frameworks can interoperate without adapters — PHP-FIG standardizes specific, narrow contracts (logging, caching, containers, HTTP messages, and so on) rather than dictating a framework, which is what lets independently-written packages interoperate across completely different frameworks.

Why does PSR-17 exist alongside PSR-7? A. PSR-17 replaces PSR-7 entirely B. PSR-7 standardizes HTTP message interfaces but deliberately leaves object construction unstandardized, so PSR-17 adds factory interfaces for creating new PSR-7 objects portably C. PSR-17 is only used for caching, unrelated to PSR-7 D. PSR-17 is required before PSR-7 can be installed via Composer

B. PSR-7 standardizes HTTP message interfaces but deliberately leaves object construction unstandardized, so PSR-17 adds factory interfaces for creating new PSR-7 objects portably — PSR-7 defines immutable request/response/stream/URI interfaces but not how to construct new instances of them; PSR-17's factory interfaces (RequestFactoryInterface, ResponseFactoryInterface, and so on) fill that specific gap so middleware can build new PSR-7 objects without depending on one concrete implementation.

What problem does PSR-20's ClockInterface solve that calling new \DateTimeImmutable() directly does not? A. It makes date formatting faster B. It allows the current time to be injected as a dependency, making time-dependent code deterministically testable with a fake clock C. It adds timezone support that DateTimeImmutable lacks D. It is required for PSR-7 compliance

B. It allows the current time to be injected as a dependency, making time-dependent code deterministically testable with a fake clock — Calling new \DateTimeImmutable() (or time()) directly hard-codes 'now' into the class, making tests of time-dependent behavior fragile; injecting a ClockInterface lets a test supply a frozen, known time instead, making assertions like 'expires in exactly 30 minutes' exact rather than a fuzzy window.

Pick any two PSRs from this lesson and write a short paragraph for each explaining what would break, concretely, if that PSR didn't exist and every framework kept its own incompatible interface for the same concern instead. Hint: Anchor the answer in a specific scenario — e.g. 'a rate-limiting middleware package could not be published for use across frameworks without PSR-15' or 'a logging library would need a separate adapter per framework without PSR-3.'

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.