Routing and Controllers
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every page a Drupal site serves, from the front page to an admin form or a JSON export, is reached through a route:
a named definition that maps a URL path to something that produces a response, plus the rules that decide who may see
it. This page is for module developers and for site builders who want to understand what happens between the browser’s
request and the HTML that comes back. It builds the routes and controllers of the meridian_collection module for the
fictitious Meridian Museum site: a public collection overview, a per-object provenance page, a settings form for
administrators, a CSRF-protected "toggle on display" action for curators, and a JSON export, together with the menu
links, tabs and action buttons that lead to them.
It was checked on 2026-10-30 against Drupal 11.4.x, PHP 8.3+ and Drush 13/14. Drupal 10 reaches end of life on
2026-12-09, so a new module targets Drupal 11; Drupal 12.0.0 is scheduled for the week of 2026-12-07 and keeps the
routing system described here. Module scaffolding (the .info.yml file and the directory layout) is in
Module Anatomy and info.yml, services and the container in
Services and Dependency Injection, the permission system behind
_permission in Access Control and Permissions in Code, and
the end-user side of menus (the menu administration screens and path aliases) in
Menus and Path Aliases.
The big picture
Drupal routing is built on the Symfony Routing component and the HttpKernel. A module declares routes in a YAML file;
core compiles them into Symfony Route objects and stores them in the router database table (rebuilt on a cache
rebuild). At request time the pieces cooperate in a fixed order:
The order of the middle steps differs from what a quick description suggests, so it is worth being precise. The parameter
conversion step runs as a route enhancer inside the dynamic router, as part of matching the request; the access check
runs afterwards as a listener on the kernel’s request event. That is why an access checker such as _entity_access can
rely on already receiving the loaded entity, and why an unloadable {collection_object} produces a 404 before any
permission is consulted. Route matching and upcasting are therefore also the place where a request for an object that
does not exist is told apart from a request the visitor is not allowed to make.
Three consequences shape how routes are written:
-
A route is declarative. Everything the router needs to match, convert and authorise is in the YAML, so core can render a menu link only when the route is accessible and can list the routes of a site without running any controller.
-
A route needs an access requirement to be reachable at all. A route without any requirement is denied, so a public page has to say so, for example with
_permission: 'access content'or_access: 'TRUE'. -
Routes are cached. After editing a
*.routing.ymlfile, rebuild the router (drush cache:rebuild, ordrush cr); adding a route in code is invisible until then.
MODULE.routing.yml
The file lives in the module root and is named after the module: meridian_collection.routing.yml. Each top-level key
is a route name, conventionally module.something (core and the Entity API follow patterns such as
entity.node.canonical). Route names are global identifiers, used by menu links, Url::fromRoute() and
redirects, so they should never be reused between modules and should be treated as part of the module’s public API once
other code relies on them.
A route has these top-level keys:
| Key | Meaning |
|---|---|
|
The URL path, always beginning with a slash. |
|
What produces the response ( |
|
Access conditions ( |
|
Instructions to the system rather than to the controller: |
|
The allowed HTTP methods, for example |
|
Symfony matching conditions, rarely needed in Drupal. |
A complete routing file for the collection
The following file defines the routes used throughout this page. It assumes the collection_object content entity of the
module (built in Custom Entity Types) and a SettingsForm class.
# modules/custom/meridian_collection/meridian_collection.routing.yml
meridian_collection.overview:
path: '/collection'
defaults:
_controller: '\Drupal\meridian_collection\Controller\CollectionController::overview'
_title: 'Collection'
requirements:
_permission: 'access content'
meridian_collection.provenance:
path: '/collection/{collection_object}/provenance'
defaults:
_controller: '\Drupal\meridian_collection\Controller\CollectionController::provenance'
_title_callback: '\Drupal\meridian_collection\Controller\CollectionController::provenanceTitle'
requirements:
_entity_access: 'collection_object.view'
collection_object: '\d+'
options:
parameters:
collection_object:
type: 'entity:collection_object'
meridian_collection.card:
path: '/collection/{collection_object}/card'
defaults:
_entity_view: 'collection_object.teaser'
_title: 'Object card'
requirements:
_entity_access: 'collection_object.view'
meridian_collection.toggle_display:
path: '/collection/{collection_object}/toggle-display'
defaults:
_controller: '\Drupal\meridian_collection\Controller\CollectionController::toggleDisplay'
_title: 'Toggle on display'
methods: [GET]
requirements:
_custom_access: '\Drupal\meridian_collection\Controller\CollectionController::curatorAccess'
_csrf_token: 'TRUE'
collection_object: '\d+'
options:
parameters:
collection_object:
type: 'entity:collection_object'
meridian_collection.export:
path: '/collection/export.{_format}'
defaults:
_controller: '\Drupal\meridian_collection\Controller\ExportController::export'
_format: 'json'
methods: [GET]
requirements:
_permission: 'access content'
_format: 'json'
meridian_collection.settings:
path: '/admin/config/content/meridian-collection'
defaults:
_form: '\Drupal\meridian_collection\Form\SettingsForm'
_title: 'Meridian collection settings'
requirements:
_permission: 'administer collection objects'
Quote class names in single quotes. YAML treats the backslash specially inside double quotes, and a \D or \m can
be consumed as an escape sequence there, so '\Drupal\meridian_collection\Controller\CollectionController' in single quotes is the safe form. A
leading backslash is optional but customary. Tabs are not allowed in YAML indentation, and a route name that is the
same as another module’s silently replaces it according to module weight, so keep names prefixed with the module.
The path
-
The path starts with
/. A missing slash is a frequent first error that surfaces only when the router rebuilds. -
A placeholder is written
{name}and matches one segment (anything except/). The name is how the value reaches the controller and how upcasting finds the type, so choose it deliberately (see below). -
A requirement named after a placeholder is a regular expression for that segment, as in
collection_object: '\d+'. Constraining a placeholder keeps one route from shadowing another (/collection/export.jsonwould otherwise be claimed by a path with a catch-all placeholder) and rejects obviously invalid values with a 404 before any controller runs. -
A default for a placeholder in
defaultsmakes it optional. A route can also use a_formatplaceholder, as the export route does, so/collection/export.jsonselects the response format through the route and not through a query string. -
Paths under
/admindo not automatically use the administration theme; the route must opt in with_admin_route: TRUEinoptions. Core does set that option for routes it recognises as administrative. -
Define the path without a trailing slash.
Defaults: what produces the page
At least one handler must be given in defaults:
| Key | Handler |
|---|---|
|
A callable in the form |
|
A class implementing |
|
|
|
|
|
An entity type id: renders the entity type’s list builder, the basis of many admin overview pages. |
The route meridian_collection.card shows _entity_view: no controller is needed to display one object in the
teaser view mode, and access follows _entity_access. Title and form handlers such as _form are covered in more detail
in Form API.
Besides a handler, defaults carries the title:
-
_titleis a static string, passed through the translation system as a source string, so it is translatable like any othert()literal._title_argumentsand_title_contextprovide placeholders and a translation context. -
_title_callbackis a callable that returns the title and may take the same arguments as a controller, including upcast parameters. Use it when the title depends on the object, asprovenanceTitle()does. It returns a string or a translatable markup object; it must not return markup that has not been escaped. -
A render array returned by the controller can also carry
#title, which wins over the route’s title. This is the way to set a title that is only known after building the page.
Requirements: who may reach the route
Access requirements are evaluated by access checkers: services tagged access_check that declare which requirement key
they apply to. Core ships these:
| Requirement | Meaning |
|---|---|
|
A permission machine name. A comma means AND ( |
|
One or more role ids, with the same comma/plus syntax. Avoid it in modules: role ids are site-specific, so the route breaks when a site renames or restructures roles. Prefer a permission. |
|
The string |
|
A callable (for example |
|
|
|
|
|
|
|
|
|
Matches the |
When a route lists several requirements, all of them must allow access (the default _access_mode is ALL). Setting
_access_mode: ANY under options flips that to "at least one", which is occasionally useful for an administrative
route that either of two permissions should open. Inside a single _permission string the comma and plus control AND and
OR for that one requirement.
Two properties of access results matter for correctness:
-
An access result has three states: allowed, forbidden (an explicit, final no) and neutral (no opinion). Allowed requires at least one checker to say yes and none to say forbidden. A route nobody has an opinion on is denied, which is why an empty
requirementssection means the page is unreachable. -
Every result carries cacheability metadata. A checker that depends on the current user must say so (the cache context
user.permissionsoruser), or Drupal can serve one visitor’s access decision to another. This is the usual cause of "it works for me but not for the editor" reports.
A custom access method on the controller
When the rule needs code, the simplest approach is _custom_access pointing at a method. In the Meridian module only
users who may edit objects can change what is on display, and only when the object is not archived:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
final class CuratorAccessExample {
/**
* Access callback for the toggle route.
*
* Arguments are resolved by name and type, so the upcast object and the
* current account are available directly.
*/
public function curatorAccess(AccountInterface $account, CollectionObjectInterface $collection_object): AccessResultInterface {
return AccessResult::allowedIfHasPermission($account, 'edit collection objects')
->andIf(AccessResult::allowedIf(!$collection_object->isArchived()))
->addCacheableDependency($collection_object);
}
}
AccessResult::allowedIfHasPermission() adds the permissions cache context for you, and addCacheableDependency()
makes the cached decision invalidate when the object changes. The method lives on the controller in the full module (see
below); the standalone class here only keeps the access logic in focus. Throwing AccessDeniedHttpException inside the
controller body is possible, but it is the weaker option: menu links, tabs and Url::access() cannot know about a check
that happens only while the controller runs, so they would show links the visitor cannot use.
A reusable access checker service
If several routes need the same rule, declare a new requirement key and register a checker service. The route uses the key; the service says how to evaluate it.
# modules/custom/meridian_collection/meridian_collection.services.yml
services:
meridian_collection.access.on_display:
class: Drupal\meridian_collection\Access\OnDisplayAccessCheck
tags:
- { name: access_check, applies_to: _meridian_on_display }
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Allows a route only when the upcast object is currently on display.
*/
final class OnDisplayAccessCheck implements AccessInterface {
public function access(CollectionObjectInterface $collection_object): AccessResultInterface {
return AccessResult::allowedIf($collection_object->isOnDisplay())
->addCacheableDependency($collection_object);
}
}
meridian_collection.visitor_label:
path: '/collection/{collection_object}/label'
defaults:
_controller: '\Drupal\meridian_collection\Controller\CollectionController::provenance'
_title: 'Visitor label'
requirements:
_meridian_on_display: 'TRUE'
_entity_access: 'collection_object.view'
options:
parameters:
collection_object:
type: 'entity:collection_object'
The checker is an object with an access() method whose parameters are resolved from the request like the arguments of
a controller. It does not extend a base class, and it is only consulted for routes that carry its applies_to
requirement.
CSRF protection for links that do something
A GET link that changes state (toggling an object on or off display, deleting, publishing) is an easy target for a
forged request embedded in an image tag or a link on another site. The _csrf_token requirement protects such a route:
generating the URL from the route name (Url::fromRoute() or a menu link) appends a token to the query string, and the
access check rejects requests whose token is missing or invalid.
-
The value is the string
'TRUE', not a YAML boolean. -
The token is bound to the user’s session, so the check fails for visitors without a session, which includes most anonymous users.
-
The token must be generated through the URL generator by route name. A hand-built
"/collection/1/toggle-display"string never receives one. -
Core protects
/user/logoutthe same way since Drupal 10.3; custom routes that mutate state should do likewise, and state changes belong on POST routes with a form (which carries its own token) whenever possible.
Options: instructions to the system
| Option | Effect |
|---|---|
|
Per-placeholder configuration for parameter converters, most commonly |
|
|
|
|
|
A list of authentication provider ids ( |
|
|
|
|
|
|
Parameters and upcasting
A placeholder such as {collection_object} is captured as a string (42). Two mechanisms turn it into something useful.
Name matching. If the controller (or title callback, or access callback) declares an argument with the same name as the
placeholder, Drupal passes the value in. provenance(CollectionObjectInterface $collection_object) receives whatever the
router resolved for collection_object. The argument name is not a convention but the binding: renaming either side
silently breaks it. Arguments without a corresponding placeholder can be satisfied from the request itself:
Request $request, RouteMatchInterface $route_match, AccountInterface $account and Route $route.
Upcasting. A parameter converter replaces the raw value with an object before the controller is called. The
entity converter is built in. With options.parameters.collection_object.type: 'entity:collection_object', 42 becomes
the loaded entity, in the translation matching the current content language. Core also configures the entity converter
automatically for placeholders whose name is the id of an entity type ({node}, {user}), which is why core node
routes need no parameters option; for custom entity types and for placeholders with another name, state it explicitly.
Two further options are worth knowing for entity parameters: bundle restricts the converter to the listed bundles, and
load_latest_revision: TRUE loads the latest revision rather than the default one, which the content moderation
screens rely on.
When a converter cannot produce a value (the id does not exist, or the bundle does not match) the request ends in a
404, not a 403 or a PHP error. This also means that _entity_access never sees a missing entity, and that a visitor
cannot tell a missing object from an unpublished one if the entity access handler returns forbidden for the latter,
which is the intended privacy behaviour.
The raw value is not lost. RouteMatchInterface::getRawParameter('collection_object') returns the original string, useful
for logging or building a redirect without loading the entity again.
A custom parameter converter
Sometimes the URL carries a natural key rather than the numeric id. Museum visitors prefer
/collection/by-accession/1987.12.3 to a database id. A custom converter looks the object up by accession number.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\ParamConverter;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\ParamConverter\ParamConverterInterface;
use Symfony\Component\Routing\Route;
/**
* Converts an accession number from the URL into a collection object.
*/
final class AccessionNumberConverter implements ParamConverterInterface {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public function convert($value, $definition, $name, array $defaults) {
$ids = $this->entityTypeManager->getStorage('collection_object')
->getQuery()
->accessCheck(TRUE)
->condition('accession_number', $value)
->range(0, 1)
->execute();
// Returning NULL tells the router the value cannot be converted (404).
return $ids ? $this->entityTypeManager->getStorage('collection_object')->load(reset($ids)) : NULL;
}
public function applies($definition, $name, Route $route) {
return isset($definition['type']) && $definition['type'] === 'meridian_accession';
}
}
# meridian_collection.services.yml (added to the file above)
meridian_collection.accession_converter:
class: Drupal\meridian_collection\ParamConverter\AccessionNumberConverter
arguments: ['@entity_type.manager']
tags:
- { name: paramconverter }
# meridian_collection.routing.yml
meridian_collection.by_accession:
path: '/collection/by-accession/{collection_object}'
defaults:
_controller: '\Drupal\meridian_collection\Controller\CollectionController::provenance'
_title: 'Provenance'
requirements:
_entity_access: 'collection_object.view'
options:
parameters:
collection_object:
type: 'meridian_accession'
The service is tagged paramconverter. For each parameter the manager asks the converters, in priority order, whether
applies() to the parameter’s type value, and the first one that applies is stored on the route. Because the
accession number contains dots, the placeholder would also need a permissive requirement
(collection_object: '[0-9.A-Za-z-]+') so that the default segment pattern is not relied on.
Controllers
A controller is any PHP callable the router can resolve. In practice it is a method on a class in the module’s
src/Controller directory. The class name and method are what _controller points at.
ControllerBase and injected services
Most controllers extend \Drupal\Core\Controller\ControllerBase. It implements ContainerInjectionInterface, includes
the string translation, messenger, logger and redirect-destination traits (so $this->t(), $this->messenger() and
$this->getLogger() work), and offers helpers such as config(), currentUser(), entityTypeManager(),
formBuilder(), moduleHandler(), languageManager(), state() and redirect(). Those helpers fetch services
lazily from the global container, which is acceptable for a quick prototype but hides dependencies and makes the class
harder to test.
Since the base class uses AutowireTrait, the clean pattern in current Drupal 11 is a constructor with type-hinted
services and nothing else: the trait’s create() method resolves each constructor parameter by its type from the
container, using the autowiring aliases that core registers for its common services. No create() override and no
$container->get('service_id') line is needed. Only services the container can resolve by class or interface name are
autowirable; for the others, or when two services share a type, add an #[Autowire(service: 'id')] attribute on the
parameter. Library code (services, forms, plugins) should use constructor injection throughout; the static \Drupal::
accessors belong in procedural code (.module files, .install hooks) and tests.
Older books such as Drupal 10 Development Cookbook (2023) show controllers that override create() and call
$container->get() by hand, or that call \Drupal::service() inside methods. That code still works, but with
AutowireTrait in ControllerBase the boilerplate is optional, and the static calls are the pattern to avoid in new
code.
The collection services used by the controller are declared as autowirable services:
# meridian_collection.services.yml
services:
Drupal\meridian_collection\CollectionRepository:
autowire: true
Drupal\meridian_collection\CollectionRepositoryInterface: '@Drupal\meridian_collection\CollectionRepository'
The controller
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
use Drupal\meridian_collection\CollectionRepositoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Pages of the public collection and the curator toggle.
*/
final class CollectionController extends ControllerBase {
public function __construct(
private readonly CollectionRepositoryInterface $repository,
private readonly DateFormatterInterface $dateFormatter,
) {}
/**
* The public overview: objects currently on display.
*
* @return array<string, mixed>
* A render array.
*/
public function overview(): array {
$rows = [];
foreach ($this->repository->findOnDisplay(50) as $object) {
$rows[] = [
$object->toLink($object->label())->toString(),
$object->getAccessionNumber(),
$this->dateFormatter->format($object->getChangedTime(), 'short'),
];
}
return [
'#type' => 'table',
'#header' => [$this->t('Object'), $this->t('Accession number'), $this->t('Updated')],
'#rows' => $rows,
'#empty' => $this->t('No objects are on display right now.'),
'#cache' => [
'tags' => ['collection_object_list'],
'contexts' => ['languages:language_interface'],
],
];
}
/**
* Provenance page for one object; the entity arrives already upcast.
*
* @return array<string, mixed>
* A render array.
*/
public function provenance(CollectionObjectInterface $collection_object): array {
return [
'#theme' => 'item_list',
'#items' => [
$this->t('Accession number: @number', ['@number' => $collection_object->getAccessionNumber()]),
$this->t('Period: @period', ['@period' => $collection_object->getPeriod()]),
$this->t('Material: @material', ['@material' => $collection_object->getMaterial()]),
],
'#cache' => [
'tags' => $collection_object->getCacheTags(),
'contexts' => ['languages:language_interface'],
],
];
}
/**
* Title callback; receives the same upcast parameter.
*/
public function provenanceTitle(CollectionObjectInterface $collection_object): TranslatableMarkup {
return $this->t('Provenance of @label', ['@label' => $collection_object->label()]);
}
/**
* Flips the on-display flag and returns to the object page.
*/
public function toggleDisplay(CollectionObjectInterface $collection_object): RedirectResponse {
$collection_object->setOnDisplay(!$collection_object->isOnDisplay())->save();
$this->messenger()->addStatus($this->t('@label is now @state.', [
'@label' => $collection_object->label(),
'@state' => $collection_object->isOnDisplay() ? $this->t('on display') : $this->t('in storage'),
]));
return $this->redirect('entity.collection_object.canonical', ['collection_object' => $collection_object->id()]);
}
/**
* Access callback referenced by _custom_access on the toggle route.
*/
public function curatorAccess(AccountInterface $account, CollectionObjectInterface $collection_object): AccessResultInterface {
return AccessResult::allowedIfHasPermission($account, 'edit collection objects')
->addCacheableDependency($collection_object);
}
}
Points worth noting in this controller:
-
finaland constructor promotion keep the class small; the router does not need to subclass it. -
provenance()andprovenanceTitle()takeCollectionObjectInterface $collection_object, the upcast entity. The type hint is not decorative: if the argument were typedstring, it would receive the raw id instead. -
The methods contain no
\Drupal::call and nonewof a service; everything arrives by the constructor. -
The static permission name
edit collection objectsis declared inmeridian_collection.permissions.yml, shown in the security section below. -
Link text in
overview()is rendered withtoString()inside table cells; a render array ('#type' => 'link') is more idiomatic and keeps cache metadata, as described under Url and Link.
Returning a render array
Return a render array from any page that should appear inside the site’s theme: with its blocks, regions, title and
messages. Drupal turns the array into HTML, collects the cache metadata from everything inside it, and wraps the result
in the page. Typical building blocks are #markup and #plain_text for simple text, #theme for a theme hook or
template, #type for a form element, table, link or other render element, and #attached for libraries and
drupalSettings. A #cache entry with tags, contexts and max-age is how the array tells the page cache and the dynamic
page cache when to forget the output; the full vocabulary is in
Render API and Cache Metadata.
Never return a plain string or an echoed fragment: the kernel reports that the controller result is not a Response or
renderable array. Output built from user input must go through #plain_text, a template with autoescaping, or a filtered
#markup, because #markup is filtered with an admin-level HTML allow list rather than escaped.
Returning a Response
When the output is not a themed page, return a Symfony Response and Drupal sends it as is, skipping the main content
rendering. Common cases:
-
RedirectResponse(asControllerBase::redirect()returns) for post-action navigation. For an external destination useTrustedRedirectResponse, which refuses untrusted hosts; never redirect to a URL taken from request input without validating it. -
JsonResponseor, better,CacheableJsonResponseplusCacheableMetadata, so the response participates in Drupal’s caching. -
BinaryFileResponseorStreamedResponsefor downloads and large exports. -
An exception:
NotFoundHttpException,AccessDeniedHttpExceptionand the other Symfony HTTP exceptions are rendered by Drupal as the site’s 404 and 403 pages.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Controller;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\meridian_collection\CollectionRepositoryInterface;
/**
* JSON export of the objects on display.
*/
final class ExportController extends ControllerBase {
public function __construct(
private readonly CollectionRepositoryInterface $repository,
) {}
public function export(): CacheableJsonResponse {
$items = [];
foreach ($this->repository->findOnDisplay(500) as $object) {
$items[] = [
'accession_number' => $object->getAccessionNumber(),
'title' => $object->label(),
'period' => $object->getPeriod(),
];
}
$response = new CacheableJsonResponse(['data' => $items]);
$response->addCacheableDependency(
(new CacheableMetadata())
->addCacheTags(['collection_object_list'])
->setCacheMaxAge(3600)
);
return $response;
}
}
The route’s _format: 'json' requirement means /collection/export.json matches and /collection/export.xml does not.
A real, authenticated API usually belongs to the core web services modules rather than a hand-written controller
(see JSON:API).
How the kernel treats the return value:
Forms and entity routes without a controller
Not every route needs a controller class. The settings page uses _form, which builds the form and handles submission
on its own path. The object card uses _entity_view, and entity types defined with the Entity API normally get their
canonical, edit and delete routes generated from their link templates by the route provider, with names like
entity.collection_object.canonical. Writing a controller is the right choice for everything that is neither a form
nor an entity display.
Dynamic routes
Routes that depend on data or configuration cannot be written statically. The route_callbacks key in a routing file
names a callable that returns a RouteCollection or an array of Route objects. The Meridian site exposes the export
in the formats an administrator enabled:
# meridian_collection.routing.yml (additional entry)
route_callbacks:
- '\Drupal\meridian_collection\Routing\ExportRoutes::routes'
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Routing;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\Routing\Route;
/**
* Builds one export route per format enabled in configuration.
*/
final class ExportRoutes implements ContainerInjectionInterface {
use AutowireTrait;
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* @return array<string, \Symfony\Component\Routing\Route>
*/
public function routes(): array {
$routes = [];
$formats = $this->configFactory->get('meridian_collection.settings')->get('export_formats') ?? ['json'];
foreach ($formats as $format) {
$routes['meridian_collection.export_' . $format] = new Route(
'/collection/dump.' . $format,
[
'_controller' => '\Drupal\meridian_collection\Controller\ExportController::export',
'_format' => $format,
],
['_permission' => 'access content'],
[],
'',
[],
['GET'],
);
}
return $routes;
}
}
The callable is run when the router is built, not on each request. When the configuration it reads changes, the module
has to ask for a rebuild (inject RouteBuilderInterface and call setRebuildNeeded() from the settings form’s submit
handler), otherwise the old set of routes stays in the table until the next cache rebuild.
Route subscribers: altering routes
Other modules' routes can be changed after they are collected, by subscribing to the routing alter event. A subscriber
extends RouteSubscriberBase and implements alterRoutes(), which receives the whole RouteCollection. The Meridian
module moves the object edit form into the admin theme and requires a curator permission on it:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Adjusts the routes of the collection_object entity.
*/
final class CollectionRouteSubscriber extends RouteSubscriberBase {
protected function alterRoutes(RouteCollection $collection): void {
if ($route = $collection->get('entity.collection_object.edit_form')) {
$route->setOption('_admin_route', TRUE);
$route->setRequirement('_permission', 'edit collection objects');
}
}
}
# meridian_collection.services.yml (added)
meridian_collection.route_subscriber:
class: Drupal\meridian_collection\Routing\CollectionRouteSubscriber
tags:
- { name: event_subscriber }
RouteSubscriberBase subscribes to RoutingEvents::ALTER (and to the event that lets subscribers add routes). If two
subscribers touch the same route, the order matters; override getSubscribedEvents() and give the alter event an explicit
priority so that yours runs after the module whose route you modify. Subscribers can also add routes, though route_callbacks is simpler for routes that are only added. Event subscribers
in general are covered in Events and Subscribers.
Keep alterations narrow. Replacing a core route’s controller or loosening its access requirements changes what every module on the site assumes, and the change has to be re-checked on each core update.
Menu links, local tasks and local actions
Routes become visible to users through four YAML plugin files, all named after the module and all discovered when the caches are rebuilt. A link is displayed only if the user has access to the route, evaluated with the same access checkers; there is no separate permission to maintain for a menu item.
Menu links: MODULE.links.menu.yml
Menu links place an item in a menu, normally the administration menu for a module’s settings page, or the main navigation for a public page.
# meridian_collection.links.menu.yml
meridian_collection.admin_settings:
title: 'Meridian collection'
description: 'Configure the museum collection and its export formats.'
route_name: meridian_collection.settings
parent: system.admin_config_content
weight: 20
meridian_collection.overview:
title: 'Collection'
description: 'Objects currently on display.'
route_name: meridian_collection.overview
menu_name: main
weight: 30
The key properties are title (translatable; title_context disambiguates), route_name for internal pages (or url
for an external address), route_parameters for routes with placeholders, parent (the plugin id of the parent
link, which is how the Configuration administration page hierarchy is built), menu_name (defaults to the Tools menu),
weight (lower first) and enabled. The first link nests under Configuration > Content authoring, where the admin
menu and the toolbar show it; the second goes in the Main navigation menu.
Links defined in code are plugins that an administrator can rearrange or disable in the menu UI, but not delete.
Because their content is cached, drush cr is needed after a change. The Toolbar module is among the modules Drupal 12
moves to contrib, so sites depending on it should install the contributed version, but the menu definitions remain the
same.
A module can alter all discovered links with a hook. As an OOP hook (Drupal 11.1 and later) it goes in a class under
src/Hook and no .module function is needed:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Menu hooks of the collection module.
*/
final class MenuHooks {
/**
* Hides the legacy export link from the administration menu.
*
* @param array<string, array<string, mixed>> $links
* The discovered menu link definitions, keyed by plugin id.
*/
#[Hook('menu_links_discovered_alter')]
public function alterLinks(array &$links): void {
if (isset($links['meridian_collection.legacy_export'])) {
unset($links['meridian_collection.legacy_export']);
}
}
}
Local tasks: MODULE.links.task.yml
Local tasks are the tabs ("View", "Edit", "Delete") shown on a page. A tab belongs to a base route: every task with
the same base_route appears in the same tab bar.
# meridian_collection.links.task.yml
meridian_collection.overview_tab:
route_name: meridian_collection.overview
base_route: meridian_collection.overview
title: 'Overview'
weight: 0
meridian_collection.export_tab:
route_name: meridian_collection.export
base_route: meridian_collection.overview
title: 'JSON export'
weight: 10
route_name is the route the tab links to and base_route groups the tabs, and parent_id creates second-level
tabs (the base route is then taken from the parent). Tabs also appear only when the user can access their route, and a
tab bar with a single visible tab is hidden.
Local actions: MODULE.links.action.yml
Local actions are the "+ Add …" buttons above a list. An action lists the routes it appears_on:
# meridian_collection.links.action.yml
meridian_collection.add_object:
route_name: entity.collection_object.add_form
title: 'Add object'
appears_on:
- meridian_collection.overview
Further keys include route_parameters, weight, a class extending LocalActionDefault for a dynamic title, and a
deriver for generating many actions from data. A similar file, MODULE.links.contextual.yml, defines the
pencil-icon contextual links with the group, route_name, title and weight keys.
Url and Link
Code that needs an address must not concatenate paths. The \Drupal\Core\Url value object describes a destination and
knows how to turn it into a string honouring the base path, language prefix, query string and cacheability.
<?php
declare(strict_types=1);
use Drupal\Core\Link;
use Drupal\Core\Url;
// A routed URL, optionally with parameters and options.
$url = Url::fromRoute('meridian_collection.provenance', ['collection_object' => 42], [
'query' => ['ref' => 'kiosk'],
'fragment' => 'material',
'absolute' => TRUE,
]);
// Other kinds of destination.
$home = Url::fromRoute('<front>');
$page = Url::fromUri('internal:/visit');
$external = Url::fromUri('https://example.org/loans', ['attributes' => ['rel' => 'noopener']]);
$entity = Url::fromUri('entity:node/12');
// A string for a template or a response; TRUE also returns the bubbleable metadata.
$text = $url->toString();
$generated = $url->toString(TRUE);
// Check that the current user can follow it before rendering a link.
if ($url->access()) {
$link = Link::fromTextAndUrl('Provenance', $url)->toRenderable();
}
// Shortcut when the route name is known.
$tour = Link::createFromRoute('Visit', 'meridian_collection.overview')->toRenderable();
The behaviours to know:
-
Url::fromRoute()takes a route name and the route parameters. The<front>pseudo route is the home page, and<nolink>and<button>produce links without a destination. Route parameters can also be entities; the id is taken from them. -
Url::fromUri()takesinternal:,base:,entity:,route:and absolutehttp(s):URIs. User-entered paths go throughUrl::fromUserInput(), which requires a leading slash,?or#. -
toString()returns a string with no cache metadata; withTRUEit returns aGeneratedUrlcarrying the cache contexts (language, path prefix) that must be bubbled up to the render array. Whenever the URL is placed in a render array, prefer the render array ofLink::toRenderable()or'#type' => 'link', because the renderer collects that metadata for you. -
Url::access()runs the route’s access requirements for the given account (the current user by default), which is how a link is hidden when the visitor cannot use it. Routes that enforce access by throwing exceptions in their controller return no information here. -
In Twig, use
path('route_name', {param: value})andurl(...)for strings andlink('text', url)for links.
The current request’s route is available from the current_route_match service
(RouteMatchInterface::getRouteName(), getParameter(), getRawParameter()), which a block or subscriber uses to
adapt to the page.
Security checklist for routes
-
Always state access. Missing requirements mean the page is unreachable;
_access: 'TRUE'should be a conscious decision, reviewed for every route that exposes data. Prefer_permissionover_role. -
Define permissions in the module. Roles are granted permissions by site builders; a module that needs a new one adds
meridian_collection.permissions.yml, and sensitive permissions are marked withrestrict access: true. -
Use entity access for entity routes.
_entity_access: 'collection_object.view'lets the entity’s own access handler and any access policies decide, so field and revision rules apply consistently. A hand-written_permissioncheck can bypass them. -
Do not trust the URL. Constrain placeholders with a regular expression, validate request input in the controller, and use
accessCheck(TRUE)on every entity query that serves visitors. -
Protect state-changing GET routes with
_csrf_tokenor move them to a form or POST route with a token. -
Escape output.
#markupis filtered, not escaped; user-supplied text goes through#plain_text,t()placeholders (@,%), or Twig autoescaping, and|rawon user input is an XSS risk. -
Validate redirects. Redirect only to local routes, or use
TrustedRedirectResponsewith an allow list; do not forward adestinationquery value to a different host. -
Cache correctly. Access results and responses that vary by user need the matching cache context.
no_cache: TRUEhides problems but also disables page caching for the path. -
Mind the admin theme and admin paths. An
/admin/...path is not automatically protected: protection comes from the requirement, not the path.
# meridian_collection.permissions.yml (excerpt)
administer collection objects:
title: 'Administer collection objects'
description: 'Manage the fields, displays and settings of collection objects. Grants every operation on every object.'
restrict access: true
edit collection objects:
title: 'Edit collection objects'
Debugging and testing routes
-
drush routelists routes (with--name=or--path=to filter) and shows the defaults, requirements and options as Drupal understands them after the rebuild. -
drush cache:rebuildafter every YAML change; a route that "does not exist" is usually a stale router. -
drush generate controllerscaffolds a controller class and the routing entry in the Drupal Code Generator’s style. -
The Webprofiler and Devel modules show the matched route, parameters and access decisions for a request.
A functional test confirms the access rules end to end. Core’s current PHPUnit generation uses attributes instead of annotations:
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Functional;
use Drupal\Tests\BrowserTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class CollectionRoutesTest extends BrowserTestBase {
protected static $modules = ['meridian_collection'];
protected $defaultTheme = 'stark';
public function testOverviewIsPublicAndSettingsAreNot(): void {
$this->drupalGet('/collection');
$this->assertSession()->statusCodeEquals(200);
$this->drupalGet('/admin/config/content/meridian-collection');
$this->assertSession()->statusCodeEquals(403);
$this->drupalLogin($this->drupalCreateUser(['administer collection objects']));
$this->drupalGet('/admin/config/content/meridian-collection');
$this->assertSession()->statusCodeEquals(200);
}
}
Source: Automated testing in Drupal
Common errors
| Symptom | Cause and fix |
|---|---|
The new page is a 404 |
The router was not rebuilt, the path lacks a leading slash, or the YAML failed to parse. Run |
403 for everyone, including the administrator |
The route has no access requirement, the permission name is misspelled, or it is not defined in a |
"The controller result claims to be providing relevant content" or a similar kernel error |
The controller returned a string or an echoed value instead of a render array or a |
The controller receives a string instead of an entity |
The placeholder name and the argument name differ, the |
|
The name before the dot is not the placeholder of an upcast entity. |
A link is visible but leads to a 403 |
The check lives only in the controller body, so |
Different users see each other’s content |
An access result or render array is missing a cache context such as |
The CSRF link works for the administrator but not anonymous visitors |
The token needs a session; use a form or POST route for anonymous actions. |
Route changes have no effect after a deployment |
The router table is cached. Run |
Version notes and corrections
-
Drupal 8 to 11. The route format,
ControllerBase, upcasting and access checkers have been stable since Drupal 8.hook_menu()from Drupal 7 does not exist; its job is split between routes and thelinks.*.ymlfiles described here. -
Constructor injection.
ControllerBaseincludesAutowireTraitin current 11.x, so controllers can declare typed constructor parameters without acreate()method. Books and tutorials from the Drupal 8 and 9 era write the boilerplate by hand. -
Hooks. Since Drupal 11.1 hooks can be methods on classes in
src/Hookmarked with#[Hook('name')]; the.modulefunction style shown in older books still works.hook_install,hook_update_Nand a few others stay procedural. -
HTMX. Drupal 11.3 adds HTMX support: the
_htmx_routeoption, an HTMX renderer and thedrupal_htmxwrapper format. Reverse proxies and CDNs must pass theHX-RequestandHX-Redirectheaders. -
CSRF and logout.
/user/logouthas required a CSRF token since 10.3; custom state-changing links should follow the same rule. -
Entity routes. Entity types define their routes through link templates and route providers, so a controller for entity add/edit/delete is rarely written by hand.
-
Drupal 12. The routing system continues unchanged in 12.0.0 (scheduled for the week of 2026-12-07). The Toolbar module moves to contrib, so sites relying on it need the contributed project; module-defined menu links keep working.
-
Drush. Drush 13 serves Drupal 10.2 and 11 and Drush 14 serves 11.3 and 12. Commands use PHP attributes, and
drush.services.ymlis no longer supported from Drush 13.
References
-
Routing system: Introductory Drupal routes and controllers example
-
Routing system: Altering existing routes and adding new routes based on dynamic ones
-
Parameters in routes: Implementing custom parameter converters
-
Routing system: Routing-related objects (Route, CurrentRouteMatch, RouteMatch, Url)
-
Change record: Support for object oriented hook implementations