Drupal Controllers Explained: From Route to Render Arrayfor Drupal 11 , and 10

Last updated :  

You've now seen a route point at a method name (PageExampleController::simple), and a permission gate that string of code. But what actually happens inside that method? This lesson finally opens the real PHP class and shows you the code that runs when a visitor's request reaches the end of the line — the controller.

What is a controller?

A controller is simply a PHP class whose job is to handle an incoming request and hand back content. When a route matches, Drupal instantiates the controller class named in _controller and calls the specific method you pointed at. That method doesn't return raw HTML — it returns a structured PHP array called a render array, which Drupal's theme system later turns into the actual HTML page, complete with the site's navigation, blocks, and styling wrapped around it.

What you'll learn in this lesson

  • Where controller classes live and why their namespace matters
  • The simplest possible controller method, and what a render array actually is
  • How URL parameters from routing.yml arrive as typed method arguments
  • A critical security habit: never trust a URL parameter, ever

The source file

Path: modules/page_example/src/Controller/PageExampleController.php

<?php

namespace Drupal\page_example\Controller;

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Drupal\examples\Utility\DescriptionTemplateTrait;

/**
 * Controller routines for page example routes.
 */
class PageExampleController extends ControllerBase {

  use DescriptionTemplateTrait;

  /**
   * {@inheritdoc}
   */
  protected function getModuleName() {
    return 'page_example';
  }

  /**
   * Constructs a simple page.
   *
   * The router _controller callback, maps the path
   * 'examples/page-example/simple' to this method.
   *
   * _controller callbacks return a renderable array for the content area of the
   * page. The theme system will later render and surround the content with the
   * appropriate blocks, navigation, and styling.
   */
  public function simple() {
    return [
      '#markup' => '<p>' . $this->t('Simple page: The quick brown fox jumps over the lazy dog.') . '</p>',
    ];
  }

  /**
   * A more complex _controller callback that takes arguments.
   *
   * This callback is mapped to the path
   * 'examples/page-example/arguments/{first}/{second}'.
   *
   * The arguments in brackets are passed to this callback from the page URL.
   * The placeholder names "first" and "second" can have any value but should
   * match the callback method variable names; i.e. $first and $second.
   *
   * This function also demonstrates a more complex render array in the returned
   * values. Instead of rendering the HTML with theme('item_list'), content is
   * left un-rendered, and the theme function name is set using #theme. This
   * content will now be rendered as late as possible, giving more parts of the
   * system a chance to change it if necessary.
   *
   * Consult @link http://drupal.org/node/930760 Render Arrays documentation
   * @endlink for details.
   *
   * @param string $first
   *   A string to use, should be a number.
   * @param string $second
   *   Another string to use, should be a number.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
   *   If the parameters are invalid.
   */
  public function arguments($first, $second) {
    // Make sure you don't trust the URL to be safe! Always check for exploits.
    if (!is_numeric($first) || !is_numeric($second)) {
      // We will just show a standard "access denied" page in this case.
      throw new AccessDeniedHttpException();
    }

    $list[] = $this->t("First number was @number.", ['@number' => $first]);
    $list[] = $this->t("Second number was @number.", ['@number' => $second]);
    $list[] = $this->t('The total was @number.', ['@number' => $first + $second]);

    $render_array['page_example_arguments'] = [
      // The theme function to apply to the #items.
      '#theme' => 'item_list',
      // The list itself.
      '#items' => $list,
      '#title' => $this->t('Argument Information'),
    ];
    return $render_array;
  }

}

How it works

Namespace and file location

namespace Drupal\page_example\Controller;

Drupal follows the PSR-4 autoloading standard, which means the namespace directly encodes where PHP will find this file on disk. The pattern is always Drupal\<module_name>\Controller, and this specific class lives at modules/page_example/src/Controller/PageExampleController.php. The src/Controller/ folder is the conventional home for every controller class in a Drupal module — put yours there too and the autoloader will find it without any extra configuration.

The three imports

At the top of the file, three classes are pulled in with use statements:

  • ControllerBase — the standard base class nearly every Drupal controller extends. It hands you helper methods like $this->t() for translation, $this->currentUser(), and $this->entityTypeManager(), all wired up automatically.
  • AccessDeniedHttpException — a Symfony exception class. Throwing it from anywhere in a controller tells Drupal "stop, return HTTP 403 Access Denied" — you'll see it used below.
  • DescriptionTemplateTrait — specific to the Examples project itself, not something you'd reuse in your own modules. Skip ahead to the next paragraph for what it does.

Extending ControllerBase

class PageExampleController extends ControllerBase {

You're not strictly required to extend ControllerBase — Drupal only cares that your class has the method named in _controller — but nearly everyone does, because ControllerBase implements ContainerInjectionInterface. In plain English: it lets Drupal's dependency injection container instantiate your controller and hand it whatever services it needs, and it bundles a large set of pre-wired convenience methods that save you from writing the same boilerplate in every controller you ever build.

The DescriptionTemplateTrait aside

use DescriptionTemplateTrait;

protected function getModuleName() {
  return 'page_example';
}

A PHP trait is a way to reuse a chunk of behavior across unrelated classes without inheritance. Here, DescriptionTemplateTrait — shared by every module in the Examples project — adds a ready-made description() method that renders a standard "about this module" overview page from a Twig template, using whatever module name getModuleName() returns. This is Examples-project-specific plumbing; you won't reach for this particular trait in your own modules, but it's a good first look at how traits let unrelated classes share behavior.

simple() — the minimal controller

public function simple() {
  return [
    '#markup' => '<p>' . $this->t('Simple page: The quick brown fox jumps over the lazy dog.') . '</p>',
  ];
}

This is about as small as a controller method can get, and it's worth studying closely because you'll write dozens of methods like it. Two things stand out:

  • #markup is a render array property that outputs a raw HTML string. Drupal automatically runs it through a filter (Xss::filterAdmin()) that strips dangerous tags, so it's safe for content you as the developer control.
  • $this->t() is the translation helper ControllerBase gives you. It doesn't just return a plain string — it wraps it in a TranslatableMarkup object so Drupal's localization system can swap in a translated version for any language the site supports. Every user-visible string in a controller should pass through $this->t(), even if your site only ever runs in one language today.

One more thing that isn't obvious from reading the code: this array is not rendered to HTML the moment the method returns. Drupal collects it, runs it through the render pipeline — gathering cache metadata, running alter hooks, executing theme functions — and only converts it to a final HTML string right before sending the response. That deferred rendering is exactly what makes Drupal's caching system possible; you'll dig into it properly much later in this course.

arguments($first, $second) — parameters and validation

public function arguments($first, $second) {

Remember the {first} and {second} placeholders from page_example.arguments in the routing lesson? This is where they land. Drupal's router extracts whatever the visitor put in those URL segments and passes them straight in as method arguments — the parameter names here ($first, $second) have to match the placeholder names in the route's path exactly, or Drupal won't know how to wire them together.

Never trust a URL parameter

if (!is_numeric($first) || !is_numeric($second)) {
  throw new AccessDeniedHttpException();
}

This is one of the most important habits to build early. A URL parameter can be anything — a visitor (or an automated attacker) can type whatever they want into that segment of the URL, regardless of what the route's name or your intentions imply. This controller validates both values with PHP's is_numeric() before doing anything with them, and immediately throws AccessDeniedHttpException if either one fails — which causes Drupal to stop processing and return a clean HTTP 403 response, with no further code executing.

Rule of thumb: treat every value that came from the URL, a form submission, or any other user-controlled input as untrusted until you've explicitly validated it. This one if statement is a small example of a habit that prevents a large share of real-world security bugs.

Building translatable strings with placeholders

$list[] = $this->t("First number was @number.", ['@number' => $first]);
$list[] = $this->t("Second number was @number.", ['@number' => $second]);
$list[] = $this->t('The total was @number.', ['@number' => $first + $second]);

Each line uses $this->t() again, but this time with a second argument: an array mapping a replacement token (@number) to the actual value. The @ prefix specifically tells Drupal to HTML-escape whatever gets substituted in, which blocks a class of cross-site-scripting (XSS) attacks where a malicious URL value could otherwise inject its own HTML or JavaScript into the page. Two other prefixes exist for different situations: %variable (escapes the value and wraps it in <em> emphasis tags) and :variable (used specifically for URLs, applying URL-appropriate escaping). Get in the habit of reaching for a token instead of string concatenation any time you're inserting a variable into a translatable string.

Returning a themed list instead of raw markup

$render_array['page_example_arguments'] = [
  '#theme' => 'item_list',
  '#items' => $list,
  '#title' => $this->t('Argument Information'),
];
return $render_array;

Instead of hand-building an HTML <ul> string, this render array uses #theme to delegate the actual HTML-building to a named theme function — item_list is a theme hook built into Drupal core specifically for turning an array of items into a proper HTML list. #items supplies the list entries, and #title becomes the heading above them.

Reaching for #theme instead of raw #markup for anything beyond the most trivial output is the preferred Drupal pattern, for three concrete reasons: it separates your logic from presentation (a theme can override how item_list renders without touching your PHP), rendering stays deferred so cache metadata can still be attached, and the output stays friendly to other modules' alter hooks. You'll see #theme and render arrays in much greater depth later in this course.

See it for yourself

Visit /examples/page-example/simple on your own DDEV site — that's the simple() method's output, word for word. Then try /examples/page-example/arguments/5/10 to see arguments() in action: it should report "First number was 5," "Second number was 10," and "The total was 15." Try changing those numbers in the URL to something non-numeric, like abc, and you should get an access-denied page instead — that's the validation check doing its job.

The Simple - no arguments page rendered by PageExampleController::simple() The Argument Information page rendered by arguments(5, 10), reading First number was 5, Second number was 10, The total was 15

That's arguments(5, 10), reached via /examples/page-example/arguments/5/10 — the two path segments become $first and $second, validated as numeric, then handed straight into the #theme => 'item_list' render array from the section above. Try replacing either number in the URL with something non-numeric, like abc, and you'll get an access-denied page instead, proof the validation check in the method's first few lines is actually enforced.

Quick check: why does arguments() throw AccessDeniedHttpException instead of, say, just returning an empty page when the input is invalid? (Answer: it's a deliberate, honest HTTP response. A 403 tells the browser and any automated tooling exactly what happened — access was denied — rather than silently returning something that looks like success but isn't.)

Key takeaways

  • A controller is a PHP class in src/Controller/ that extends ControllerBase; any public method can be a route's _controller callback.
  • Controller methods return a render array, never raw HTML directly. #markup works for simple inline HTML; #theme delegates to a named theme hook for structured output.
  • Wrap every user-visible string in $this->t(), and use token prefixes (@variable, %variable, :variable) rather than string concatenation whenever you insert a dynamic value — this keeps output both translatable and safely escaped.
  • {placeholder} segments from routing.yml arrive as method arguments with matching names — get the names wrong and Drupal can't wire them together.
  • Never trust a URL parameter. Validate everything before using it, and throw the appropriate HTTP exception (like AccessDeniedHttpException) when input is invalid.
  • #theme is generally preferable to raw #markup for structured content — it keeps logic and presentation separate and stays friendly to caching and alter hooks.

Coming up next

Routes, permissions, and controllers are now in place — but nobody's going to find these pages unless there's a link to click. Next up: page_example.links.menu.yml, and how a route turns into an actual, clickable entry in Drupal's admin Tools menu.