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