Routing and controllers in Drupal 8

The routing system of Drupal 8 is a complete rewrite of Drupal 7 and its previous versions' hook_menu. A Drupal route is a URL path with a specific return content. This return content is usually specified as a method of a controller class which returns a render array.

The routing system is powered by Symfony's HTTP Kernel component, which we will revisit later. It is not necessary to understand this in order to work with routes.

Let's dive straight away and create a new route.

First, we shall create a module to hold all the code in this chapter. You can checkout the code:

$ git clone git@github.com:drupal8book/myroute.git
$ cd myroute
$ git checkout -f simple-route

or walk along with me by typing the code or using drupalconsole to generate it.

$ drupal generate:module

 Enter the new module name:
 > myroute

 Enter the module machine name [myroute]:
 > 

 Enter the module Path [/modules/custom]:
 > 

 Enter module description [My Awesome Module]:
 > Routing and controllers

 Enter package name [Custom]:
 > D8MD

 Enter Drupal Core version [8.x]:
 > 

 Do you want to generate a .module file (yes/no) [yes]:
 > no 

 Define module as feature (yes/no) [no]:
 > 

 Do you want to add a composer.json file to your module (yes/no) [yes]:
 > no

 Would you like to add module dependencies (yes/no) [no]:
 > 


 Do you confirm generation? (yes/no) [yes]:
 > 

Generated or updated files
 Site path: /var/www/html
 1 - modules/custom/myroute/myroute.info.yml

Let's write a simple controller which prints "hello world" when we hit the path /hello.

$ drupal generate:controller

 Enter the module name [email_management]:
 > myroute

 Enter the Controller class name [DefaultController]:
 > HelloWorldController

 Enter the Controller method title (to stop adding more methods, leave this empty) [ ]:
 > Hello World

 Enter the action method name [hello]:
 > 

 Enter the route path [/myroute/hello/{name}]:
 > hello

 Enter the Controller method title (to stop adding more methods, leave this empty) [ ]:
 > 

 Do you want to generate a unit test class (yes/no) [yes]:
 > no


 Do you want to load services from the container (yes/no) [no]:
 > no


 Do you confirm generation? (yes/no) [yes]:
 > 

Generated or updated files
 Site path: /var/www/html
 1 - modules/custom/myroute/src/Controller/HelloWorldController.php
 2 - modules/custom/myroute/myroute.routing.yml
 // router:rebuild

 Rebuilding routes, wait a moment please


 [OK] Done rebuilding route(s).

Make sure you enable the module.

$ drush en myroute -y

Open modules/custom/myroute/src/Controller/HelloWorldController.php and change the markup text to "Hello World".

public function hello() {
  return [
    '#type' => 'markup',
    '#markup' => $this->t('Hello World')
  ];
}

you might need to rebuild cache.

$ drush cr

Hit /hello.

The equivalent Drupal 7 code for this would be something on the lines of

// inside myroute.module...

function myroute_menu() {
  $items = array();
  $items['main'] = array(
    'title' => Hello World',
    'page callback' => myroute_hello',
    'access arguments' => array('access content'),
    'type' => MENU_NORMAL_ITEM,
    'file' => 'myroute.pages.inc'
  );
  return $items;
}

// inside myroute.pages.inc 

function myroute_hello() {
  return t(‘Hello World’);
}

Routes with parameters

This is great, but how to add URL parameters?

Let's add a new route with a URL parameter.

$ cd myroute
$ git checkout -f route-with-params

or if you choose to use drupal console,

drupal generate:controller
 Enter the module name [email_management]:
 > myroute

 Enter the Controller class name [DefaultController]:
 > GreetingController

 Enter the Controller method title (to stop adding more methods, leave this empty) [ ]:
 > Greeting

 Enter the action method name [hello]:
 > greeting

 Enter the route path [/myroute/hello/{name}]:
 > hello/{name}

 Enter the Controller method title (to stop adding more methods, leave this empty) [ ]:
 > 

 Do you want to generate a unit test class (yes/no) [yes]:
 > no


 Do you want to load services from the container (yes/no) [no]:
 > 


 Do you confirm generation? (yes/no) [yes]:
 > 

Generated or updated files
 Site path: /var/www/html
 1 - modules/custom/myroute/src/Controller/GreetingController.php
 2 - modules/custom/myroute/myroute.routing.yml
 // router:rebuild

 Rebuilding routes, wait a moment please


 [OK] Done rebuilding route(s).

Edit the greeting controller function to print the name.

public function greeting($name) {
  return [
    '#type' => 'markup',
    '#markup' => $this->t('Hello, @name!', ['@name' => $name]),
  ];
}

Try /hello/Joe.

/hello/123-456 works too. Let's tone it down a little and add a validation criteria for the parameter.

Names can only have alphabets and spaces.

myroute.greeting_controller_greeting:
  path: 'hello/{name}'
  defaults:
    _controller: '\Drupal\myroute\Controller\GreetingController::greeting'
    _title: 'Greeting'
  requirements:
    _permission: 'access content'
    name: '[a-zA-z ]+'

Rebuild the cache for this to take effect.

$ drush cr

/hello/123-456 will now give a 404. Try /hello/Joe%20Pesci.

Custom permissions

How about adding custom permissions? What if we want to show the /hello page only to users who can administer content.

$ cd myroute
$ git checkout -f custom-access-check

We first indicate that the route uses custom permissions.

myroute.hello_world_controller_hello:
  path: 'hello'
  defaults:
    _controller: '\Drupal\myroute\Controller\HelloWorldController::hello'
    _title: 'Hello World'
  requirements:
    _custom_access: '\Drupal\myroute\Controller\HelloWorldController::custom_access_check'

Note that the _permission parameter has been replaced by _custom_access parameter.

Lets implement this custom access method inside the HelloWorldController.

// add the required namespaces at the top.
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;

  /**
   * Custom access check
   *
   * @param \Drupal\Core\Session\AccountInterface $account
   *   access checking done against this account.
   */
  public function custom_access_check(AccountInterface $account) {
    return AccessResult::allowedIf($account->hasPermission('access content') &&
      $account->hasPermission('administer content'));
  }

Rebuild cache and try hitting /hello as an anon user, you should get an "Access denied" page. Of note here is the AccessResult class, which was introduced to make access check related data cacheable.

Dynamic routes

Defining routes via a YAML file applies to static paths. If we want to route programmatically, we have to define and return a \Symfony\Component\Routing\Route object.

$ cd myroute
$ git checkout -f dynamic-routes

To illustrate the concept of dynamic routes, let's take an imaginary requirement where we have a custom path, /content_types/{content_type}, which will display all the fields of a content type {content_type}. In order to create a dynamic route for the same, we have to create our own Routing class inside the src/Routing directory and override the routes() method. This is equivalent to its YAML cousin, the myroute.routing.yml file, but written in PHP.

<?php

/**
 * @file
 * Contains \Drupal\myroute\Routing\CTRoutes.
 */

namespace Drupal\myroute\Routing;

use Symfony\Component\Routing\Route;

/**
 * Dynamic routes for content types.
 */
class CTRoutes {

  /**
   * {@inheritdoc}
   */
  public function routes() {
    $routes = [];
    $content_types = \Drupal::service('entity.manager')->getStorage('node_type')->loadMultiple();
    foreach ($content_types as $content_type) {
      $routes['myroute.content_type_controller.' . $content_type->id() ] = new Route(
        '/content_types/' . $content_type->id(),
        array(
          '_controller' => '\Drupal\myroute\Controller\CTController::fields',
          '_title' => 'Field info for ' . $content_type->label(),
          'content_type' => $content_type->id(),
        ),
        array(
          '_permission'  => 'access content',
        )
      );
    }
    return $routes;
  }
}

The custom router above creates an array of routes for every content type in the system and returns it. For instance, if there are 3 content types in the system, like page, foo and bar, there will be 3 routes with paths /content_types/page, /content_types/foo and /content_types/bar respectively.

Let's flesh out the CTController as well.

<?php

namespace Drupal\myroute\Controller;

use Drupal\Core\Controller\ControllerBase;

/**
 * Class CTController.
 *
 * @package Drupal\myroute\Controller
 */
class CTController extends ControllerBase {

  /**
   * List fields info of a content type.
   *
   * @return string
   *   Return field list.
   */
  public function fields($content_type) {
    $render = '<table><tr><th>' . $this->t('Field type') . '</th><th>' . $this->t('Label') . '</th></tr>';
    $field_definitions = \Drupal::entityManager()->getFieldDefinitions('node', $content_type);
    foreach ($field_definitions as $field_name => $field_definition) {
      if (!empty($field_definition->getTargetBundle())) {
        $render .= '<tr><td>' . $field_definition->getType() . '</td><td>' . $field_definition->getLabel() . '</td></tr>';
      }
    }
    $render .= '</table>';
    return [
      '#type' => 'markup',
      '#markup' => $render,
    ];
  }
}

The field() method looks up the field definitions of the contenttype argument and renders it as a HTML table.

Finally, we have to indicate Drupal to pick up the custom Routing class we've added. This can be done by adding a _route_callbacks item to the routing YAML file.

route_callbacks:
  - '\Drupal\myroute\Routing\CTRoutes::routes'

Rebuild the cache and you are all set. Hit the content_types/article page, you should see something like this(provided you have the article CT in your system).

Article CT listing