Skip to content

Middleware

Ray Fung edited this page Feb 26, 2026 · 3 revisions

Middleware

Razy's middleware system uses an onion-layer pipeline to wrap request handling. Each middleware receives a context array and a $next closure, allowing it to inspect, modify, or short-circuit the request before and after it reaches the core handler.


Table of Contents


Concepts

Request
  ??
  ??
?Œâ??€?€?€?€?€?€?€?€?€?€?€?€?€??
?? Middleware A ?‚â??€??before logic
?? ?Œâ??€?€?€?€?€?€?€?€?€?�â?
?? ??Middle B ?‚â??€?€??before logic
?? ???Œâ??€?€?€?€?€???‚â?
?? ????Core ???‚â??€?€??core handler
?? ???”â??€?€?€?€?€???‚â?
?? ??         ?‚â??€?€??after logic
?? ?”â??€?€?€?€?€?€?€?€?€?˜â?
??             ?‚â??€??after logic
?”â??€?€?€?€?€?€?€?€?€?€?€?€?€??
  ??
  ??
Response

The pipeline builds from inside out ??the last middleware added wraps the outermost layer. Each middleware calls $next($context) to continue, or returns early to short-circuit.

Context Array

Every middleware receives $context with these keys:

Key Type Description
url_query string Original URL query string
route string Matched route path
module string Module code handling the route
closure_path string Controller closure path
arguments array Route arguments
method string HTTP method (GET, POST, *)
type string Route type: standard, lazy, script
is_shadow bool Whether this is a shadow route
contains mixed Optional extra data

MiddlewareInterface

namespace Razy\Contract;

use Closure;

interface MiddlewareInterface
{
    public function handle(array $context, Closure $next): mixed;
}

All middleware must implement this single method. Call $next($context) to proceed, or return a value to short-circuit.


Writing Middleware

Class-Based Middleware

use Razy\Contract\MiddlewareInterface;
use Closure;

class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly Logger $logger,
    ) {}

    public function handle(array $context, Closure $next): mixed
    {
        // Before
        $this->logger->info("Route: {$context['route']} [{$context['method']}]");
        $start = microtime(true);

        // Proceed
        $result = $next($context);

        // After
        $elapsed = round((microtime(true) - $start) * 1000, 2);
        $this->logger->info("Completed in {$elapsed}ms");

        return $result;
    }
}

Closure-Based Middleware

For simple cases, use a Closure directly:

$timing = function (array $context, Closure $next): mixed {
    $start = hrtime(true);
    $result = $next($context);
    $ms = (hrtime(true) - $start) / 1e6;
    header("X-Response-Time: {$ms}ms");
    return $result;
};

MiddlewarePipeline

The pipeline executes middleware in FIFO order around a core handler.

use Razy\Distributor\MiddlewarePipeline;

$pipeline = new MiddlewarePipeline();

// Add middleware (FIFO ??first added runs outermost)
$pipeline->pipe(new LoggingMiddleware($logger));
$pipeline->pipe(new AuthMiddleware($auth));

// Add multiple at once
$pipeline->pipeMany([
    new SessionMiddleware($session),
    new CsrfMiddleware($csrf),
]);

// Execute through the core handler
$result = $pipeline->process($context, function (array $ctx) {
    // Core handler ??the innermost layer
    return $controller->handle($ctx);
});

Pipeline Inspection

$pipeline->isEmpty();           // bool ??no middleware registered?
$pipeline->count();             // int ??number of middleware
$pipeline->getMiddleware();     // array ??raw middleware stack

How It Works

The pipeline builds the onion by iterating array_reverse($middleware), wrapping each layer around the next:

// Given: [A, B, C] with core handler H
// Execution order: A ??B ??C ??H ??C ??B ??A
//
// Internally built as:
// A(B(C(H)))

MiddlewareGroupRegistry

Group middleware into reusable named sets.

use Razy\Distributor\MiddlewareGroupRegistry;

$registry = new MiddlewareGroupRegistry();

// Define named groups
$registry->define('web', [
    new SessionMiddleware($session),
    new CsrfMiddleware($csrf),
]);

$registry->define('api', [
    new AuthMiddleware($auth),
    new RateLimitMiddleware($limiter, 'api'),
]);

// Extend an existing group
$registry->appendTo('web', [new LoggingMiddleware($logger)]);
$registry->prependTo('api', [new CorsMiddleware()]);

// Resolve a group
$middlewareList = $registry->resolve('web');
// [SessionMiddleware, CsrfMiddleware, LoggingMiddleware]

// Resolve mixed ??group names + inline middleware
$all = $registry->resolveMany([
    'web',                              // group name (string without \)
    new CustomMiddleware(),             // inline object
    fn($ctx, $next) => $next($ctx),    // inline closure
]);

Registry Inspection

$registry->has('web');           // bool
$registry->getGroupNames();     // ['web', 'api']
$registry->count();             // 2
$registry->remove('api');        // remove a group

Resolving Rules

resolveMany() distinguishes by type:

  • Strings without \ ??treated as group names, resolved recursively
  • Strings with \ ??treated as class names (instantiated or passed through)
  • MiddlewareInterface objects ??used directly
  • Closure ??used directly

Built-in Middleware

AuthMiddleware

Checks authentication via AuthManager. Returns 401 on failure.

use Razy\Auth\AuthMiddleware;

// Default guard
$middleware = new AuthMiddleware($authManager);

// Specific guard
$middleware = new AuthMiddleware($authManager, guard: 'api');

// Custom unauthorized handler
$middleware = new AuthMiddleware(
    auth: $authManager,
    guard: 'session',
    onUnauthorized: function (array $context) {
        header('Location: /login');
        return null;
    },
);

Behavior:

  1. Calls $auth->guard($guard)->check()
  2. If false: invokes $onUnauthorized($context) or sets HTTP 401, returns null
  3. If true: calls $next($context)

AuthorizeMiddleware

Checks authorization via Gate. Returns 403 on failure.

use Razy\Auth\AuthorizeMiddleware;

// Simple ability check
$middleware = new AuthorizeMiddleware(
    gate: $gate,
    ability: 'edit-post',
);

// With argument resolver (extract model from context)
$middleware = new AuthorizeMiddleware(
    gate: $gate,
    ability: 'update-order',
    argumentResolver: fn(array $ctx) => Order::find($db, $ctx['arguments'][0]),
);

// Custom forbidden handler
$middleware = new AuthorizeMiddleware(
    gate: $gate,
    ability: 'admin-panel',
    onForbidden: fn(array $ctx) => ['error' => 'Access denied', 'code' => 403],
);

Behavior:

  1. If $argumentResolver is set, calls it with $context to get gate arguments
  2. Calls $gate->denies($ability, ...$arguments)
  3. If denied: invokes $onForbidden($context) or sets HTTP 403, returns null
  4. If allowed: calls $next($context)

SessionMiddleware

Starts the session before request handling and saves it after.

use Razy\Session\SessionMiddleware;

$middleware = new SessionMiddleware($session);

// Access the session from the middleware
$session = $middleware->getSession();

Behavior:

  1. Calls $session->start()
  2. Delegates to $next($context)
  3. In a finally block, calls $session->save()

This ensures the session is always saved, even if an exception is thrown.


CsrfMiddleware

Validates CSRF tokens on state-changing requests.

use Razy\Csrf\CsrfMiddleware;
use Razy\Csrf\CsrfTokenManager;

$tokenManager = new CsrfTokenManager($session);

$middleware = new CsrfMiddleware(
    tokenManager: $tokenManager,
    excludedRoutes: ['/api/webhook', '/api/stripe'],
    rotateOnSuccess: true,
);

// Get the token for forms
$token = $middleware->getTokenManager()->token();

Token Extraction Order:

  1. Custom $tokenExtractor closure (if set)
  2. $_POST['_token']
  3. $_SERVER['HTTP_X_CSRF_TOKEN'] (header)
  4. $context['_token']

Behavior:

  1. Safe methods (GET, HEAD, OPTIONS) pass through without validation
  2. Excluded routes pass through
  3. Validates token via hash_equals() (timing-safe)
  4. On failure: invokes $onMismatch($context) or sets HTTP 419
  5. On success with $rotateOnSuccess: regenerates the token

In HTML forms:

<form method="POST" action="/submit">
    <input type="hidden" name="_token" value="<?= $token ?>">
    <!-- ... -->
</form>

In AJAX requests:

fetch('/api/data', {
    method: 'POST',
    headers: {
        'X-CSRF-TOKEN': csrfToken,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
});

RateLimitMiddleware

Enforces rate limits with HTTP headers.

use Razy\RateLimit\RateLimitMiddleware;

$middleware = new RateLimitMiddleware(
    limiter: $rateLimiter,
    name: 'api',
    keyResolver: fn(array $ctx) => $_SERVER['REMOTE_ADDR'],
    sendHeaders: true,
);

Behavior:

  1. Resolves the named limiter via $limiter->resolve($name, $context)
  2. If unlimited or unresolvable ??pass through
  3. Key resolution: custom $keyResolver ??$limit->getKey() ??$context['route']
  4. If rate exceeded: HTTP 429 with Retry-After header
  5. On allowed: increments counter, sends headers, proceeds

HTTP Headers Sent:

Header Description
X-RateLimit-Limit Maximum attempts allowed
X-RateLimit-Remaining Remaining attempts in window
X-RateLimit-Reset Unix timestamp when window resets
Retry-After Seconds until retry (only on 429)

See RateLimiter for configuring rate limiters and limits.


Combining Middleware

Full Stack Example

use Razy\Distributor\MiddlewarePipeline;
use Razy\Distributor\MiddlewareGroupRegistry;

// Define groups
$groups = new MiddlewareGroupRegistry();
$groups->define('web', [
    new SessionMiddleware($session),
    new CsrfMiddleware($csrfManager),
]);
$groups->define('auth', [
    new AuthMiddleware($authManager),
]);

// Build pipeline from groups + inline
$pipeline = new MiddlewarePipeline();
$pipeline->pipeMany($groups->resolve('web'));
$pipeline->pipeMany($groups->resolve('auth'));
$pipeline->pipe(new AuthorizeMiddleware($gate, 'admin-panel'));

// Execute
$result = $pipeline->process($context, function (array $ctx) use ($controller) {
    return $controller->dispatch($ctx['closure_path'], $ctx['arguments']);
});

Conditional Middleware

$pipeline = new MiddlewarePipeline();

// Always run session
$pipeline->pipe(new SessionMiddleware($session));

// Conditionally add CSRF for web routes
if ($context['type'] !== 'script') {
    $pipeline->pipe(new CsrfMiddleware($csrfManager));
}

// Conditionally add auth
if (str_starts_with($context['route'], '/admin')) {
    $pipeline->pipe(new AuthMiddleware($authManager, 'session'));
    $pipeline->pipe(new AuthorizeMiddleware($gate, 'admin-access'));
}

API Reference

MiddlewarePipeline

Method Signature Returns
pipe (MiddlewareInterface|Closure $middleware): static Add one middleware
pipeMany (array $middlewareList): static Add multiple middleware
process (array $context, Closure $coreHandler): mixed Execute pipeline
isEmpty (): bool No middleware?
count (): int Middleware count
getMiddleware (): array Raw middleware stack

MiddlewareGroupRegistry

Method Signature Returns
define (string $name, array $middleware): static Define/overwrite group
appendTo (string $name, array $middleware): static Append to group
prependTo (string $name, array $middleware): static Prepend to group
resolve (string $name): array Get group's middleware list
resolveMany (array $items): array Resolve mixed list
has (string $name): bool Group exists?
getGroupNames (): array All group names
count (): int Number of groups
remove (string $name): static Remove group

AuthMiddleware

Method Signature Returns
__construct (AuthManager $auth, ?string $guard = null, ?Closure $onUnauthorized = null)
handle (array $context, Closure $next): mixed 401 or proceed

AuthorizeMiddleware

Method Signature Returns
__construct (Gate $gate, string $ability, ?Closure $argumentResolver = null, ?Closure $onForbidden = null)
handle (array $context, Closure $next): mixed 403 or proceed

SessionMiddleware

Method Signature Returns
__construct (SessionInterface $session)
handle (array $context, Closure $next): mixed Start/save session
getSession (): SessionInterface Access session

CsrfMiddleware

Method Signature Returns
__construct (CsrfTokenManager $tokenManager, ?Closure $onMismatch = null, array $excludedRoutes = [], bool $rotateOnSuccess = false, ?Closure $tokenExtractor = null)
handle (array $context, Closure $next): mixed 419 or proceed
getTokenManager (): CsrfTokenManager Access token manager

CsrfTokenManager

Method Signature Returns
__construct (SessionInterface $session)
token (): string Get/generate 64-char hex token
validate (string $submittedToken): bool Timing-safe comparison
regenerate (): string Replace and return new token
hasToken (): bool Token exists in session?
clearToken (): void Remove token from session

RateLimitMiddleware

Method Signature Returns
__construct (RateLimiter $limiter, string $name, ?Closure $keyResolver = null, ?Closure $onLimitExceeded = null, bool $sendHeaders = true)
handle (array $context, Closure $next): mixed 429 or proceed
getRateLimiter (): RateLimiter Access rate limiter
getName (): string Limiter name

??Previous: Routing Session

Clone this wiki locally