Creating a Controller and Routing in Drupal Module Developmentfor Drupal 8 , 9 , 10 , and 11

Last updated :  

In the first lesson you built a working page without stopping to ask why any of it worked. Now we go back and take the same two files — HelloWorldPage.php and hello_world.routing.yml — apart line by line. Think of routing as the receptionist and the controller as the specialist she transfers you to: the receptionist's only job is matching your request to the right person, and the specialist's only job is actually handling it. Neither one does the other's work.

What you'll learn in this lesson

  • What every line of a minimal controller class is actually doing
  • Why __invoke() lets a whole class be used as if it were a single function
  • What each key in a route definition controls, including access

The controller, line by line

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Controller;

use Drupal\Core\Controller\ControllerBase;

/**
 * Returns responses for Hello World routes.
 */
final class HelloWorldPage extends ControllerBase {

  /**
   * Builds the response.
   */
  public function __invoke(): array {

    $build['content'] = [
      '#type' => 'item',
      '#markup' => $this->t('It works!'),
    ];

    return $build;
  }

}

declare(strict_types=1);

Turns on PHP's strict type checking for this file — a function typed to expect an int will now throw immediately if you pass it a string, instead of silently coercing it. Cheap to add, and it catches real bugs early; there's very little reason to leave it out of a new file.

The namespace and PSR-4

namespace Drupal\hello_world\Controller;

This namespace isn't decorative — it has to match the file's location on disk (src/Controller/ inside the module) exactly, following the PSR-4 autoloading standard. Get the namespace and the folder path out of sync, and Drupal's autoloader simply can't find your class; you'll see a "class not found" error that has nothing obviously to do with a typo in a namespace string.

Extending ControllerBase

use Drupal\Core\Controller\ControllerBase;
...
final class HelloWorldPage extends ControllerBase {

ControllerBase is what hands you $this->t() for translation and a handful of other commonly-needed services, without you having to wire them up by hand. The final keyword simply says "nobody should extend this class further" — a reasonable default for a small, single-purpose controller like this one.

__invoke() — why this method name specifically

public function __invoke(): array {
  $build['content'] = [
    '#type' => 'item',
    '#markup' => $this->t('It works!'),
  ];
  return $build;
}

__invoke() is a PHP magic method that lets an object be called as if it were a plain function. Drupal takes advantage of this: when a route's _controller points at a class with no method name specified, Drupal calls that class as a function, which triggers __invoke(). It's a convention worth using whenever a controller genuinely does only one thing — for anything with multiple related pages, you'd use named methods instead (_controller: '...::pageOne').

The return value — a render array — is the real point of this whole class. '#type' => 'item' picks a simple built-in render element, and '#markup' => $this->t('It works!') supplies its content, wrapped in t() so it's translatable. Returning this array rather than an HTML string is what lets Drupal apply theming, caching, and other modules' alter hooks before anything reaches the browser.

The routing file, line by line

hello_world.hello_world_page:
  path: '/hello'
  defaults:
    _title: 'Hello World Page'
    _controller: '\Drupal\hello_world\Controller\HelloWorldPage'
  requirements:
    _permission: 'access content'

The route name

hello_world.hello_world_page is an internal identifier, never shown to visitors — but it needs to be unique site-wide, which is exactly why the convention is <module_name>.<route_id>. Skip the module-name prefix and you risk a silent collision with some other module's route of the same name.

path

The actual public URL — /hello here. It must start with a slash, and it's worth avoiding paths that collide with Drupal's own reserved prefixes like /admin or /user.

defaults

_title sets the browser tab / page heading text. _controller points Drupal at the exact class to run — note the full, backslash-prefixed namespace. Because this controller uses __invoke(), no ::methodName suffix is needed; Drupal calls the class directly.

requirements — this is where access control lives

_permission: 'access content' is Drupal's most permissive real permission — effectively "any visitor, logged in or not." Swap in a stricter permission (or a custom one you define yourself) any time a page shouldn't be wide open, and Drupal enforces it before your controller code ever runs.

hello world drupal module
A mismatch here fails loudly, at least: if the class name in _controller doesn't exactly match the real class (including namespace), you'll get a clear "controller not found" error rather than a silent failure — annoying, but at least it tells you where to look.

Quick check: if you changed _permission: 'access content' to _permission: 'administer site configuration', what would an anonymous visitor to /hello see? (Answer: an access-denied page — the route's requirements are checked before the controller ever runs, so the page content itself is irrelevant if the visitor doesn't hold the required permission.)

Key takeaways

  • Routing and controllers have strictly separate jobs: the route decides if and how a request is handled; the controller decides what gets returned.
  • __invoke() lets Drupal call a controller class directly as a function — reach for it for single-purpose controllers, named methods for anything with multiple pages.
  • A route's requirements key is checked before your controller code ever executes — access control belongs there, not inside the controller logic.
  • The namespace in a controller class must exactly mirror its file path under src/, or Drupal's autoloader can't find it.

Coming up next

You've now seen a complete route end to end. Next, in Creating a Routing File, we zoom out from this one example and cover the full range of what a .routing.yml file can do — beyond the single static path you've seen so far.