Middleware API Reference#
Complete reference for creating and using middleware in Webrick.
Table of Contents#
Middleware Interface#
Middleware must be callable with signature:
function (Request $request, Closure $next): Response
Framework middleware may also throw exceptions instead of returning an error response directly. When that happens, RouterKernel catches the exception at the final error boundary and renders the HTTP response there.
Creating Middleware#
Closure Middleware#
$middleware = function (Request $r, Closure $next): Response {
// Before handler
$r = $r->withAttribute('start_time', microtime(true));
// Call next middleware/handler
$response = $next($r);
// After handler
$duration = microtime(true) - $r->getAttribute('start_time');
return $response->withHeader('X-Response-Time', $duration . 'ms');
};
Class Middleware#
final class LoggingMiddleware
{
public function __construct(
private LoggerInterface $logger
) {}
public function __invoke(Request $r, Closure $next): Response
{
$this->logger->info('Request', [
'method' => $r->getMethod(),
'path' => $r->getPath()
]);
$response = $next($r);
$this->logger->info('Response', [
'status' => $response->getStatusCode()
]);
return $response;
}
}
Invokable Class#
final class AuthMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$token = $r->getHeaderLine('Authorization');
if (!$this->isValidToken($token)) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$userId = $this->extractUserId($token);
$r = $r->withAttribute('auth.user_id', $userId);
return $next($r);
}
private function isValidToken(string $token): bool { /* ... */ }
private function extractUserId(string $token): int { /* ... */ }
}
If your middleware is part of normal application control flow, returning Response directly is still valid. If it represents a framework-style rejection path, prefer throwing a typed HTTP exception and let the kernel render the final response:
use Infocyph\Webrick\Exceptions\HttpException;
final class AuthMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
if (!$this->isValidToken($r->getHeaderLine('Authorization'))) {
throw HttpException::forbidden('Unauthorized');
}
return $next($r);
}
}
Registering Middleware#
Pre-Global (Before Routing)#
$kernel = RouterKernel::bootWithRegistrar(
log: $logger,
matcher: $matcher,
register: $register,
preGlobal: [
new GatewayHardeningMiddleware(/* ... */),
new TelemetryMiddleware(/* ... */),
new ThrottleMiddleware(/* ... */),
],
);
Use for:
Security checks (IP filtering, host validation)
Request logging
Rate limiting
Early request rejection
Telemetry can also be configured as a value object when you want one reusable profile:
use Infocyph\Webrick\Middleware\TelemetryMiddleware;
use Infocyph\Webrick\Support\TelemetryOptions;
$telemetry = TelemetryMiddleware::fromOptions(new TelemetryOptions(
requestIdHeader: 'Request-Id',
emitTraceparentHeader: true,
));
Post-Global (After Routing, Before Handler)#
$kernel = RouterKernel::bootWithRegistrar(
log: $logger,
matcher: $matcher,
register: $register,
postGlobal: [
new CorsAndPoliciesMiddleware(/* ... */),
new NegotiationMiddleware(/* ... */),
new ResponseCacheMiddleware(/* ... */),
],
);
Use for:
CORS headers
Content negotiation
Response caching
Compression
Per-Route#
Route::get('/admin', [AdminController::class, 'index'], [
'middleware' => ['auth', 'admin']
]);
Middleware Order#
Execution flow:
Request
↓
Pre-Global Middleware (in order)
↓
Routing
↓
Post-Global Middleware (in order)
↓
Per-Route Middleware (in order)
↓
Handler
↓
Per-Route Middleware (reverse order)
↓
Post-Global Middleware (reverse order)
↓
Pre-Global Middleware (reverse order)
↓
Response
Example:
$preGlobal = [A, B, C];
$postGlobal = [D, E];
$perRoute = [F, G];
// Execution order:
// A → B → C → [routing] → D → E → F → G → [handler] → G → F → E → D → C → B → A
Request Transformation#
Add Attributes#
$middleware = function (Request $r, Closure $next): Response {
$r = $r->withAttribute('user_id', 42);
$r = $r->withAttribute('roles', ['admin', 'editor']);
return $next($r);
};
Modify Headers#
$middleware = function (Request $r, Closure $next): Response {
// Normalize header
$auth = $r->getHeaderLine('Authorization');
if (str_starts_with($auth, 'bearer ')) {
$auth = 'Bearer ' . substr($auth, 7);
$r = $r->withHeader('Authorization', $auth);
}
return $next($r);
};
Parse Body#
$middleware = function (Request $r, Closure $next): Response {
if ($r->getHeaderLine('Content-Type') === 'application/x-msgpack') {
$body = $r->getContent();
$data = msgpack_unpack($body);
$r = $r->withParsedBody($data);
}
return $next($r);
};
Response Transformation#
Add Headers#
$middleware = function (Request $r, Closure $next): Response {
$response = $next($r);
return $response
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Frame-Options', 'DENY');
};
Modify Body#
$middleware = function (Request $r, Closure $next): Response {
$response = $next($r);
// Wrap JSON responses
if (str_contains($response->getHeaderLine('Content-Type'), 'application/json')) {
$body = json_decode((string) $response->getBody(), true);
$wrapped = [
'success' => true,
'data' => $body,
'meta' => ['timestamp' => time()]
];
return Response::json($wrapped, $response->getStatusCode());
}
return $response;
};
Compress Response#
$middleware = function (Request $r, Closure $next): Response {
$response = $next($r);
$acceptEncoding = $r->getHeaderLine('Accept-Encoding');
if (str_contains($acceptEncoding, 'gzip')) {
$body = (string) $response->getBody();
$compressed = gzencode($body, 6);
return $response
->withBody(stream_for($compressed))
->withHeader('Content-Encoding', 'gzip')
->withHeader('Content-Length', (string) strlen($compressed));
}
return $response;
};
Short-Circuit Responses#
Early Return#
final class MaintenanceModeMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
if ($this->isInMaintenanceMode()) {
return Response::json([
'error' => 'Service Unavailable',
'message' => 'We are currently performing maintenance'
], 503);
}
return $next($r);
}
}
Conditional Execution#
final class CacheMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
// Only cache GET/HEAD
if (!in_array($r->getMethod(), ['GET', 'HEAD'])) {
return $next($r);
}
$cacheKey = $this->getCacheKey($r);
$cached = $this->cache->get($cacheKey);
if ($cached) {
return $cached; // Short-circuit
}
$response = $next($r);
$this->cache->set($cacheKey, $response, 3600);
return $response;
}
}
Middleware Aliases#
Register Alias#
use Infocyph\Webrick\Router\Dispatch\MiddlewareAliases;
MiddlewareAliases::register('auth', fn() => new AuthMiddleware());
MiddlewareAliases::register('throttle', fn(...$params) => new ThrottleMiddleware(
max: (int)($params[0] ?? 60),
window: (int)($params[1] ?? 60),
pool: Cache::pool('throttle')
));
Use Alias#
Route::get('/protected', [SecretController::class, 'index'], [
'middleware' => ['auth', 'throttle:30,60']
]);
Common Patterns#
Timing Middleware#
final class TimingMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$start = microtime(true);
$response = $next($r);
$duration = (microtime(true) - $start) * 1000; // ms
return $response->withHeader('X-Response-Time', number_format($duration, 2) . 'ms');
}
}
Request ID Middleware#
final class RequestIdMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$requestId = $r->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(16));
$r = $r->withAttribute('request_id', $requestId);
$response = $next($r);
return $response->withHeader('X-Request-Id', $requestId);
}
}
Locale Middleware#
final class LocaleMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
$locale = $r->query('lang')
?? $r->cookie('locale')
?? $r->getAttribute('locale', 'en');
$r = $r->withAttribute('locale', $locale);
$response = $next($r);
// Set cookie for next request
if (!$r->cookie('locale')) {
$cookie = "locale={$locale}; Path=/; Max-Age=31536000; SameSite=Lax";
$response = $response->withAddedHeader('Set-Cookie', $cookie);
}
return $response;
}
}
CORS Middleware#
final class SimpleCorsMiddleware
{
public function __invoke(Request $r, Closure $next): Response
{
// Preflight request
if ($r->isOptions()) {
return Response::create('', 204, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
'Access-Control-Max-Age' => '3600'
]);
}
$response = $next($r);
return $response->withHeader('Access-Control-Allow-Origin', '*');
}
}
Best Practices#
✅ Do#
Keep middleware focused (single responsibility)
// ✅ Good: One concern final class AuthMiddleware { /* Only auth */ } // ❌ Bad: Multiple concerns final class AuthAndLoggingMiddleware { /* Auth + logging */ }
Use immutable modifications
$r = $r->withAttribute('key', 'value'); // ✅ Correct $r->setAttribute('key', 'value'); // ❌ Wrong (doesn't exist)
Always call $next unless short-circuiting
// ✅ Good return $next($r); // ❌ Bad (breaks chain) $next($r); // Returns nothing
Place order-sensitive middleware correctly
// ✅ Correct order $preGlobal = [ GatewayHardeningMiddleware::class, // First: security TelemetryMiddleware::class, // Then: logging ThrottleMiddleware::class, // Then: rate limiting ];
Use type hints
// ✅ Good public function __invoke(Request $r, Closure $next): Response // ❌ Bad public function __invoke($r, $next)
❌ Don’t#
Don’t mutate request/response directly
// ❌ Wrong $r->attributes['key'] = 'value'; // ✅ Correct $r = $r->withAttribute('key', 'value');
Don’t catch exceptions without re-throwing
// ❌ Bad (swallows errors) try { return $next($r); } catch (\Throwable $e) { return Response::json(['error' => 'Something went wrong'], 500); } // ✅ Good (logs but re-throws) try { return $next($r); } catch (\Throwable $e) { $this->logger->error('Middleware error', ['exception' => $e]); throw $e; }
Don’t forget to return response
// ❌ Bad public function __invoke(Request $r, Closure $next): Response { $next($r); // Missing return } // ✅ Good public function __invoke(Request $r, Closure $next): Response { return $next($r); }
Don’t perform heavy operations in every request
// ❌ Bad (slow) public function __invoke(Request $r, Closure $next): Response { $this->rebuildCache(); // Expensive! return $next($r); } // ✅ Good (conditional) public function __invoke(Request $r, Closure $next): Response { if ($r->getPath() === '/admin/rebuild-cache') { $this->rebuildCache(); } return $next($r); }
Testing Middleware#
Unit Test#
use PHPUnit\Framework\TestCase;
class AuthMiddlewareTest extends TestCase
{
public function testRejectsUnauthenticated(): void
{
$middleware = new AuthMiddleware();
$request = Request::fake(method: 'GET', uri: '/protected');
$next = fn() => Response::json(['secret' => 'data']);
$response = $middleware($request, $next);
$this->assertEquals(401, $response->getStatusCode());
}
public function testAllowsAuthenticated(): void
{
$middleware = new AuthMiddleware();
$request = Request::fake(
headers: ['Authorization' => 'Bearer valid-token'],
method: 'GET',
uri: '/protected',
);
$next = fn($r) => Response::json(['user_id' => $r->getAttribute('auth.user_id')]);
$response = $middleware($request, $next);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
$this->assertNotNull($data['user_id']);
}
}
Integration Test#
class MiddlewareStackTest extends TestCase
{
public function testMiddlewareStack(): void
{
$kernel = RouterKernel::bootWithRegistrar(
log: $logger,
matcher: $matcher,
register: $register,
preGlobal: [
new TimingMiddleware(),
new RequestIdMiddleware(),
],
postGlobal: [
new CorsMiddleware(),
],
);
$request = Request::fake(method: 'GET', uri: '/test');
$response = $kernel->handle($request);
$this->assertTrue($response->hasHeader('X-Response-Time'));
$this->assertTrue($response->hasHeader('X-Request-Id'));
$this->assertTrue($response->hasHeader('Access-Control-Allow-Origin'));
}
}
Summary#
Middleware provides:
✅ Request/response transformation
✅ Cross-cutting concerns (logging, caching, security)
✅ Composable, reusable logic
✅ Clean separation of concerns
Key concepts:
Callable with (Request, Closure) → Response
Three registration points (pre-global, post-global, per-route)
Immutable transformations (PSR-7)
Can short-circuit (early return)
Order matters (especially security middleware)
Golden rule: Each middleware should do one thing well.