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).
Prerequisites
Section titled “Prerequisites”- A custom module installed on a Drupal 11 site. The first module tutorial covers scaffolding one.
- The request lifecycle explains where routing sits between the incoming request and your code.
- Services and dependency injection explains the container that controllers pull their dependencies from.
-
Declare the route in
MODULE.routing.yml, in the module’s top-level directory, with your module’s machine name in place ofMODULE. The samples on this page come from the examples project’smodule_howtos_amodule.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:pathis the URL, with placeholders in braces. Each placeholder becomes an argument to your controller method, matched by parameter name.defaultssays what to execute:_controlleras aClass::methodstring, plus either a static_titleor a_title_callback.requirementscontrols access and matching. Every route must declare an access requirement:_permissionis the most common; alternatives are_access: 'TRUE'for unrestricted pages,_entity_access(for examplenode.update), and_custom_accesspointing at a method that returns an\Drupal\Core\Access\AccessResult. A requirement keyed by a placeholder name (likedepartmentabove) is a regular expression the raw value must match; failing it produces a 404.
-
Write the controller. Extend
\Drupal\Core\Controller\ControllerBaseand 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.ControllerBaseuses\Drupal\Core\DependencyInjection\AutowireTrait, whose inheritedcreate()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(orAccessDeniedHttpException) from a controller produces the standard 404 (or 403) page. That keeps “the data does not exist” handling next to the data lookup. -
Rebuild caches so the router picks up the new file:
Terminal window drush cache:rebuildRoutes are compiled and cached; Drupal does not re-read
*.routing.ymlon every request. Repeat this after every change to the file.
Verify
Section titled “Verify”- Visit
/team/engineeringas a user with theaccess contentpermission. 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 throwsNotFoundHttpExceptionfor 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/engineeringprints the matched route name and definition.
Variations
Section titled “Variations”Entity parameters and upcasting
Section titled “Entity parameters and upcasting”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:
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:
<?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.
Title callbacks
Section titled “Title callbacks”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”).
JSON responses
Section titled “JSON responses”A controller is not required to return a render array. Return any Symfony response object and Drupal sends it as-is:
/** * 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.
Routes you compute at runtime
Section titled “Routes you compute at runtime”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.
An explicit factory instead of autowiring
Section titled “An explicit factory instead of autowiring”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.
Related
Section titled “Related”- The request lifecycle for what happens between the request and your controller.
- Build and alter forms for routes whose page is a form (
_form). - Services and dependency injection for the container behind
create()and autowiring. the Routing API topicon api.drupal.org documents every route definition key.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.