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.
Inhaltsverzeichnis
- 1. Was PSR-15 eigentlich regelt
- 2. RequestHandlerInterface und MiddlewareInterface im Detail
- 3. Das Onion-Modell: Wie eine Pipeline wirklich läuft
- 4. Eine minimale Pipeline ohne Framework bauen
- 5. Erste eigene Middleware: Logging
- 6. Auth-Middleware und kontrolliertes Abbrechen
- 7. CORS-Middleware und die Rolle der Reihenfolge
- 8. Fehlerbehandlung als eigene Middleware-Schicht
- 9. Grenzen des Eigenbaus und wann ein Framework sinnvoller ist
- 10. Zusammenfassung
- 11. FAQ
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.