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.phproutes.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 / AJAXRequestFormat::expectsJson()JsonResponseHandler
  • Debug webapp.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"]
  1. AuthMiddleware on the group — coarse session check, redirect without exception.
  2. AclRouteAuthorization — fine-grained permission check for named route, throw.
  3. 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 — VerifyCsrfTokenMiddleware stays in the stack.