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:

flowchart TD R["Browser request GET /collection/42/provenance"] --> K["HttpKernel: middlewares (page cache, session)"] K --> M["Route match: path, HTTP method and _format against the router table"] M -->|"no route"| N404["404 Not Found page"] M --> P["Parameter conversion (route enhancer): 42 becomes the collection_object entity"] P -->|"converter returns nothing"| N404 P --> A{"Access check: every requirement on the route"} A -->|"forbidden or neutral"| N403["403 Access denied page"] A -->|"allowed"| C["Controller resolved and called with the converted arguments"] C --> V{"What did the controller return?"} V -->|"render array"| H["Main content renderer wraps it in the page: title, blocks, theme"] V -->|"Response object"| X["Sent as is, after response subscribers"] H --> O["HTML response"] X --> O

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.yml file, rebuild the router (drush cache:rebuild, or drush 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

path

The URL path, always beginning with a slash. {placeholder} segments capture variable parts. The first segment cannot be a placeholder.

defaults

What produces the response (_controller, _form, _entity_view, _entity_list, _entity_form), the page title (_title, _title_callback) and default values for placeholders.

requirements

Access conditions (_permission, _role, _access, _custom_access, _entity_access, _csrf_token and others) and regular expressions that constrain placeholders and the _format.

options

Instructions to the system rather than to the controller: parameters (upcasting), _admin_route, no_cache, _auth, _access_mode, _htmx_route.

methods

The allowed HTTP methods, for example [GET, POST]. Set it explicitly on any route that changes state.

host, schemes

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.json would 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 defaults makes it optional. A route can also use a _format placeholder, as the export route does, so /collection/export.json selects the response format through the route and not through a query string.

  • Paths under /admin do not automatically use the administration theme; the route must opt in with _admin_route: TRUE in options. 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

_controller

A callable in the form \Namespace\Class::method or service_id:method. The class is instantiated through the class resolver, so it may implement ContainerInjectionInterface (as ControllerBase does) and receive services.

_form

A class implementing FormInterface. Drupal builds the form, renders it as the main content and handles submission on the same path (see Form API).

_entity_view

entity_type.view_mode, for example collection_object.teaser. The entity is taken from the placeholder named after the entity type and rendered with that view mode, without a controller.

_entity_form

entity_type.operation, for example node.default: the entity form for an operation on the placeholder’s entity.

_entity_list

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:

  • _title is a static string, passed through the translation system as a source string, so it is translatable like any other t() literal. _title_arguments and _title_context provide placeholders and a translation context.

  • _title_callback is 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, as provenanceTitle() 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

_permission

A permission machine name. A comma means AND ('edit collection objects, view collection objects'), a plus means OR ('a + b'). Permissions are the preferred mechanism for access control because site builders can grant them in the UI.

_role

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.

_access

The string 'TRUE' allows everyone, including anonymous visitors. It must be a string, and it is the deliberate way to publish a page with no restriction.

_custom_access

A callable (for example \Namespace\Class::method) returning an access result. Its arguments are resolved by name and type: AccountInterface $account, Route, RouteMatchInterface and upcast parameters.

_entity_access

placeholder.operation, for example collection_object.view or node.update. The placeholder named before the dot must be an upcast entity; Drupal then asks the entity access handler for that operation.

_entity_create_access

entity_type:bundle form for create operations on a bundle-aware entity type.

_user_is_logged_in

'TRUE' for authenticated users only, 'FALSE' for anonymous only.

_csrf_token

'TRUE': the URL must carry a valid token in its query string (see below).

_format

Matches the _format request value (json, xml, html), which is how a route differentiates formats.

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 requirements section 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.permissions or user), 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.

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/logout the 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

parameters

Per-placeholder configuration for parameter converters, most commonly type: 'entity:collection_object'.

_admin_route

TRUE renders the route in the administration theme and applies admin-route handling (for example, node edit forms use it).

no_cache

TRUE marks the route’s responses uncacheable. A blunt instrument that disables the page cache for the path; prefer accurate cache metadata.

_auth

A list of authentication provider ids (['basic_auth'], ['cookie']) the route accepts. Used by REST-like and API routes that must not rely on the session cookie.

_access_mode

ALL (default) or ANY: whether every requirement or at least one must allow access.

_maintenance_access

TRUE keeps the route reachable while the site is in maintenance mode.

_htmx_route

TRUE (Drupal 11.3 and later) makes the route return a minimal response for HTMX requests; see AJAX and HTMX. Proxies and CDNs in front of the site must pass the HX-Request and HX-Redirect headers from 11.3.

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:

  • final and constructor promotion keep the class small; the router does not need to subclass it.

  • provenance() and provenanceTitle() take CollectionObjectInterface $collection_object, the upcast entity. The type hint is not decorative: if the argument were typed string, it would receive the raw id instead.

  • The methods contain no \Drupal:: call and no new of a service; everything arrives by the constructor.

  • The static permission name edit collection objects is declared in meridian_collection.permissions.yml, shown in the security section below.

  • Link text in overview() is rendered with toString() 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 (as ControllerBase::redirect() returns) for post-action navigation. For an external destination use TrustedRedirectResponse, which refuses untrusted hosts; never redirect to a URL taken from request input without validating it.

  • JsonResponse or, better, CacheableJsonResponse plus CacheableMetadata, so the response participates in Drupal’s caching.

  • BinaryFileResponse or StreamedResponse for downloads and large exports.

  • An exception: NotFoundHttpException, AccessDeniedHttpException and 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:

flowchart LR C["Controller returns"] --> Q{"Is it a Response object?"} Q -->|"yes"| S["Sent after response subscribers (headers, cache)"] Q -->|"array"| W{"Wrapper format of the request"} W -->|"html (default)"| HR["HtmlRenderer: page with title, blocks, regions"] W -->|"drupal_ajax, drupal_modal, drupal_dialog"| AR["Ajax and dialog renderers: commands or dialog content"] W -->|"drupal_htmx"| XR["HTMX renderer: main content and assets only"] Q -->|"anything else"| E["Exception: controller result is not renderable"]

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.

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 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 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 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.

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() takes internal:, base:, entity:, route: and absolute http(s): URIs. User-entered paths go through Url::fromUserInput(), which requires a leading slash, ? or #.

  • toString() returns a string with no cache metadata; with TRUE it returns a GeneratedUrl carrying 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 of Link::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}) and url(...) for strings and link('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 _permission over _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 with restrict 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 _permission check 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_token or move them to a form or POST route with a token.

  • Escape output. #markup is filtered, not escaped; user-supplied text goes through #plain_text, t() placeholders (@, %), or Twig autoescaping, and |raw on user input is an XSS risk.

  • Validate redirects. Redirect only to local routes, or use TrustedRedirectResponse with an allow list; do not forward a destination query value to a different host.

  • Cache correctly. Access results and responses that vary by user need the matching cache context. no_cache: TRUE hides 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 route lists routes (with --name= or --path= to filter) and shows the defaults, requirements and options as Drupal understands them after the rebuild.

  • drush cache:rebuild after every YAML change; a route that "does not exist" is usually a stale router.

  • drush generate controller scaffolds 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);
  }

}

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 drush cr and read the status report and logs.

403 for everyone, including the administrator

The route has no access requirement, the permission name is misspelled, or it is not defined in a permissions.yml. Check with drush route.

"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 Response.

The controller receives a string instead of an entity

The placeholder name and the argument name differ, the parameters.type is missing for a custom entity, or the argument is typed string.

_entity_access always denies or errors

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 Url::access() and menus cannot see it; move it to a requirement.

Different users see each other’s content

An access result or render array is missing a cache context such as user.permissions or user.

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 drush deploy, which includes a cache rebuild, or drush cr.

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 the links.*.yml files described here.

  • Constructor injection. ControllerBase includes AutowireTrait in current 11.x, so controllers can declare typed constructor parameters without a create() 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/Hook marked with #[Hook('name')]; the .module function style shown in older books still works. hook_install, hook_update_N and a few others stay procedural.

  • HTMX. Drupal 11.3 adds HTMX support: the _htmx_route option, an HTMX renderer and the drupal_htmx wrapper format. Reverse proxies and CDNs must pass the HX-Request and HX-Redirect headers.

  • CSRF and logout. /user/logout has 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.yml is no longer supported from Drush 13.