PSR-15 Middleware selbst implementieren: Die Pipeline verstehen
AI generated
8.4
PHP · PSR-15 · Middleware
PSR-15 Middleware selbst implementieren
Die Pipeline hinter dem Framework verstehen

Fast jedes moderne PHP-Framework verspricht eine PSR-15-kompatible Middleware-Pipeline, doch was intern passiert, bleibt für viele Entwickler eine Blackbox. Wir bauen die Pipeline Schritt für Schritt selbst, ohne Framework, und zeigen am Beispiel von Logging-, Auth- und CORS-Middleware, wie das Onion-Modell technisch funktioniert.

16 Min. Lesezeit RequestHandlerInterface MiddlewareInterface Onion-Modell PSR-7

1. Was PSR-15 eigentlich regelt

PSR-15 ist verglichen mit anderen PHP-FIG-Standards erstaunlich klein. Der Standard definiert genau zwei Interfaces, RequestHandlerInterface und MiddlewareInterface, und legt fest, wie eine eingehende Anfrage durch eine Kette aus Middleware bis zu einem finalen Handler wandert. Er baut dabei auf PSR-7 auf, das die eigentlichen HTTP-Nachrichtenobjekte wie ServerRequestInterface und ResponseInterface bereitstellt, und ergänzt PSR-17 als Fabrik-Schnittstelle für neue Response-Objekte.

Wer mit Slim, Mezzio, Laminas oder einer eigenen PSR-15-Implementierung arbeitet, begegnet der Pipeline meist als fertiges Konzept, das einfach funktioniert. Genau diese Selbstverständlichkeit führt dazu, dass viele Entwickler nie verstehen, warum Middleware in einer bestimmten Reihenfolge registriert werden muss oder wie ein Framework eine Anfrage überhaupt an die richtige Stelle weiterreicht. Ein Eigenbau ohne jede Abhängigkeit macht diese Mechanik vollständig sichtbar.

2. RequestHandlerInterface und MiddlewareInterface im Detail

RequestHandlerInterface definiert eine einzige Methode, handle(), die eine ServerRequestInterface entgegennimmt und eine ResponseInterface zurückgibt. Ein Handler ist der Endpunkt der Pipeline, er erzeugt eine konkrete Antwort und reicht nichts mehr weiter. In einer typischen Anwendung ist das der Controller oder die Route, die am Ende tatsächlich Daten aus einer Datenbank lädt und eine Response zusammenbaut.

MiddlewareInterface definiert ebenfalls nur eine Methode, process(), die zusätzlich zur Anfrage einen RequestHandlerInterface entgegennimmt. Genau dieser zweite Parameter ist der entscheidende Unterschied zu einem einfachen Handler, denn eine Middleware kann selbst entscheiden, ob und wann sie diesen Handler aufruft. Sie kann die Anfrage vorher verändern, die Antwort danach anpassen oder die gesamte Kette abbrechen, indem sie den übergebenen Handler niemals aufruft.


<?php

declare(strict_types=1);

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface;
}

3. Das Onion-Modell: Wie eine Pipeline wirklich läuft

Die gängige Metapher für PSR-15-Pipelines ist die Zwiebel. Jede registrierte Middleware legt sich wie eine weitere Schicht um den finalen Handler im Kern. Die eingehende Anfrage wandert von außen nach innen durch jede Schicht, bis sie den Kern erreicht, die Antwort wandert anschließend denselben Weg wieder zurück nach außen, durch dieselben Schichten in umgekehrter Richtung.

Konkret bedeutet das: Code, den eine Middleware vor dem Aufruf von handler->handle() ausführt, läuft beim Hineingehen in die Zwiebel. Code nach diesem Aufruf läuft beim Herausgehen, nachdem der Kern und alle inneren Schichten bereits eine Antwort erzeugt haben. Dieses Muster unterscheidet sich fundamental von einer simplen linearen Abarbeitung und erklärt, warum die Registrierungsreihenfolge der Middleware direkten Einfluss auf das Verhalten der gesamten Anwendung hat.


Anfrage
  -> Fehlerbehandlung (außen)
     -> CORS
        -> Auth
           -> Logging
              -> finaler Handler (Kern)
           <- Logging (Dauer messen)
        <- Auth
     <- CORS (Header ergänzen)
  <- Fehlerbehandlung
Antwort

4. Eine minimale Pipeline ohne Framework bauen

Der Kern des Eigenbaus ist eine Klasse, die selbst RequestHandlerInterface implementiert und intern eine Warteschlange von Middleware verwaltet. Ruft man handle() auf, entnimmt die Pipeline die erste Middleware aus der Warteschlange und übergibt ihr sich selbst als Rest der Kette. Ist die Warteschlange leer, wird direkt der finale Handler aufgerufen, der die eigentliche Antwort erzeugt.

Der Trick liegt im clone-Aufruf: Statt einen mutablen Zeiger auf die aktuelle Position in der Warteschlange zu pflegen, erzeugt jeder Schritt eine neue, unveränderliche Kopie der Pipeline mit der um ein Element verkürzten Warteschlange. Das vermeidet Seiteneffekte, wenn dieselbe Pipeline-Instanz theoretisch mehrfach verwendet werden sollte, und ist konzeptionell nah an dem, was Bibliotheken wie relay/relay intern tun.


<?php

declare(strict_types=1);

namespace App\Http;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Minimaler Request-Handler, der eine Warteschlange von Middleware
 * abarbeitet und am Ende an den finalen Handler übergibt.
 */
final class MiddlewarePipeline implements RequestHandlerInterface
{
    /** @var array<int, MiddlewareInterface> */
    private array $queue;

    public function __construct(
        private readonly RequestHandlerInterface $finalHandler,
        MiddlewareInterface ...$middleware,
    ) {
        $this->queue = $middleware;
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($this->queue === []) {
            return $this->finalHandler->handle($request);
        }

        // Die nächste Middleware aus der Warteschlange entnehmen
        $current = array_shift($this->queue);

        // Sich selbst als "Rest der Pipeline" an die nächste
        // Middleware weiterreichen, das erzeugt das Onion-Modell
        $next = clone $this;
        $next->queue = $this->queue;

        return $current->process($request, $next);
    }
}

5. Erste eigene Middleware: Logging

Eine Logging-Middleware ist der einfachste Einstieg, weil sie weder die Anfrage noch die Antwort inhaltlich verändert, sondern nur beobachtet. Sie misst die Zeit vor dem Aufruf von handler->handle(), lässt die gesamte restliche Pipeline laufen und schreibt anschließend Methode, URI, Statuscode und Dauer in ein Log.

Damit die gemessene Dauer wirklich die gesamte restliche Verarbeitung umfasst, muss die Logging-Middleware möglichst weit außen in der Pipeline stehen, idealerweise direkt hinter der Fehlerbehandlung. Steht sie zu weit innen, etwa nach der Auth-Middleware, fehlt die Zeit für abgelehnte Anfragen komplett im Log, was Performance-Analysen später verfälscht.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

final class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $start = microtime(true);

        // Anfrage an die nächste Schicht der Pipeline weiterreichen
        $response = $handler->handle($request);

        $duration = round((microtime(true) - $start) * 1000, 2);
        $this->logger->info(sprintf(
            '%s %s -> %d (%sms)',
            $request->getMethod(),
            (string) $request->getUri(),
            $response->getStatusCode(),
            $duration,
        ));

        return $response;
    }
}

6. Auth-Middleware und kontrolliertes Abbrechen

Eine Auth-Middleware zeigt am deutlichsten, warum der Handler-Parameter in process() so wichtig ist. Sie liest den Authorization-Header, validiert das Token und entscheidet dann bewusst, ob sie den übergebenen Handler überhaupt aufruft. Ist das Token ungültig, erzeugt sie direkt über eine PSR-17-Response-Factory eine 401-Antwort und gibt diese zurück, ohne handler->handle() jemals aufzurufen.

Genau das ist Short-Circuiting: Alle Middleware, die in der Pipeline nach der Auth-Middleware registriert wurde, und der finale Handler selbst werden in diesem Fall niemals ausgeführt. Ist das Token gültig, reicht die Middleware die Anfrage mit withAttribute() angereichert weiter, sodass nachfolgender Code den authentifizierten Nutzer ohne erneute Validierung auslesen kann.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly TokenValidatorInterface $validator,
        private readonly ResponseFactoryInterface $responseFactory,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $token = $request->getHeaderLine('Authorization');

        if (!$this->validator->isValid($token)) {
            // Pipeline hier bewusst abbrechen, handler->handle()
            // wird absichtlich NICHT aufgerufen
            $response = $this->responseFactory->createResponse(401);
            $response->getBody()->write('Unauthorized');
            return $response;
        }

        return $handler->handle($request->withAttribute('user_token', $token));
    }
}

7. CORS-Middleware und die Rolle der Reihenfolge

Eine CORS-Middleware ruft handler->handle() zuerst auf und verändert erst danach die zurückgegebene Response, indem sie Access-Control-Header ergänzt. Sie arbeitet also fast ausschließlich auf dem Rückweg der Zwiebel, nicht auf dem Hinweg, und ist damit das Gegenstück zur Auth-Middleware, die vor allem auf dem Hinweg entscheidet.

Die Reihenfolge ist hier keine Nebensache: Steht CORS in der Pipeline vor Auth, also weiter außen, landen die CORS-Header auch auf einer 401-Antwort der Auth-Middleware, weil die Response auf dem Rückweg durch die CORS-Schicht läuft. Steht CORS dagegen hinter Auth, fehlen die Header bei jeder abgelehnten Anfrage, und Browser können die Fehlerantwort aufgrund der fehlenden CORS-Header im Frontend gar nicht erst auslesen.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

final class CorsMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $response = $handler->handle($request);

        return $response
            ->withHeader('Access-Control-Allow-Origin', 'https://mironsoft.de')
            ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    }
}

// Cors steht bewusst vor Auth: die CORS-Header sollen auch auf
// 401-Antworten landen, weil der Browser sie sonst nicht lesen kann
$pipeline = new MiddlewarePipeline(
    $finalHandler,
    new ErrorHandlingMiddleware($responseFactory, $logger),
    new CorsMiddleware(),
    new AuthMiddleware($validator, $responseFactory),
    new LoggingMiddleware($logger),
);

8. Fehlerbehandlung als eigene Middleware-Schicht

Statt try/catch-Blöcke über jeden einzelnen Handler und jede Middleware zu verteilen, bündelt eine eigene Fehlerbehandlungs-Middleware diese Logik an genau einer Stelle. Sie ruft handler->handle() in einem try-Block auf und fängt jede Throwable ab, die aus einer beliebigen inneren Schicht der Pipeline nach oben durchgereicht wird.

Damit das funktioniert, muss die Fehlerbehandlungs-Middleware als äußerste Schicht registriert werden, also als erste Middleware in der Konstruktor-Liste. Nur so kann sie Ausnahmen aus wirklich jeder weiter innen liegenden Komponente abfangen, inklusive Fehlern aus der Auth-Middleware oder aus Datenbankzugriffen im finalen Handler, und daraus eine saubere, einheitlich formatierte 500-Antwort erzeugen.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

final class ErrorHandlingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        try {
            return $handler->handle($request);
        } catch (\Throwable $exception) {
            $this->logger->error($exception->getMessage(), [
                'exception' => $exception,
            ]);

            $response = $this->responseFactory->createResponse(500);
            $response->getBody()->write('Internal Server Error');
            return $response;
        }
    }
}

9. Grenzen des Eigenbaus und wann ein Framework sinnvoller ist

Der größte Vorteil des Eigenbaus ist Testbarkeit: Jede Middleware lässt sich isoliert testen, indem man einen Mock von RequestHandlerInterface übergibt, dessen handle()-Methode eine feste Response zurückgibt. So prüft man ausschließlich, was die jeweilige Middleware selbst an Request oder Response verändert, ohne die gesamte Pipeline aufzubauen.

Für produktive Anwendungen fehlen einer minimalen Eigenbau-Pipeline aber typischerweise Dinge wie routenspezifische Middleware-Gruppen, automatische Auswahl der passenden PSR-17-Factory oder Optimierungen für sehr lange Ketten. Bibliotheken wie relay/relay bieten die reine Pipeline-Mechanik bereits fertig getestet an, größere Frameworks wie Mezzio oder Slim ergänzen Routing und Container-Integration. Der Eigenbau bleibt vor allem für das Verständnis und für sehr kleine, abhängigkeitsarme Microservices wertvoll.

Ansatz Abhängigkeit Kontrolle über die Pipeline Empfehlung
Eigene Pipeline wie in diesem Artikel Nur PSR-7, PSR-15, PSR-17 Vollständig, jede Zeile bekannt Lernprojekte, sehr kleine Microservices
relay/relay Eine schlanke Bibliothek Hoch, austauschbare Resolver Wenn nur die reine Pipeline gebraucht wird
Slim Framework Slim-Router und Slim-App Mittel, an Slim-Konventionen gebunden Kleine bis mittlere APIs ohne komplexes Routing
Mezzio Laminas-Komponenten Mittel bis hoch, viele Erweiterungspunkte Mittelgroße bis große Anwendungen
Klassisches try/catch ohne PSR-15 Keine Gering, Logik verteilt sich im Code Nicht empfohlen für wachsende Codebasen

Mironsoft

PHP-Modernisierung, Code-Qualität und Legacy-Refactoring

Gewachsener PHP-Code, der niemand mehr gern anfasst?

Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.

Legacy-Refactoring

Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.

Code-Qualität etablieren

PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.

Versions-Upgrade

PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.

10. Zusammenfassung

PSR-15 Middleware: Das Wichtigste auf einen Blick

Zwei Interfaces

PSR-15 besteht nur aus RequestHandlerInterface und MiddlewareInterface, alles andere ist Implementierungsdetail.

Onion-Modell

Code vor handler->handle() läuft beim Hineingehen, Code danach beim Herausgehen aus der Pipeline.

Short-Circuiting

Eine Middleware kann die Pipeline abbrechen, indem sie handler->handle() bewusst nicht aufruft.

Reihenfolge zählt

CORS- und Fehlerbehandlungs-Middleware gehören meist als äußerste Schicht vor die Auth-Middleware.

11. FAQ: PSR-15 Middleware: Das Wichtigste auf einen Blick

1Was ist der Unterschied zwischen PSR-15 und PSR-7?
PSR-7 definiert die HTTP-Nachrichtenobjekte selbst, also Request, Response und Stream. PSR-15 legt fest, wie diese Objekte durch eine Kette aus Middleware und einem finalen Handler laufen.
2Muss ich PSR-15 selbst implementieren oder reicht eine Bibliothek?
Für Produktionscode reicht in der Regel eine getestete Bibliothek wie relay/relay oder ein Framework. Der Eigenbau lohnt sich vor allem zum Verständnis der Mechanik dahinter.
3Was passiert, wenn eine Middleware handler->handle() nicht aufruft?
Die Pipeline bricht an dieser Stelle ab, alle nachfolgenden Middleware und der finale Handler werden nie ausgeführt. Genau das nutzt Auth-Middleware für kontrolliertes Short-Circuiting.
4Warum heißt es Onion-Modell?
Weil jede Middleware wie eine Zwiebelschicht um die nächste liegt. Die Anfrage wandert von außen nach innen, die Antwort wandert danach wieder von innen nach außen durch dieselben Schichten.
5Kann eine Middleware die Antwort nach handler->handle() noch verändern?
Ja, genau das macht die CORS-Middleware in diesem Artikel. Sie ruft zuerst handler->handle() auf und ergänzt danach Header an der zurückgegebenen Response.
6Wo sollte Fehlerbehandlungs-Middleware in der Pipeline stehen?
Als äußerste Schicht, also zuerst registriert, damit sie Ausnahmen aus allen weiter innen liegenden Middleware und dem finalen Handler abfangen kann.
7Ist PSR-15 an ein bestimmtes Framework gebunden?
Nein, PSR-15 ist framework-unabhängig. Es setzt lediglich PSR-7- und optional PSR-17-kompatible Objekte voraus, praktisch jedes moderne PHP-Framework unterstützt es.
8Wie teste ich eine einzelne Middleware isoliert?
Man übergibt einen Mock von RequestHandlerInterface, dessen handle()-Methode eine feste Response zurückgibt, und prüft dann, wie die Middleware Anfrage oder Antwort verändert.
9Warum nutzt die Pipeline-Klasse clone statt eines Index-Zeigers?
Beide Ansätze funktionieren technisch. Clone macht jede Pipeline-Instanz unveränderlich und vermeidet Seiteneffekte, falls dieselbe Pipeline mehrfach parallel verwendet wird.
10Lohnt sich der Eigenbau für ein reales Projekt?
Für sehr kleine Microservices ohne weitere Abhängigkeiten durchaus. Für wachsende Anwendungen ist eine getestete Bibliothek wie relay/relay oder ein Framework die sicherere Wahl.