Skip to content

Routing

Routing maps a URL to a module and an action — the two names Quiote needs to find the code that answers a request. It builds on the Symfony Routing component, but wraps it so routes carry Quiote’s _module / _action metadata and support reverse generation (building a URL from a route name and parameters).

This page covers how to declare routes, how path placeholders reach your action, and how to customise the HTTP-verb-to-method mapping. You declare routes two ways, and they coexist in the same route collection:

  • a Routing subclass (programmatic), and
  • #[Route] attributes on action classes.

Routing is one stop in Quiote’s PSR-15 middleware pipeline. The pipeline is built once and reused; RoutingMiddleware is the stage that turns a URL into a target action:

  1. It matches the request path against the RouteCollection your routing factory role builds (your Routing subclass, plus any merged #[Route] attributes).
  2. From the matched route it reads the _module and _action defaults, and maps the HTTP verb to an action-method token (read, write, …) via HttpMethodMapper.
  3. It packages all of that into an ActionDescriptor and stores it on the request. Everything downstream — security, validation, dispatch — reads the action from that descriptor.

Where it sits in the pipeline:

… → ContentNegotiationMiddleware picks the output type → RoutingMiddleware resolves the action and builds the ActionDescriptorOutputTypeSyncMiddleware lets a route override the negotiated type → SecurityMiddleware checks access → ValidationMiddleware validates input (and promotes route placeholders into parameters) → DispatchMiddleware runs the action and renders the view.

See The request lifecycle for the full path.

The supported, general way to declare routes is a subclass of Quiote\Routing\Routing that builds a RouteCollection in build(). You point the routing factory role at this class (see Configuration).

<?php
namespace App\Routing;
use Quiote\Routing\AttributeRoutes;
use Quiote\Routing\Routing;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
final class AppRouting extends Routing
{
protected function build(): array
{
$routes = new RouteCollection();
$meta = [];
$routes->add('index', new Route('/', ['_module' => 'Default', '_action' => 'Index']));
$meta['index'] = ['gen_path' => '/', 'path' => '/', 'cut' => false];
$routes->add('post', new Route('/blog/{slug}', ['_module' => 'Blog', '_action' => 'Post']));
$meta['post'] = ['gen_path' => '/blog/{slug}', 'path' => '/blog/{slug}', 'cut' => false];
// Merge in routes declared via #[Route] attributes:
AttributeRoutes::mergeInto($routes, $meta);
return [$routes, $meta];
}
}

Two things every route needs:

  • A path — a Symfony route pattern, with {placeholders} for dynamic segments.
  • The _module and _action defaults — they tell the dispatcher which action to run. An optional _output_type default fixes the output type for the route.

Building Route objects and meta entries by hand is explicit but verbose. addRoute() does both in one call and supports a parent/child hierarchy:

protected function build(): array
{
$this->addRoute('/', ['name' => 'index', 'module' => 'Default', 'action' => 'Index']);
$this->addRoute('/blog/{slug}', ['name' => 'post', 'module' => 'Blog', 'action' => 'Post']);
return [$this->getRouteCollection(), $this->getMeta()];
}

The signature is:

public function addRoute(string $pattern, array $opts = [], ?string $parent = null): string
  • $pattern — the route pattern. If it does not start with / and a $parent is given, it is appended to the parent’s pattern (so children extend their parent’s path).
  • $opts — supports name (auto-generated if omitted), defaults, and the promoted keys module, action, locale, output_type.
  • $parent — the name of a parent route to nest under.

It returns the final route name.

For routes that live naturally next to the action they trigger, declare them with #[Route] on the action class. AttributeRoutes::mergeInto() (called from your build()) scans modules and merges these into the same collection.

<?php
namespace App\Modules\Blog\Actions;
use Quiote\Action\Action;
use Quiote\Request\WebRequest;
use Quiote\Routing\Attribute\Route;
#[Route('/blog/{slug}', name: 'post', methods: ['GET'])]
class PostAction extends Action
{
public function executeRead(WebRequest $rd)
{
return 'Success';
}
}

Note the attribute goes on the class, not a method. This is deliberate: a Quiote action is one class exposing several HTTP-verb methods (executeRead, executeWrite), the opposite of Symfony’s one-controller-method-per-route model. A class can carry more than one #[Route] (the attribute is repeatable).

module and action are not attribute fields — they are derived from the action’s location in the module tree, the same mapping Controller::createActionInstance() uses, in reverse.

The #[Route] parameters:

ParameterMeaning
pathSymfony route path, e.g. /products/{id}
nameRoute name; derived from module+action if omitted
methodsHTTP methods accepted; empty means all
requirementsPer-parameter regex requirements
defaultsExtra route defaults, merged under module/action
hostRoute host pattern
conditionSymfony ExpressionLanguage condition
priorityHigher matches first
outputTypeOutput type this route resolves to

They are not mutually exclusive — pick per route:

  • Use a Routing subclass for the app’s route map, especially routes with hierarchy, shared prefixes, or generated patterns.
  • Use #[Route] attributes for routes you want to read right next to the action, and for module-local routes that ship with a reusable module.

Path placeholders arrive as request parameters, read through WebRequest::getParameter() — the same call you use for any other input. Reading one requires a declared validator, as shown below:

public function executeRead(WebRequest $rd)
{
// requires a validator declared for 'slug' (see below)
$slug = $rd->getParameter('slug');
// ...
return 'Success';
}

Route placeholders are not exempt from strict parameter validation. Here is what happens to a {slug} on the way to your action:

  1. RoutingMiddleware matches the route and records the placeholder value.
  2. ValidationMiddleware, before your action runs, promotes it into a request parameter with WebRequest::setUnvalidatedParameter(). This stages the value so a validator can see and check it, but does not whitelist it for getParameter().
  3. A validator declared for slug checks the value and whitelists it. With no validator, the staged value is pruned after validation.

So a {slug} with no validator is treated exactly like an unvalidated query or body field: getParameter('slug') throws UnvalidatedParameterAccessException (unless you pass a default).

Which HTTP verb runs which action method is decided by Quiote\Execution\HttpMethodMapper. The defaults are:

VerbTokenMethod
GET, HEAD, OPTIONS, TRACEreadexecuteRead
POSTwriteexecuteWrite
PUT, PATCHupdateexecuteUpdate
DELETEremoveexecuteRemove

You can override or extend this map with the routing.http_method_map setting. Your entries are merged onto the defaults — you only list what you want to change:

Config/settings.php
return [
// ...
'routing.http_method_map' => [
'PATCH' => 'write', // route PATCH to executeWrite instead of executeUpdate
'LOCK' => 'lock', // adds a non-standard verb, calling executeLock()
],
];

Or set it programmatically before the map is first read:

use Quiote\Config\Config;
Config::set('routing.http_method_map', ['LOCK' => 'lock']);

Rules:

  • Keys (verbs) are matched case-insensitively; values (tokens) are lowercased.
  • The token becomes the method name, ucfirst-ed: token lock calls executeLock(). Any action meant to handle that verb needs the matching method.
  • The token also names the per-method validation hooks — token lock maps to registerLockValidators(), validateLock(), handleLockError() (see Validation).
  • An unmapped verb falls back to the read token.

Note the XML tab: a routing.* key needs the prefix attribute on the enclosing <settings> element, because a bare <setting> compiles to a core. key — see The XML prefix attribute. The Actions and views page describes how the resolved method is called.

The routing object generates URLs in reverse from a route name and parameters via gen():

public function __construct(private readonly Routing $routing) {}
// ...
$url = $this->routing->gen('post', ['slug' => 'hello-world']);
// resolves to /blog/hello-world

Quiote\Routing\Routing is bound in the container, so any action, view or service that generates URLs declares it in its constructor.