Route interceptors

Route interceptors — глобальні pre-handler хуки, які ядро викликає в RouteStrategy після middleware, але до autowire та виклику контролера. Вони зручні для перевірки прав доступу (ACL), політик і бізнес-обмежень на рівні імені маршруту.

Ядро надає лише інтерфейс RouteInterceptorInterface — типи винятків, правила ACL і відповіді (403, redirect) визначає застосунок.

Див. також: Middleware, Маршрутизація, життєвий цикл → RouteStrategy.

Interceptors і middleware

Route interceptors Middleware
Контракт RouteInterceptorInterface::intercept() PSR-15 MiddlewareInterface::process()
Реєстрація config/routes.phproutes.interceptors lazyMiddleware() у файлах маршрутів
Область Глобально для всіх збігів маршруту Глобально, група або один маршрут
Типова відмова Кинути виняток (403, 401…) Повернути ResponseInterface (redirect)
Приклад ACL за setName() маршруту AuthMiddleware → redirect на login

На практиці часто поєднують обидва підходи: middleware на групі /admin перевіряє «чи залогінений» і робить redirect; interceptor перевіряє «чи дозволено цей named route» і кидає виняток.

Реєстрація

Додайте класи interceptors у config/routes.php (компонент Acl надає готовий interceptor):

use Concept\Components\Acl\Authorization\AclRouteAuthorization;

return [
    'routes' => [
        'interceptors' => [
            AclRouteAuthorization::class,
        ],
        'list' => [
            dirname(__DIR__) . '/routes/web.php',
        ],
    ],
];

Порядок у масиві — порядок виклику. Клас резолвиться з DI-контейнера (залежності конструктора підставляються автоматично). За telemetry.enabled кожен interceptor фіксується як TelemetryEvent::HTTP_ROUTE_INTERCEPTOR_EXECUTED.

Приклад: компонент Acl

Interceptor отримує об’єкт League\Route\Route і PSR-7 запит. Компонент Acl (Concept\Components\Acl\Authorization\AclRouteAuthorization) перевіряє named route через правила з БД і кидає AccessDeniedException:

<?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,
        );
    }
}

Виняток компонента Acl (містить ім’я маршруту для redirect):

<?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;
    }
}

Що відбувається з винятком

Interceptor не повертає відповідь — лише кидає виняток або завершується без дій. Необроблений виняток перехоплює Whoops (ErrorHandlerServiceProvider):

  • JSON / AJAXRequestFormat::expectsJson()JsonResponseHandler
  • Debug webapp.debug → Pretty Page
  • Production web — fallback з PathName::ERRORS_FALLBACK_VIEWS

Для передбачуваної відповіді 403 або redirect краще обгорнути handler у middleware (наступний розділ), а не покладатися лише на Whoops.

Redirect і 403 через middleware

Компонент Acl надає HandleAccessDeniedMiddleware, який перехоплює AccessDeniedException (за аналогією з HandleValidationExceptionMiddleware) і повертає redirect або JSON:

<?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()
            );
        }
    }
}

Зареєструйте middleware зовні interceptor-ланцюжка (першим у файлі маршрутів):

use Concept\Components\Acl\Middlewares\HandleAccessDeniedMiddleware;

$router->lazyMiddleware(HandleAccessDeniedMiddleware::class);
$router->lazyMiddleware(HandleValidationExceptionMiddleware::class);
// ... інші middleware

Так interceptor лишається «тонким» (лише перевірка + throw), а формат відповіді (HTML redirect vs JSON 403) централізовано в middleware.

Альтернатива: лише middleware (redirect)

Якщо достатньо перевірки «залогінений / активний користувач» без ACL за named route, redirect можна зробити без interceptor — наприклад AuthMiddleware з компонента AuthAdmin:

public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler
): ResponseInterface {
    if (!$this->auth->check()) {
        return new RedirectResponse('/admin/login', HttpStatusCode::FOUND);
    }

    return $handler->handle($request);
}

Або через ResponseFactoryInterface::redirectByName() — див. Middleware → приклади авторизації.

Поєднаний сценарій

flowchart TB
    REQ["HTTP запит"]
    MW1["HandleAccessDeniedMiddleware"]
    MW2["AuthMiddleware на /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 або redirect"]
  1. AuthMiddleware (AuthAdmin) на групі — груба перевірка сесії, redirect без винятку.
  2. AclRouteAuthorization (Acl) — тонка перевірка права на named route, throw.
  3. HandleAccessDeniedMiddleware (Acl) — перехоплює виняток, відповідає клієнту.

Поради

  • Завжди іменуйте маршрути (setName()), якщо ACL прив’язаний до імені.
  • Не дублюйте перевірку login у interceptor і middleware — розділіть ролі.
  • Для API — JSON через HandleAccessDeniedMiddleware + expectsJson().
  • Interceptor не замінює CSRF — VerifyCsrfTokenMiddleware лишається в стеку.