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#

  1. Keep middleware focused (single responsibility)

    // ✅ Good: One concern
    final class AuthMiddleware { /* Only auth */ }
    
    // ❌ Bad: Multiple concerns
    final class AuthAndLoggingMiddleware { /* Auth + logging */ }
    
  2. Use immutable modifications

    $r = $r->withAttribute('key', 'value');  // ✅ Correct
    $r->setAttribute('key', 'value');        // ❌ Wrong (doesn't exist)
    
  3. Always call $next unless short-circuiting

    // ✅ Good
     return $next($r);
    
    // ❌ Bad (breaks chain)
    $next($r);  // Returns nothing
    
  4. Place order-sensitive middleware correctly

    // ✅ Correct order
    $preGlobal = [
        GatewayHardeningMiddleware::class,  // First: security
        TelemetryMiddleware::class,         // Then: logging
        ThrottleMiddleware::class,          // Then: rate limiting
    ];
    
  5. Use type hints

    // ✅ Good
    public function __invoke(Request $r, Closure $next): Response
    
    // ❌ Bad
    public function __invoke($r, $next)
    

Don’t#

  1. Don’t mutate request/response directly

    // ❌ Wrong
    $r->attributes['key'] = 'value';
    
    // ✅ Correct
    $r = $r->withAttribute('key', 'value');
    
  2. 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;
    }
    
  3. 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);
    }
    
  4. 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:

  1. Callable with (Request, Closure) → Response

  2. Three registration points (pre-global, post-global, per-route)

  3. Immutable transformations (PSR-7)

  4. Can short-circuit (early return)

  5. Order matters (especially security middleware)

Golden rule: Each middleware should do one thing well.