Skip to content

How-to guideFor module developersDrupal 11

Routes and controllers

This page shows how to give your module its own pages: declare a route in a *.routing.yml file, point it at a controller class, and inject the services the controller needs. It also covers route parameters, entity upcasting, title callbacks, and JSON responses.

A URL of your choosing that runs your PHP and returns a page (or JSON), guarded by an access requirement you declare. You want this whenever a module needs its own page that is not a form (use _form, covered in Build and alter forms) and not an entity’s own add/edit/delete page (entity types generate those routes themselves; see Define a custom entity type).

  1. Declare the route in MODULE.routing.yml, in the module’s top-level directory, with your module’s machine name in place of MODULE. The samples on this page come from the examples project’s module_howtos_a module.

    web/modules/custom/docs_snippets/modules/module_howtos_a/module_howtos_a.routing.yml
    module_howtos_a.team:
    path: '/team/{department}'
    defaults:
    _controller: 'Drupal\module_howtos_a\Controller\TeamController::page'
    _title_callback: 'Drupal\module_howtos_a\Controller\TeamController::title'
    requirements:
    _permission: 'access content'
    # The {department} slug must match this regex or the router returns 404.
    department: '[a-z_]+'

    Each top-level key is a route name, conventionally module_name.purpose. Other code refers to routes by this name, never by path, so paths can change without breaking links. Under it:

    • path is the URL, with placeholders in braces. Each placeholder becomes an argument to your controller method, matched by parameter name.
    • defaults says what to execute: _controller as a Class::method string, plus either a static _title or a _title_callback.
    • requirements controls access and matching. Every route must declare an access requirement: _permission is the most common; alternatives are _access: 'TRUE' for unrestricted pages, _entity_access (for example node.update), and _custom_access pointing at a method that returns an \Drupal\Core\Access\AccessResult. A requirement keyed by a placeholder name (like department above) is a regular expression the raw value must match; failing it produces a 404.
  2. Write the controller. Extend \Drupal\Core\Controller\ControllerBase and return a render array; Drupal turns it into a full HTML page in the configured theme.

    web/modules/custom/docs_snippets/modules/module_howtos_a/src/Controller/TeamController.php
    /**
    * Lists the people in a department.
    *
    * ControllerBase uses AutowireTrait, so the inherited create() factory
    * resolves every constructor argument from its type hint. A typed
    * constructor is all the wiring this class needs: no create() override.
    */
    final class TeamController extends ControllerBase {
    /**
    * Hard-coded roster, standing in for a real data source.
    */
    private const DEPARTMENTS = [
    'engineering' => ['Ada', 'Grace', 'Linus'],
    'design' => ['Dieter', 'Susan'],
    ];
    /**
    * Constructs the controller.
    *
    * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
    * The date formatter service.
    * @param \Drupal\Component\Datetime\TimeInterface $time
    * The time service.
    */
    public function __construct(
    protected readonly DateFormatterInterface $dateFormatter,
    protected readonly TimeInterface $time,
    ) {}
    /**
    * Builds the team page for one department.
    *
    * @param string $department
    * The department slug from the {department} route parameter.
    *
    * @return array
    * A render array.
    */
    public function page(string $department): array {
    $build['members'] = [
    '#theme' => 'item_list',
    '#items' => $this->members($department),
    ];
    $build['generated'] = [
    '#markup' => $this->t('Generated at @time.', [
    '@time' => $this->dateFormatter->format($this->time->getRequestTime(), 'short'),
    ]),
    ];
    return $build;
    }
    /**
    * Returns the page title for the team page.
    *
    * @param string $department
    * The department slug from the {department} route parameter.
    *
    * @return \Drupal\Core\StringTranslation\TranslatableMarkup
    * The translated page title.
    */
    public function title(string $department): TranslatableMarkup {
    return $this->t('The @department team', ['@department' => $department]);
    }

    There is no create() method here, and no service wiring anywhere else. ControllerBase uses \Drupal\Core\DependencyInjection\AutowireTrait, whose inherited create() reads the constructor with reflection and resolves each argument from the container by its type hint. When a type hint alone is ambiguous (a logger channel, for example, since every channel implements the same interface), name the service with the #[Autowire(service: '...')] parameter attribute.

    Throwing NotFoundHttpException (or AccessDeniedHttpException) from a controller produces the standard 404 (or 403) page. That keeps “the data does not exist” handling next to the data lookup.

  3. Rebuild caches so the router picks up the new file:

    Terminal window
    drush cache:rebuild

    Routes are compiled and cached; Drupal does not re-read *.routing.yml on every request. Repeat this after every change to the file.

  • Visit /team/engineering as a user with the access content permission. You see a page titled “The engineering team” with a three-item list and a “Generated at …” line.
  • Visit /team/nope. You get a 404, because the controller throws NotFoundHttpException for unknown departments.
  • Visit /team/Engineering. You also get a 404: the uppercase letter fails the [a-z_]+ requirement before the controller ever runs.
  • drush route --path=/team/engineering prints the matched route name and definition.

When a path placeholder is an entity ID, Drupal can load the entity for you (“upcasting”) so your controller receives a full object instead of a string. Declare the parameter type under options:

web/modules/custom/event_rsvp/event_rsvp.routing.yml
event_rsvp.rsvp_form:
path: '/node/{node}/rsvp'
defaults:
_form: 'Drupal\event_rsvp\Form\RsvpForm'
_title: 'RSVP to this event'
requirements:
_permission: 'rsvp to events'
# The visitor must be able to view the event itself, so unpublished or
# otherwise restricted nodes get no RSVP form.
_entity_access: 'node.view'
# Custom access check: only nodes of an RSVP-enabled content type get
# an RSVP form. See \Drupal\event_rsvp\Access\RsvpAccessCheck.
_event_rsvp_enabled: 'TRUE'
node: \d+
options:
parameters:
node:
type: 'entity:node'
event_rsvp.report:
path: '/admin/reports/event-rsvp/{node}'
defaults:
_controller: 'Drupal\event_rsvp\Controller\RsvpReportController::report'
_title_callback: 'Drupal\event_rsvp\Controller\RsvpReportController::reportTitle'
requirements:
_permission: 'view event rsvp reports'
_entity_access: 'node.view'
node: \d+
options:
parameters:
node:
type: 'entity:node'
event_rsvp.settings:
path: '/admin/config/services/event-rsvp'
defaults:
_form: 'Drupal\event_rsvp\Form\RsvpSettingsForm'
_title: 'Event RSVP settings'
requirements:
_permission: 'administer event rsvp'

With type: 'entity:node', \Drupal\Core\ParamConverter\EntityConverter loads the node and the controller method type-hints it directly. A missing entity becomes a 404 with no code on your part. Loading is not an access check: the _entity_access: 'node.view' requirement on the two node routes is what stops visitors from reaching pages about nodes they cannot view. The controller:

web/modules/custom/event_rsvp/src/Controller/RsvpReportController.php
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\event_rsvp\RsvpStorage;
use Drupal\node\NodeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Lists who has responded to an event.
*/
final class RsvpReportController extends ControllerBase {
/**
* Constructs the report controller.
*
* @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage
* The RSVP storage service.
* @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
* The date formatter service.
*/
public function __construct(
protected readonly RsvpStorage $rsvpStorage,
protected readonly DateFormatterInterface $dateFormatter,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
// Dependency injection: ask the container for the services this
// controller needs instead of reaching for \Drupal::service().
return new static(
$container->get('event_rsvp.storage'),
$container->get('date.formatter'),
);
}
/**
* Builds the RSVP report for one event node.
*
* @param \Drupal\node\NodeInterface $node
* The event node, upcast from the {node} route parameter.
*
* @return array
* A render array.
*/
public function report(NodeInterface $node): array {
$rows = [];
foreach ($this->rsvpStorage->listForNode((int) $node->id()) as $record) {
$rows[] = [
$record->mail,
$this->dateFormatter->format((int) $record->created, 'short'),
];
}
$build['table'] = [
'#type' => 'table',
'#header' => [$this->t('Email'), $this->t('Submitted')],
'#rows' => $rows,
'#empty' => $this->t('Nobody has responded yet.'),
// Cached copies of this page are invalidated when the node changes or
// when RsvpStorage writes a new RSVP for it.
'#cache' => [
'tags' => array_merge(
$node->getCacheTags(),
[RsvpStorage::cacheTag((int) $node->id())],
),
],
];
return $build;
}
/**
* Returns the report page title.
*
* @param \Drupal\node\NodeInterface $node
* The event node.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The translated page title.
*/
public function reportTitle(NodeInterface $node): TranslatableMarkup {
return $this->t('RSVPs for @title', ['@title' => $node->label()]);
}
}

If the placeholder name matches an entity type ID (like {node}) and the controller type-hints an entity interface, \Drupal\Core\Entity\EntityResolverManager adds this parameter definition for you. Declaring it explicitly costs one stanza and removes the guesswork. The converter also accepts a bundle list to 404 on the wrong bundle, and load_latest_revision: TRUE for moderation workflows.

A static _title cannot mention the data on the page. Point _title_callback at a controller method instead, as the event_rsvp.report route above does: the callback receives the same upcast parameters as the controller and returns a translatable string (“RSVPs for @title”).

A controller is not required to return a render array. Return any Symfony response object and Drupal sends it as-is:

web/modules/custom/docs_snippets/modules/module_howtos_a/src/Controller/TeamController.php
/**
* Returns the department roster as JSON.
*
* @param string $department
* The department slug from the {department} route parameter.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The roster as application/json.
*/
public function export(string $department): JsonResponse {
return new JsonResponse([
'department' => $department,
'members' => $this->members($department),
]);
}

The methods: [GET] key on the route restricts which HTTP methods match. For cacheable JSON, return a \Drupal\Core\Cache\CacheableJsonResponse and add the cacheability of whatever the data depends on. For exposing entities as JSON, prefer JSON:API over hand-built endpoints.

A YAML file cannot express “one route per X”. Add a route_callbacks entry to *.routing.yml pointing at a method that returns \Symfony\Component\Routing\Route objects:

route_callbacks:
- '\Drupal\my_module\Routing\MyRoutes::routes'

To alter routes that other modules define (change a path, swap a controller, tighten a requirement), subclass \Drupal\Core\Routing\RouteSubscriberBase and edit the collection in alterRoutes(). Both run only at route rebuild time, not per request.

Autowiring fails when a constructor argument cannot be resolved from a type hint and you prefer not to use #[Autowire]. Override create() yourself, as RsvpReportController above does: ask the container for each service by ID and pass it to the constructor. Both styles are current; pick one per class, not both.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.