Skip to content

Routing

Routing configuration is defined in application modules in the file "etc/routing.php", located under the module's root directory. This file must return a class that implements Amarant\Framework\Contract\Routing\RoutingConfiguratorInterface.

Important

In production mode, routing configuration is cached under "var/cache" in the application root directory. If changes are made, this cache has to be deleted for the new configuration to take effect.

Create a route


Vendor/ModuleName/etc/routing.php
<?php

declare(strict_types=1);

use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Contract\Application\ContextInterface;
use Amarant\Framework\Contract\Routing\RouteCollectionInterface;
use Amarant\Framework\Contract\Routing\RoutingConfiguratorInterface;
use Amarant\Framework\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
use Vendor\ModuleName\Controller\RouteController;

return new class () implements RoutingConfiguratorInterface {
    #[Override] public function configure(
        ContextInterface $context,
        RouteCollectionInterface $routeCollection
    ): void {
        $routeCollection
            ->addRoute(
                Route::create(
                    path: '/some-route',
                    controller: RouteController::class,
                    name: 'some_route',
                    area: AreaInterface::AREA_FRONTEND,
                    methods: [Request::METHOD_GET],
                )
            );
    }
};

Path

Specifies the route's path.

Controller

The controller class. By default, the "__invoke" method will be called to execute the controller.
If a different method should be called, specify it by adding "::" and the method name.

For example:

controller: RouteController::class . '::theMethodNameToExecute',

Name

A unique name for the route.

Important

Use "_" , "." and alphanumeric characters only as route names. This is very important for page layouts whose filenames are determined using the route name. The layout builder converts all "_" to "." and expects the layout files
to have a very specific naming convention.

Area

Frontend, backend or global. Specifying a route to be global makes it available in both frontend and backend area.

Methods

A list of HTTP methods the route should support.

Route parameters


To specify route parameters, add them in the path wrapped in curly brackets, like so:

path: '/some-route/{param-a}/{param-b}',

To add parameter requirements, use a regular expression. For example, to require the parameters to be digits only:

path: '/some-route/{param-a:\d+}/{param-b:\d+}',

To add an optional part of the path, wrap it in square brackets:

path: '/some-route/{param-a:\d+}/{param-b:\d+}[/{param-c}]',

Warning

The optional part can only be the last part of the path.

Parameter resolving and injection

All route parameters will be resolved after the route is matched and passed onto the controller's executing method. The controller method should expect these parameters to all be in camelcase.

This means route parameters "param-a", "param-b" will be passed onto the method as "paramA", "paramB", which also means the method's signature should look something like this:

public function __invoke(string $paramA, string $paramB)

Or something like this if the parameters are supposed to be only digits:

public function __invoke(int $paramA, int $paramB)

The method can also declare additional parameters to be injected, for example this will inject the current request context:

public function __invoke(int $paramA, int $paramB, RequestContextInterface $requestContext)

The order of additional parameters in the method signature does not matter.

Additional parameter requirements


Route parameters can be additionally validated using regex or closures specified in route's "paramRequirements".

Vendor/ModuleName/etc/routing.php
paramRequirements: [
    // closure
    'param-a' => fn ($param) => \is_string($param) && \str_contains($param, 'x'),
    // regex
    'param-b' => '/[^0-9]/',
]

Access and data scopes


To require the user to have specific access or data scopes to be able to access the route, add the following to route's "metadata":

Vendor/ModuleName/etc/routing.php
metadata: [
    // require the user to have these access scopes
    Route::METADATA_REQUIRED_ACCESS_SCOPES => ['access_scope_a', 'access_scope_b'],
    // require the user to have access to the default data scope
    Route::METADATA_REQUIRED_DATA_SCOPES => [
        \Amarant\Framework\Config\DefaultScopeContextProvider::createScopeValue(
            \Amarant\Framework\Contract\Config\ScopeInterface::DEFAULT_SCOPE,
            \Amarant\Framework\Contract\Config\ScopeInterface::DEFAULT_SCOPE
        )
    ]
]

Default status code


To set a default status code a response should have when this route is matched, add the following to route's "metadata":

Vendor/ModuleName/etc/routing.php
metadata: [
    Route::METADATA_DEFAULT_STATUS_CODE => 202,
]

Note

This status code is applied only to layout results returned from controllers.

Hidden routes


Sometimes you may want a route to be able to forward to, but don't want the users to be able to visit it.

To make the router skip a route, add the following to route's "metadata":

Vendor/ModuleName/etc/routing.php
metadata: [
    Route::METADATA_NO_ROUTER => true,
]

Grouping


Routes can be added to groups.

Vendor/ModuleName/etc/routing.php
<?php

declare(strict_types=1);

use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Contract\Application\ContextInterface;
use Amarant\Framework\Contract\Routing\RouteCollectionInterface;
use Amarant\Framework\Contract\Routing\RoutingConfiguratorInterface;
use Amarant\Framework\Routing\Route;
use Amarant\Framework\Routing\RouteGroup;
use Symfony\Component\HttpFoundation\Request;
use Vendor\ModuleName\Controller\RouteController;

return new class () implements RoutingConfiguratorInterface {
    #[Override] public function configure(
        ContextInterface $context,
        RouteCollectionInterface $routeCollection
    ): void {
        $myRouteGroup = $routeCollection->addGroup(
            new RouteGroup(
                'my-group', // (1)
                '/my-group-prefix' // (2)
            )
        );

        $myRouteGroup
            ->addRoute(
                Route::create(
                    path: '/some-route',
                    controller: RouteController::class,
                    name: 'some_route',
                    area: AreaInterface::AREA_FRONTEND,
                    methods: [Request::METHOD_GET],
                )
            );
    }
};
  1. Unique group name. If the group already exists, it won't be added to the collection. You can check and fetch the existing group using hasGroup and getGroup on the route collection.
  2. Optional path prefix. The paths of all routes in a group will be prefixed by this value.

Notice how we now add the route to the group and not to the collection. If we should add it to the collection, it would also become a part of a group, but the default one, which has no prefix.

Note

A route is matchable no matter if their path ends with a slash (/) or not. When adding a base route to a group with a prefix, you could declare the group's prefix ending without a slash, and then setting the route's path to just "/".


To add menu entry for a route:

Vendor/ModuleName/etc/routing.php
<?php

declare(strict_types=1);

use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Contract\Application\ContextInterface;
use Amarant\Framework\Contract\Routing\RouteCollectionInterface;
use Amarant\Framework\Contract\Routing\RoutingConfiguratorInterface;
use Amarant\Framework\Routing\Menu\RouteMenu;
use Amarant\Framework\Routing\Menu\RouteMenuGroup;
use Amarant\Framework\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
use Vendor\ModuleName\Controller\RouteController;

return new class () implements RoutingConfiguratorInterface {
    #[Override] public function configure(
        ContextInterface $context,
        RouteCollectionInterface $routeCollection
    ): void {
        $routeCollection->addMenuGroup(
            new RouteMenuGroup(
                groupId: 'my-group',        // (1)
                title: 'My group',          // (2)
                sortOrder: 10,              // (3)
                iconClass: 'bx bx-package'  // (4)
            )
        );

        $routeCollection
            ->addRoute(
                Route::create(
                    path: '/some-route',
                    controller: RouteController::class,
                    name: 'some_route',
                    area: AreaInterface::AREA_BACKEND,  // (5)
                    methods: [Request::METHOD_GET],
                )
                ->setMenu(
                    new RouteMenu(
                        groupId: 'my-group',    // (6)
                        title: 'My route',      // (7)
                        sortOrder: 10           // (8)
                    )
                )
            );
    }
};
  1. Unique menu group id. If a group exists, it will be overwritten. Check if a group exists using getMenuGroup first.
  2. The group's title, usually rendered as a dropdown menu title.
  3. Sort order used when rendering menu groups.
  4. Optional icon class, usually added to the group's title as an "<i>" element.
  5. Route menus, by default, are meant to be used in the backend (back office).
  6. The menu group id under which the menu entry will be rendered, usually as a link within a dropdown menu item.
  7. The title of the entry, usually the title of the link.
  8. Sort order used when rendering menus inside a menu group.

Note

The menu is rendered only in the backend (back office) theme Hadron. It's possible to use this functionality in the frontend, but you have to render it yourself and set the area parameter of such menu items to frontend.

Alias-only routes


Sometimes a route should be reachable only via an internal forward (see Forwarding) and not directly accessible by users. To exclude a route from the router while keeping it forward-able, add the following to route's metadata:

Vendor/ModuleName/etc/routing.php
metadata: [
    Route::METADATA_ALIAS_ONLY => true,
]

This is similar to METADATA_NO_ROUTER but the intent is different: METADATA_NO_ROUTER marks routes that should never be targeted (e.g. internal-only fallbacks), while METADATA_ALIAS_ONLY marks routes that are valid destinations for forwards but must not be directly visited.

Forwarding


A controller can internally forward the request to another route by returning a ForwardResult. The application resolves the target route and calls its controller within the same request, without issuing an HTTP redirect.

Vendor/ModuleName/Controller/SomeController.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Controller;

use Amarant\Framework\Routing\Controller\ForwardResult;

final class SomeController
{
    public function __invoke(): ForwardResult
    {
        return new ForwardResult(
            routeName: 'target_route_name',         // (1)
            routeParameters: ['paramA' => 'value']  // (2)
        );
    }
}
  1. The name of the route to forward to.
  2. Optional parameters passed to the target controller.

Note

Forwarding does not issue an HTTP redirect. The browser URL stays the same, and only one response is sent. The target route is resolved from the same area as the current request.

Controller argument providers


Beyond route path parameters, the framework automatically resolves additional arguments and injects them into controller methods. This is done by argument providers — classes implementing ControllerArgumentProviderInterface, tagged with DiEnum::CONTROLLER_ARGUMENT_PROVIDER.

Each provider implements supports(RequestContextInterface $requestContext): bool to declare when it applies, and provide(RequestContextInterface $requestContext): array to return a map of parameter name → value. Providers are checked in order; all that return true from supports() contribute their arguments.

JSON input (input)

JsonInputArgumentProvider applies when:

  • The request method is POST, PATCH, PUT, or DELETE.
  • The request body is valid JSON.
  • The route defines an input class (via ->input(MyRequestDto::class)) that implements JsonSerializableInterface.
  • The controller method has a parameter named input whose type matches that class.

When it applies, the provider deserializes the JSON body into the input class using SerializerInterface, then validates the result with Symfony Validator. A validation failure throws InputException. Denormalization groups are taken from the route's operation configuration when an operationId is set.

Controller
public function __invoke(CreateProductInput $input): mixed
{
    // $input is fully deserialized and validated
}
etc/routing.php
->input(CreateProductInput::class)

Form / multipart input (input)

RawInputArgumentProvider works identically to the JSON provider but applies when the request body is not valid JSON — i.e. application/x-www-form-urlencoded or multipart/form-data. It merges $request->request->all() and $request->files->all() into a single array and deserializes it into the input class.

The controller signature and route definition are the same as for JSON input. The two providers are mutually exclusive: JSON content triggers the JSON provider; everything else triggers the raw provider.

Resource model

ResourceModelArgumentProvider applies when the route's resourceId is a DataModelInterface subclass (i.e. the route is part of a resource definition). It injects the already-loaded resource item — fetched earlier in the request lifecycle — directly into any controller parameter whose type matches a DataModelInterface subclass.

Controller
public function __invoke(Product $product): mixed
{
    // $product is the loaded entity identified by the route's resource ID
}

No additional route configuration is needed; the provider reads the loaded model from the request context.

Adding a custom argument provider

Implement ControllerArgumentProviderInterface and tag it with DiEnum::CONTROLLER_ARGUMENT_PROVIDER:

Vendor/ModuleName/Configuration/Di.php
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;

$container
    ->bind(
        MyArgumentProvider::class,
        fn (ConfigurableContainerInterface $container) => $container->getBindBuilder()
            ->type(MyArgumentProvider::class)
            ->build()
    )
    ->tag([MyArgumentProvider::class], [DiEnum::CONTROLLER_ARGUMENT_PROVIDER]);

The provide() method returns a map of parameter names to values. Names must match the controller method's parameter names exactly. Return only the parameters your provider is responsible for; other providers contribute the rest.


Request lifecycle events


The HTTP application dispatches a series of events during request processing. These can be subscribed to for things like authentication, rate limiting, response manipulation, or custom error handling. All events are identified by constants on Amarant\Framework\Enum\EventEnum.

EventEnum::APP_REQUESTRequestEvent

Fired before route matching begins. Subscribers can short-circuit the entire request by setting a response, or inject a route and parameters directly to skip the router.

Method Description
getRequestContext() The current request context.
setResponse(ResponseInterface) Set a response — skips routing and controller entirely.
setRoute(Route) Provide a route — skips the router.
setRouteParameters(array) Set route parameters alongside an injected route.

EventEnum::APP_REQUEST_NO_ROUTENoRouteRequestEvent

Fired when the router finds no matching route. Subscribers can supply a fallback route or a response. If neither is provided, a RoutingException is thrown.

Method Description
getRequestContext() The current request context.
setRoute(Route) Provide a fallback route to use instead.
setRouteParameters(array) Set parameters for the fallback route.
setResponse(ResponseInterface) Alternatively, respond directly.

EventEnum::APP_REQUEST_ROUTERouteRequestEvent

Fired after a route has been matched, before the controller is resolved. Subscribers can swap the route, add or remove route parameters, or respond directly.

Method Description
getRoute() The matched route.
setRoute(Route) Replace the matched route.
getRouteParams() / setRouteParams(array) Read or replace route parameters.
addRouteParam(string, mixed) Add a single parameter.
removeRouteParam(string) Remove a single parameter.
setResponse(ResponseInterface) Short-circuit before the controller runs.

EventEnum::APP_REQUEST_RESPONSEResponseRequestEvent

Fired after the controller has run and produced a response. Subscribers can inspect or replace the response.

Method Description
getRoute() The matched route.
getResponse() The response produced by the controller.
setResponse(ResponseInterface) Replace the response.

EventEnum::APP_REQUEST_EXCEPTIONExceptionRequestEvent

Fired when an unhandled exception occurs anywhere during the request. The exception has already been logged. Subscribers can inspect the exception and replace the error response. The exception is always re-thrown in debug mode regardless of this event.

Method Description
getException() The thrown exception.
getRoute() The matched route at the time of the exception, if any.
getResponse() The current error response.
setResponse(ResponseInterface) Replace the error response.

EventEnum::APP_REQUEST_RESPONSE_FINALIZED_BEFOREResponseFinalizedBeforeEvent

Fired just before the framework converts the internal response object to a Symfony Response. Subscribers can still replace the response at this point.

EventEnum::APP_REQUEST_RESPONSE_FINALIZEDResponseFinalizedRequestEvent

Fired after the Symfony Response object has been assembled, but before it is prepared for sending.

EventEnum::APP_REQUEST_RESPONSE_SEND_BEFOREResponseSendBeforeRequestEvent

Fired as the last step before the response is returned to the web server. Use for final inspection or logging; the response can no longer be replaced.

Request context


Amarant\Framework\Contract\Application\Request\RequestContextInterface is the central object available throughout a request. It carries the Symfony Request, the matched Route, the resolved identity, and a free-form metadata bag used to communicate between event subscribers and other request-scoped services.

Injecting the request context

In a constructor, always inject RequestContextContainerInterface and call currentOrNull() when you need the context. The container is a shared singleton; the request context itself is request-scoped and will not exist at construction time.

use Amarant\Framework\Contract\Application\Request\RequestContextContainerInterface;

final class SomeService
{
    public function __construct(
        private readonly RequestContextContainerInterface $requestContextContainer
    ) {}

    public function doSomething(): void
    {
        $requestContext = $this->requestContextContainer->currentOrNull();
        if ($requestContext === null) {
            return;
        }
        // use $requestContext
    }
}

In a controller method, RequestContextInterface can be declared directly as a parameter, and it will be injected automatically alongside any route parameters (see Parameter resolving and injection).

use Amarant\Framework\Contract\Application\Request\RequestContextInterface;

final class SomeController
{
    public function __invoke(RequestContextInterface $requestContext): mixed
    {
        // use $requestContext directly
    }
}

Commonly used methods

Method Description
getRequest(): Request The underlying Symfony Request object.
getContext(): ContextInterface Application context — area, mode, debug flag.
getRoute(): ?Route The currently matched route.
getRouteParameters(): array Route parameters resolved for this request.
getIdentity(): ?IdentityInterface The authenticated identity, or null for guests.
setIdentity(?IdentityInterface) Set or clear the identity.
getRequestParameter(string, mixed) Read a query/body parameter by name.
getAllRequestParameters(): array All merged request parameters.
getRequestId(): string The unique ID assigned to this request.
isApi(): bool Whether this is an API request.
getException(): ?Throwable The exception that occurred, if any.

Metadata

The metadata bag (getMeta / setMeta / appendMeta) is the standard way for event subscribers and services to pass data to later stages of the request without coupling directly to each other.

appendMeta(string $key, mixed $value) appends a value to an array stored under $key.
appendMetaUnique(string $key, int|string $value) does the same but skips duplicates.

The following constants on RequestContextInterface define the well-known metadata keys used by the framework.

Append a Symfony\Component\HttpFoundation\Cookie instance to queue it for the response.

Always create cookies using Amarant\Framework\Contract\Factory\Application\Request\CookieFactoryInterface rather than instantiating Cookie directly. The factory automatically sets the secure flag based on the current request scheme and provides consistent defaults across the application.

use Amarant\Framework\Contract\Factory\Application\Request\CookieFactoryInterface;
use Amarant\Framework\Contract\Application\Request\RequestContextContainerInterface;
use Amarant\Framework\Contract\Application\Request\RequestContextInterface;

final class SomeService
{
    public function __construct(
        private readonly RequestContextContainerInterface $requestContextContainer,
        private readonly CookieFactoryInterface $cookieFactory,
    ) {}

    public function setCookie(): void
    {
        $requestContext = $this->requestContextContainer->currentOrNull();

        $requestContext->appendMeta(
            RequestContextInterface::META_RESPONSE_SET_COOKIES,
            $this->cookieFactory->create(
                name: 'my_cookie',
                value: 'some-value',
                path: '/',
                httpOnly: true,
                sameSite: Cookie::SAMESITE_STRICT,
                ttl: 3600
            )
        );
    }
}

Append either a Cookie instance created via CookieFactoryInterface (to control path/flags precisely) or a plain cookie name string to queue a cookie for removal.

// by name only
$requestContext->appendMeta(
    RequestContextInterface::META_RESPONSE_REMOVE_COOKIES,
    'my_cookie'
);

// with path/flag control — use the factory
$requestContext->appendMeta(
    RequestContextInterface::META_RESPONSE_REMOVE_COOKIES,
    $this->cookieFactory->create(name: 'my_cookie', value: null, path: '/')
);

Both queues are consumed by Amarant\Framework\EventSubscriber\Request\CookieEventSubscriber, which runs on EventEnum::APP_REQUEST_RESPONSE_FINALIZED at priority -450. Cookies are applied to the final Symfony Response object just before it is sent, after most other response processing has completed.

META_RESPONSE_TTL — page cache TTL (seconds)

Set an integer TTL on the current response. Used by CacheEventSubscriber to store the response in a reverse-proxy cache when caching is enabled.

$requestContext->setMeta(RequestContextInterface::META_RESPONSE_TTL, 300); // cache for 5 minutes

META_RESPONSE_LAYOUT_RENDERED_PAGE_TTL — layout page TTL (seconds)

Same purpose as META_RESPONSE_TTL but set by the layout renderer for full-page layout responses. Takes precedence over META_RESPONSE_TTL when both are present.

META_RESPONSE_TAGS — cache tags

Set an array of string tags on the response. Used by CacheEventSubscriber to tag the cached entry so it can be invalidated by tag.

$requestContext->setMeta(RequestContextInterface::META_RESPONSE_TAGS, ['product_42', 'category_7']);

META_CACHED — response served from cache

Set to true by CacheEventSubscriber when the response was served directly from cache. Other subscribers can check this flag to skip work that is redundant for cached responses.

META_REQUEST_ID — request identifier

The unique UUID assigned to this request. Also, accessible via getRequestId().

Request context container


Amarant\Framework\Contract\Application\Request\RequestContextContainerInterface is a stack that holds request contexts. The HTTP application pushes a context at the start of each request and the container makes it available to any service that injects RequestContextInterface directly.

In tests and any code that dispatches a request manually, you must push a context yourself before calling services that depend on it:

$requestContext = $this->makeClient()->createRequestContext('/');
$container->get(RequestContextContainerInterface::class)->push($requestContext);
Method Description
push(RequestContextInterface) Push a context onto the stack.
current(): RequestContextInterface Get the current (top) context. Throws if empty.
currentOrNull(): ?RequestContextInterface Same, returns null instead of throwing.
pop(): ?RequestContextInterface Remove and return the top context.
isEmpty(): bool Whether there is no active context.
clear(): void Clear the entire stack.