Skip to content

TutorialFor module developersDrupal 11

Build your first module 2: routes, controllers, and DI

A module becomes visible the moment it answers a URL. In this part you declare the module’s routes, build the controller behind the administrator-facing RSVP report, and meet dependency injection the way core itself uses it: a promoted constructor plus a static create() factory.

Part 1 left you with an installed, empty module. This part adds event_rsvp.routing.yml, declaring all three of the module’s routes, and RsvpReportController, which renders the report page at /admin/reports/event-rsvp/{node}. The controller depends on a storage service that does not exist until part 3, and the final step turns that into a feature: you trigger the container error on purpose and learn to read it.

  • The event_rsvp module from part 1, installed and enabled.
  • You are logged in as the administrator account.

A route maps a URL path to the code that responds to it, plus the conditions under which responding is allowed. Routes live in <module>.routing.yml. Create web/modules/custom/event_rsvp/event_rsvp.routing.yml with the module’s three routes:

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'

You write this file once and it carries the whole series: event_rsvp.rsvp_form (the visitor-facing form) comes alive in part 3, and event_rsvp.settings (the admin form) in part 4. Declaring a route whose class does not exist yet is harmless; Drupal only loads the class when someone visits the path. This part focuses on event_rsvp.report, key by key:

  • path contains a parameter, {node}. Whatever appears in that URL slot becomes available to your code by the name node.
  • defaults describes what to execute. _controller points at a method, as Class::method. _title_callback names a second method that returns the page title; you need a callback rather than a static _title because the title contains the event’s name.
  • requirements describes who gets in and what the parameter may look like. _permission: 'view event rsvp reports' makes the router check a permission (you define it in part 6; see the note below). _entity_access: 'node.view' runs the node’s own view access check, so nobody reaches the report of an event they cannot see, such as an unpublished draft. node: \d+ is a regex constraint on the raw parameter, so /admin/reports/event-rsvp/banana is a 404 without your code ever running.
  • options.parameters.node.type: 'entity:node' enables parameter upcasting: before calling your controller, Drupal’s \Drupal\Core\ParamConverter\EntityConverter loads the full node entity for the ID in the URL. Your controller receives a NodeInterface object, never a bare string. If no node with that ID exists, the converter fails and the response is a 404.

The form route carries the same _entity_access check. Its _event_rsvp_enabled: 'TRUE' requirement is a custom access check. It stays inert until part 6, where you register the service that handles it; until then, the router ignores requirements no registered check claims.

Routes are compiled and cached, so every edit to a routing file needs a cache rebuild to take effect:

Terminal window
ddev drush cache:rebuild

The route’s _controller names Drupal\event_rsvp\Controller\RsvpReportController::report, which by PSR-4 means the file src/Controller/RsvpReportController.php. Create it:

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()]);
}
}

Work through it from the top:

  • The class extends \Drupal\Core\Controller\ControllerBase, which supplies conveniences used here: $this->t() for translatable strings and the base plumbing for injection.
  • The constructor uses property promotion: protected readonly RsvpStorage $rsvpStorage declares the property, types it, and assigns it in one place. The controller states what it needs as constructor parameters and never reaches out to fetch anything itself.
  • create() is the other half of the contract. Drupal instantiates routed controllers through this static factory, handing it the service container. The method’s one job is to pull the right services out of the container and pass them to the constructor. This indirection is what makes the class testable: a test can call the constructor directly with fakes, no container required. It is a shape you will see throughout core and contributed modules, and the services and dependency injection concept page explains the machinery behind it. (ControllerBase can also generate create() for you through autowiring, the shorter form Routes and controllers uses; you write it by hand here once so the mechanics are visible.)
  • report() receives the upcast $node and returns a render array: a structured description of the page that Drupal turns into HTML. #type => 'table' with #header, #rows, and #empty renders the report table. The #cache tags tell Drupal when cached copies of this page become stale; they reference the storage service’s cache tag, and part 5 is dedicated to how that works.
  • reportTitle() is the _title_callback from the route. It also receives the upcast node, and builds the title with the @title placeholder rather than string concatenation, so the value is escaped and the string stays translatable.

The one thing this class cannot survive without is event_rsvp.storage, the service create() asks for. You build it in part 3.

The report route needs a node ID, so give the site its first event. This series uses the Article content type for events. The Standard profile ships no content types, so add Article from the recipe core includes for it:

Terminal window
ddev php web/core/scripts/dr recipe:apply web/core/tests/fixtures/recipes/article_content_type

If /node/add/article already works, your site has the type; skip the command.

Go to Administer → Content → Add content → Article (/node/add/article), enter DrupalCon in the Title field, and select Save. Part 4 adds a setting for which content types accept RSVPs; Article is the default.

Note the node ID in the URL you land on. On a fresh site it is /node/1, and the rest of the series assumes node 1; adjust if yours differs.

Visit /admin/reports/event-rsvp/1. You get Drupal’s error page: “The website encountered an unexpected error.”

This is the expected end state of this part, and it deserves a minute of attention, because container errors are a daily sight in module development. Look at the log at Administer → Reports → Recent log messages/admin/reports/dblog, or run ddev drush watchdog:show --count=3. The top entry is a ServiceNotFoundException: the container has no service named event_rsvp.storage.

Read it as a to-do list written by your own create() method. The controller declared a dependency; the container cannot satisfy it; nothing renders until it can. Nothing else on the site is affected: the module stays healthy, and only this one new page errors. Part 3 builds the RsvpStorage service, registers it under exactly that name, and this page comes to life.

Two access notes before you move on, since this page loaded further than it strictly should have:

  • The route requires the permission view event rsvp reports, which is not defined anywhere yet. Your administrator account passes anyway: user 1 and the Administrator role pass every permission check, defined or not. Part 6 defines the module’s permissions for everyone else.
  • If you had visited as an anonymous user, you would have gotten a 403 before the controller ever ran. Access runs first; controllers run last. The request lifecycle concept page walks through the full order.

Drupal has not picked up the new routing file. Run ddev drush cache:rebuild and try again. If it still 404s, check the routing file’s name (event_rsvp.routing.yml, next to the info file) and confirm the path you typed includes a numeric node ID.

You are not logged in as an administrator. Log in with the account from installation, or run ddev drush user:login to get a one-time login link for user 1.

The error page mentions a class, not a service

Section titled “The error page mentions a class, not a service”

InvalidArgumentException ... Controller class does not exist or a PHP “class not found” error means the controller file is misplaced or misnamed. The class Drupal\event_rsvp\Controller\RsvpReportController must live at src/Controller/RsvpReportController.php, with namespace Drupal\event_rsvp\Controller; inside, and the class name must match the file name exactly.

The --name value must match exactly: event_rsvp.report. Run ddev drush core:route | grep event_rsvp to list what was actually registered.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.