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
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 { /* ... */ }
}
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
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.