-
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.
- Concepts
- MiddlewareInterface
- Writing Middleware
- MiddlewarePipeline
- MiddlewareGroupRegistry
- Built-in Middleware
- Combining Middleware
- API Reference
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 |