-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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 |
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.
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;
}
}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;
};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->isEmpty(); // bool → no middleware registered?
$pipeline->count(); // int → number of middleware
$pipeline->getMiddleware(); // array → raw middleware stackThe 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)))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->has('web'); // bool
$registry->getGroupNames(); // ['web', 'api']
$registry->count(); // 2
$registry->remove('api'); // remove a groupresolveMany() distinguishes by type:
-
Strings without
\— treated as group names, resolved recursively -
Strings with
\— treated as class names (instantiated or passed through) -
MiddlewareInterfaceobjects — used directly -
Closure— used directly
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:
-
Calls
$auth->guard($guard)->check() -
If
false: invokes$onUnauthorized($context)or sets HTTP 401, returnsnull -
If
true: calls$next($context)
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:
-
If
$argumentResolveris set, calls it with$contextto get gate arguments -
Calls
$gate->denies($ability, ...$arguments) -
If denied: invokes
$onForbidden($context)or sets HTTP 403, returnsnull -
If allowed: calls
$next($context)
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:
-
Calls
$session->start() -
Delegates to
$next($context) -
In a
finallyblock, calls$session->save()
This ensures the session is always saved, even if an exception is thrown.
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:
-
Custom
$tokenExtractorclosure (if set) -
$_POST['_token'] -
$_SERVER['HTTP_X_CSRF_TOKEN'](header) -
$context['_token']
Behavior:
-
Safe methods (
GET,HEAD,OPTIONS) pass through without validation -
Excluded routes pass through
-
Validates token via
hash_equals()(timing-safe) -
On failure: invokes
$onMismatch($context)or sets HTTP 419 -
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),
});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:
-
Resolves the named limiter via
$limiter->resolve($name, $context) -
If unlimited or unresolvable — pass through
-
Key resolution: custom
$keyResolver→$limit->getKey()→$context['route'] -
If rate exceeded: HTTP 429 with
Retry-Afterheader -
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.
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']);
});$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'));
}| 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 |
| 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 |
| Method | Signature | Returns |
| --- | --- | --- |
| __construct | (AuthManager $auth, ?string $guard = null, ?Closure $onUnauthorized = null) | |
| handle | (array $context, Closure $next): mixed | 401 or proceed |
| Method | Signature | Returns |
| --- | --- | --- |
| __construct | (Gate $gate, string $ability, ?Closure $argumentResolver = null, ?Closure $onForbidden = null) | |
| handle | (array $context, Closure $next): mixed | 403 or proceed |
| Method | Signature | Returns |
| --- | --- | --- |
| __construct | (SessionInterface $session) | |
| handle | (array $context, Closure $next): mixed | Start/save session |
| getSession | (): SessionInterface | Access session |
| 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 |
| 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 |
| 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 |