Middleware
Wrapping requests: ordering, short-circuiting, per-route layers, and the ones that ship with the framework.
Middleware wraps request handling. Each layer receives the request, the request scope, and a $next to call — or not.
<?php
namespace App\Middleware;
use Closure;
use PhpOrbit\Container\RequestScope;
use PhpOrbit\Http\Response;
use PhpOrbit\Http\ServerRequest;
use PhpOrbit\Middleware\Middleware;
final class AddRequestId implements Middleware
{
public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
{
$id = bin2hex(random_bytes(8));
// Everything further in sees the modified request.
$response = $next($request->withAttribute('requestId', $id));
// On the way back out, the response can be modified too.
return $response->withHeader('X-Request-Id', $id);
}
}They are constructed at boot and reused for every request the worker serves, so they must hold no mutable state of their own. Anything request-specific comes from the RequestScope passed in.
Registering
<?php
Application::boot(static function (Blueprint $app) use ($root): void {
// Global: outermost first.
$app->middleware(
new LogRequests($logger),
new ServeStaticFiles($root . '/public', maxAgeSeconds: 3600),
new SessionMiddleware($sessions, lifetimeSeconds: 7200),
new CsrfMiddleware(),
new TransactionGuard(),
);
$app->loadRoutes($root . '/app/routes.php');
});orbit make:middleware Name writes the class above and prints the new Name(), entry to place in that list — where it goes is left to you, since order here is meaning rather than plumbing. See The orbit CLI.
<?php
// Per route
$routes->get('/reports', ReportController::class, 'reports', middleware: [
new RequireAuthentication(),
]);
// Across a set of routes
$routes->withMiddleware([new RequireAuthentication()], static function (RouteCollection $routes): void {
$routes->post('/articles', StoreController::class, 'articles.store');
});Order
Registration order, outermost first. Route middleware runs inside the global stack.
LogRequests ── sees everything, including 404s and failures
ServeStaticFiles ── may return a file and stop here
SessionMiddleware ── loads the session, publishes it
CsrfMiddleware ── needs the session that the layer above published
TransactionGuard
RequireAuthentication (route middleware)
your handlerCsrfMiddleware must come after SessionMiddleware, because the token lives in the session. LogRequests goes first so it observes the true status of everything, including responses produced by layers below it.
Short-circuiting
A layer that does not call $next stops the request:
<?php
final class BlockDuringMaintenance implements Middleware
{
public function __construct(private readonly bool $enabled)
{
}
public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
{
if ($this->enabled && !str_starts_with($request->uri->path, '/health')) {
return Response::text('Back shortly.', Status::ServiceUnavailable)
->withHeader('Retry-After', '120');
}
return $next($request);
}
}Seeing the matched route
Routing happens before the pipeline, so a layer can inspect the route it is wrapping — while still running for requests that matched nothing:
<?php
use PhpOrbit\Routing\Route;
public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
{
// No route on a 404, so ask before taking it.
$name = $scope->provided(Route::class) ? $scope->get(Route::class)->name : null;
return $next($request);
}This is exactly how CsrfMiddleware honours csrfExempt: true on a single route.
Cleanup that always runs
<?php
public function process(ServerRequest $request, RequestScope $scope, Closure $next): Response
{
$span = $this->tracer->start($request->uri->path);
try {
return $next($request);
} finally {
// Runs on the error path too, which is when traces matter most.
$span->finish();
}
}What ships with the framework
| Middleware | Purpose |
|---|---|
LogRequests | One structured line per request: method, path, status, duration. |
ServeStaticFiles | Serves files from a root, with ETag and 304 handling. |
SessionMiddleware | Loads the session, publishes it, writes it back, sets the cookie. |
CsrfMiddleware | Rejects state-changing requests without a valid token. |
TransactionGuard | Rolls back any transaction a handler left open. |
RequireAuthentication | Redirects guests to the sign-in page, remembering where they were going. |
ServeStaticFiles
<?php
// Mounted at the root
new ServeStaticFiles($root . '/public', maxAgeSeconds: 3600);
// Mounted under a prefix — this is how /docs is served from docs/
// without copying generated files into the document root.
new ServeStaticFiles($root . '/docs', maxAgeSeconds: 3600, prefix: '/docs');Path safety rests on two independent checks: Uri has already resolved dot segments and rejected encoded separators, and this resolves the candidate with realpath() and confirms the result is still inside the root — which also catches a symlink pointing out of it. Dotfiles are never served, so .env and .git are unreachable even if they end up under a root.
A prefix matches only on a segment boundary, so a /docs mount answers /docs and /docs/… but never /docsomething. Requesting a directory serves its index.html.
The media-type table is an allowlist, and a file whose extension is not on it falls through to the router. That is not fussiness: the earlier behaviour — unknown types sent as application/octet-stream — meant this middleware would hand out the source of any file under its root, public/index.php included. A server whose job is to return files verbatim must never be pointed at code, and refusing everything it was not told about is the only reliable way to guarantee that.
Add an entry to MIME_TYPES when you need a type that is missing. The failure mode is a 404 — discoverable, and safe.
Behind nginx or Apache this is effectively dead code: those serve real files before PHP is invoked, which is faster and why the front controllers test for a file first.
TransactionGuard
The connection is a process-lifetime singleton, so a handler that opens a transaction and throws would hand the next request a connection already inside someone else's transaction. Under FPM the process dies and the driver rolls back; under a worker nothing does. The guard closes that, and reports it rather than staying silent:
Transaction left open by POST /articles was rolled back. A handler opened a
transaction without committing it.Testing a middleware
<?php
public function test_it_adds_a_request_id(): void
{
$scope = (new Container())->enterRequest();
$response = (new AddRequestId())->process(
Requests::get('/'),
$scope,
static fn (ServerRequest $r): Response => Response::text($r->attribute('requestId') ?? ''),
);
self::assertNotSame('', $response->body);
self::assertSame($response->body, $response->headers->first('X-Request-Id'));
}