Route Interceptors
Route interceptors are global pre-handler hooks that the core invokes in
RouteStrategy after middleware but before
autowiring and the controller call. They are convenient for access control (ACL),
policies, and business constraints at the named-route level.
The core provides only the RouteInterceptorInterface — exception types,
ACL rules, and responses (403, redirect) are defined by the application.
See also: Middleware, Routing, request lifecycle → RouteStrategy.
Interceptors and middleware
| Route interceptors | Middleware | |
|---|---|---|
| Contract | RouteInterceptorInterface::intercept() |
PSR-15 MiddlewareInterface::process() |
| Registration | config/routes.php → routes.interceptors |
lazyMiddleware() in route files |
| Scope | Global for all route matches | Global, group, or single route |
| Typical denial | Throw an exception (403, 401…) | Return ResponseInterface (redirect) |
| Example | ACL by route setName() |
AuthMiddleware → redirect to login |
In practice, both approaches are often combined: middleware on the /admin group
checks “is logged in” and redirects; an interceptor checks “is this named route allowed”
and throws an exception.
Registration
Add interceptor classes to config/routes.php:
<?php declare(strict_types=1);
use Concept\Components\Acl\Authorization\AclRouteAuthorization;
return [
'routes' => [
'interceptors' => [
AclRouteAuthorization::class,
],
'list' => [
dirname(__DIR__) . '/routes/web.php',
],
],
];
Order in the array is the invocation order. The class is resolved from the DI container
(constructor dependencies are injected automatically).
With telemetry.enabled, each interceptor is timed as
TelemetryEvent::HTTP_ROUTE_INTERCEPTOR_EXECUTED.
Example: Acl component
The Acl reference component ships
Concept\Components\Acl\Authorization\AclRouteAuthorization.
It resolves route rules from the database and checks permissions via AclInterface:
<?php declare(strict_types=1);
namespace Concept\Components\Acl\Authorization;
use Concept\Components\Acl\Authorization\Exceptions\AccessDeniedException;
use Concept\Components\Acl\Contracts\AclInterface;
use Concept\Components\Acl\Services\AclRouteRulesService;
use Concept\Core\Http\Contracts\RouteInterceptorInterface;
use Concept\Core\Services\Config\Contracts\ConfigInterface;
use League\Route\Route;
use Psr\Http\Message\ServerRequestInterface;
final class AclRouteAuthorization implements RouteInterceptorInterface
{
public function __construct(
private readonly AclInterface $acl,
private readonly AclRouteRulesService $routeRules,
private readonly ConfigInterface $config,
) {}
public function intercept(Route $route, ServerRequestInterface $request): void
{
$routeName = $route->getName();
if (!is_string($routeName) || $routeName === '') {
return;
}
$rule = $this->routeRules->resolve($routeName);
if ($rule === null) {
return;
}
$resource = $rule['resource'];
$privilege = $rule['privilege'] ?? null;
if ($this->acl->isAllowed($resource, $privilege)) {
return;
}
$redirectRouteName = $rule['redirect_route_name'] ?? null;
if (!is_string($redirectRouteName) || $redirectRouteName === '') {
$redirectRouteName = $this->config->getString('acl.redirect_route_name', 'admin.dashboard');
}
throw new AccessDeniedException(
sprintf('Access denied for route [%s]', $routeName),
$redirectRouteName,
);
}
}
Component-specific exception (carries redirect route name for middleware):
<?php declare(strict_types=1);
namespace Concept\Components\Acl\Authorization\Exceptions;
use RuntimeException;
final class AccessDeniedException extends RuntimeException
{
public function __construct(
string $message,
private readonly string $redirectRouteName,
) {
parent::__construct($message);
}
public function redirectRouteName(): string
{
return $this->redirectRouteName;
}
}
What happens to the exception
An interceptor does not return a response — it only throws an exception or completes without action.
An unhandled exception is caught by Whoops (ErrorHandlerServiceProvider):
- JSON / AJAX —
RequestFormat::expectsJson()→JsonResponseHandler - Debug web —
app.debug→ Pretty Page - Production web — fallback from
PathName::ERRORS_FALLBACK_VIEWS
For a predictable 403 or redirect response, wrap the handler in middleware (next section) rather than relying solely on Whoops.
Redirect and 403 via middleware
The Acl component provides
Concept\Components\Acl\Middlewares\HandleAccessDeniedMiddleware
to catch AccessDeniedException and return JSON 403 or a safe redirect:
<?php declare(strict_types=1);
namespace Concept\Components\Acl\Middlewares;
use Concept\Components\Acl\Authorization\Exceptions\AccessDeniedException;
use Concept\Core\Http\Contracts\ResponseFactoryInterface;
use Concept\Core\Http\Protocol\HttpStatusCode;
use Concept\Core\Http\Requests\RequestFormat;
use Concept\Core\Services\Session\Contracts\FlashBagInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class HandleAccessDeniedMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly RequestFormat $requestFormat,
private readonly FlashBagInterface $flashBag,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (AccessDeniedException $exception) {
if ($this->requestFormat->expectsJson($request)) {
return $this->responseFactory->jsonError(
$exception->getMessage(),
HttpStatusCode::FORBIDDEN
);
}
$this->flashBag->addError($exception->getMessage());
return $this->responseFactory->back(HttpStatusCode::FOUND, $exception->redirectRouteName());
}
}
}
Register middleware outside the interceptor chain (first in the route file):
use Concept\Components\Acl\Middlewares\HandleAccessDeniedMiddleware;
$router->lazyMiddleware(HandleAccessDeniedMiddleware::class);
$router->lazyMiddleware(HandleValidationExceptionMiddleware::class);
// ... other middleware
This keeps the interceptor “thin” (check + throw only), while response format (HTML redirect vs JSON 403) is centralized in middleware.
Alternative: middleware only (redirect)
If a simple “logged in / active user” check is enough without ACL by named route,
redirect can be done without an interceptor —
AuthMiddleware:
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
if (!$this->auth->check()) {
return new RedirectResponse('/admin/login', HttpStatusCode::FOUND);
}
return $handler->handle($request);
}
Or via ResponseFactoryInterface::redirectByName() — see
Middleware → authorization examples.
Combined scenario
flowchart TB
REQ["HTTP request"]
MW1["HandleAccessDeniedMiddleware"]
MW2["AuthMiddleware on /admin"]
MATCH["Router match"]
INT["AclRouteAuthorization"]
CTRL["Controller"]
REQ --> MW1 --> MW2 --> MATCH --> INT
INT -->|"can access"| CTRL
INT -->|"AccessDeniedException"| MW1
MW2 -->|"not logged in"| REDIR["RedirectResponse login"]
MW1 -->|"catch"| RESP["JSON 403 or redirect"]
AuthMiddlewareon the group — coarse session check, redirect without exception.AclRouteAuthorization— fine-grained permission check for named route,throw.HandleAccessDeniedMiddleware— catches the exception, responds to the client.
Tips
- Always name routes (
setName()) if ACL is tied to the name. - Do not duplicate login checks in interceptor and middleware — separate roles.
- For API — JSON via
HandleAccessDeniedMiddleware+expectsJson(). - Interceptor does not replace CSRF —
VerifyCsrfTokenMiddlewarestays in the stack.