What Happened to Hook_Menu in Drupal 8?

In Drupal 7 and earlier versions hook_menu has been the Swiss Army knife of hooks. But it's been removed in Drupal 8. So where did all this functionality go?

In Drupal 7 and earlier versions hook_menu has been the Swiss Army knife of hooks. It does a little bit of everything: page paths, menu callbacks, tabs and local tasks, contextual links, access control, arguments and parameters, form callbacks, and on top of all that it even sets up menu items! In my book it’s probably the most-used hook of all. I don’t know if I’ve ever written a module that didn’t implement hook_menu.

But things have changed In Drupal 8. Hook_menu is gone and now all these tasks are managed separately using a system of YAML files that provide metadata about each item and corresponding PHP classes that provide the underlying logic.

The new system makes lots of sense, but figuring out how to make the switch can be confusing. To make things worse, the API has changed a few times over the long cycle of Drupal 8 development, so there is documentation out in the wild that is now incorrect. This article explains how things work now, and it shouldn't change any more.

I’m going to list some of the situations I ran into while porting a custom module to Drupal 8 and show before and after code examples of what happened to my old hook_menu items.

Custom Pages

One of the simplest uses of hook_menu is to set up a custom page at a given path. You'd use this for a classic "Hello World" module. In Drupal 8, paths are managed using a MODULE.routing.yml file to describe each path (or ‘route’) and a corresponding controller class that extends a base controller, which contains the logic of what happens on that path. Each controller class lives in its own file, where the file is named to match the class name. This controller logic might have lived in a separate MODULE.pages.inc file in Drupal 7.

In Drupal 7 the code might look like this:


function example_menu() {
  $items = array();
  $items['main'] = array(
    'title' => 'Main Page',
    'page callback' => example_main_page',
    'access arguments' => array('access content'),
    'type' => MENU_NORMAL_ITEM,
    'file' => 'MODULE.pages.inc'
  );
  return $items;
}

function example_main_page() {
  return t(‘Something goes here’);
}

In Drupal 8 we put the route information into a file called MODULE.routing.yml. Routes have names that don’t necessary have anything to do with their paths. They are just unique identifiers. They should be prefixed with your module name to avoid name clashes. You may see documentation that talks about using _content or _form instead of _controller in this YAML file, but that was later changed. You should now always use _controller to identify the related controller.


example.main_page_controller:
  path: '/main'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
  requirements:
    _permission: 'access content'


Note that we now use a preceding slash on paths! In Drupal 7 the path would have been main, and in Drupal 8 it is /main! I keep forgetting that and it is a common source of problems as I make the transition. It’s the first thing to check if your new code isn’t working!

The page callback goes into a controller class. In this example the controller class is named MainPageController.php, and is located at MODULE/src/Controller/MainPageController.php. The file name should match the class name of the controller, and all your module’s controllers should be in that /src/Controller directory. That location is dictated by the PSR-4 standard that Drupal has adopted. Basically, anything that is located in the expected place in the ‘/src’ directory will be autoloaded when needed without using module_load_include() or listing file locations in the .info file, as we had to do in Drupal 7.

The method used inside the controller to manage this route can have any name, mainPage is an arbitrary choice for the method in this example. The method used in the controller file should match the YAML file, where it is described as CLASS_NAME::METHOD. Note that the Contains line in the class @file documentation matches the _controller entry in the YAML file above.

A controller can manage one or more routes, as long as each has a method for its callback and its own entry in the YAML file. For instance, the core nodeController manages four of the routes listed in node.routing.yml.

The controller should always return a render array, not text or HTML, another change from Drupal 7.

Translation is available within the controller as $this->t() instead of t(). This works because ControllerBase has added the StringTranslationTrait. There's a good article about how PHP Traits like translation work in Drupal 8 on Drupalize.Me.


/**
 * @file
 * Contains \Drupal\example\Controller\MainPageController.
 */
namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;

class MainPageController extends ControllerBase {
  public function mainPage() {
    return [
        '#markup' => $this->t('Something goes here!'),
    ];
  }


Paths With Arguments

Some paths need additional arguments or parameters. If my page had a couple extra parameters it would look like this in Drupal 7:


function example_menu() {
  $items = array();
  $items[‘main/%/%’] = array(
    'title' => 'Main Page',
    'page callback' => 'example_main_page',
    ‘page arguments’ => array(1, 2),
    'access arguments' => array('access content'),
    'type' => MENU_NORMAL_ITEM,
  );
  return $items;
}

function example_main_page($first, $second) {
  return t(‘Something goes here’);
}

In Drupal 8 the YAML file would be adjusted to look like this (adding the parameters to the path):


example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page’
  requirements:
    _permission: 'access content'

The controller then looks like this (showing the parameters in the function signature)::


/**
 * @file
 * Contains \Drupal\example\Controller\MainPageController.
 */
namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;

class MainPageController extends ControllerBase {
  public function mainPage($first, $second) {
    // Do something with $first and $second.
    return [
        '#markup => $this->t('Something goes here!'),
    ];
  }
}

Obviously anything in the path could be altered by a user so you’ll want to test for valid values and otherwise ensure that these values are safe to use. I can’t tell if the system does any sanitization of these values or if this is a straight pass-through of whatever is in the url, so I’d probably assume that I need to type hint and sanitize these values as necessary for my code to work.

Paths With Optional Arguments

The above code will work correctly only for that specific path, with both parameters. Neither the path /main, nor /main/first will work, only /main/first/second. If you want the parameters to be optional, so /main, /main/first, and /main/first/second are all valid paths, you need to make some changes to the YAML file.

By adding the arguments to the defaults section you are telling the controller to treat the base path as the main route and the two additional parameters as path alternatives. You are also setting the default value for the parameters. The empty value says they are optional, or you could give them a fixed default value to be used if they are not present in the url.


example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
    first: ''
    second: ''
  requirements:
    _permission: 'access content'

Restricting Parameters

Once you set up parameters you probably should also provide information about what values will be allowed for them. You can do this by adding some more information to the YAML file. The example below indicates that $first can only contain the values ‘Y’ or ‘N’, and $second must be a number. Any parameters that don’t match these rules will return a 404. Basically the code is expecting to evaluate a regular expression to determine if the path is valid.

See Symfony documentation for lots more information about configuring routes and route requirements.


example.main_page_controller:
  path: '/main/{first}/{second}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Main Page'
    first: ''
    second: ''
  requirements:
    _permission: 'access content'
    first: Y|N
    second: \d+

Entity Parameters

As in Drupal 7, when creating a route that has an entity id you can set it up so the system will automatically pass the entity object to the callback instead of just the id. This is called ‘upcasting’. In Drupal 7 we did this by using %node instead of %. In Drupal 8 you just need to use the name of the entity type as the parameter name, for instance {node} or {user}.


example.main_page_controller:
  path: '/node/{node}'
  defaults:
    _controller: '\Drupal\example\Controller\MainPageController::mainPage'
    _title: 'Node Page'
  requirements:
    _permission: 'access content'

This upcasting only happens if you have type-hinted the entity object in your controller parameter. Otherwise it will simply be the value of the parameter.

JSON Callbacks

All the above code will create HTML at the specified path. Your render array will be converted to HTML automatically by the system. But what if you wanted that path to display JSON instead? I had trouble finding any documentation about how to do that. There is some old documentation that indicates you need to add _format: json to the YAML file in the requirements section, but that is not required unless you want to provide alternate formats at the same path.

Create the array of values you want to return and then return it as a JsonResponse object. Be sure to add ”use Symfony\Component\HttpFoundation\JsonResponse” at the top of your class so it will be available.


/**
 * @file
 * Contains \Drupal\example\Controller\MainPageController.
 */
namespace Drupal\example\Controller;

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\JsonResponse;

class MainPageController extends ControllerBase {
  public function mainPage() {
    $return = array();
    // Create key/value array.
    return new JsonResponse($return);
  }
}

Access Control

Hook_menu() also manages access control. Access control is now handled by the MODULE.routing.yml file. There are various ways to control access:

Allow access by anyone to this path:


example.main_page_controller:
  path: '/main'
  requirements:
    _access: 'TRUE'

Limit access to users with ‘access content’ permission:


example.main_page_controller:
  path: '/main'
  requirements:
    _permission: 'access content'

Limit access to users with the ‘admin’ role:


example.main_page_controller:
  path: '/main'
  requirements:
    _role: 'admin'

Limit access to users who have ‘edit’ permission on an entity (when the entity is provided in the path):


example.main_page_controller:
  path: '/node/{node}'
  requirements:
    _entity_access: 'node.edit'

See Drupal.org documentation for more details about setting up access control in your MODULE.routing.yml file.

Hook_Menu_Alter

So what if a route already exists (created by core or some other module) and you want to alter something about it? In Drupal 7 that is done with hook_menu_alter, but that hook is also removed in Drupal 8. It’s a little more complicated now. The simplest example in core I could find was in the Node module, which is altering a route created by the System module.

A class file at MODULE/src/Routing/CLASSNAME.php extends RouteSubscriberBase and looks like the following. It finds the route it wants to alter using the alterRoutes() method and changes it as necessary. You can see that the values that are being altered map to lines in the original MODULE.routing.yml file for this entry.


/**
 * @file
 * Contains \Drupal\node\Routing\RouteSubscriber.
 */

namespace Drupal\node\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
 * Listens to the dynamic route events.
 */
class RouteSubscriber extends RouteSubscriberBase {

  /**
   * {@inheritdoc}
   */
  protected function alterRoutes(RouteCollection $collection) {
    // As nodes are the primary type of content, the node listing should be
    // easily available. In order to do that, override admin/content to show
    // a node listing instead of the path's child links.
    $route = $collection->get('system.admin_content');
    if ($route) {
      $route->setDefaults(array(
        '_title' => 'Content',
        '_entity_list' => 'node',
      ));
      $route->setRequirements(array(
        '_permission' => 'access content overview',
      ));
    }
  }

}

To wire up the menu_alter there is also a MODULE.services.yml file with an entry that points to the class that does the work:


services:
  node.route_subscriber:
    class: Drupal\node\Routing\RouteSubscriber
    tags:
      - { name: event_subscriber }

Many core modules put their RouteSubscriber class in a different location: MODULE/src/EventSubscriber/CLASSNAME.php instead of MODULE/src/Routing/CLASSNAME.php. I haven’t been able to figure out why you would use one location over the other.

Altering routes and creating dynamic routes are complicated topics that are really beyond the scope of this article. There are more complex examples in the Field UI and Views modules in core.

And More!

And these are still only some of the things that are done in hook_menu in Drupal 7 that need to be transformed to Drupal 8. Hook_menu is also used for creating menu items, local tasks (tabs), contextual links, and form callbacks. I’ll dive into the Drupal 8 versions of some of those in a later article.

More information about this topic:

Published in:

Get in touch with us

Tell us about your project or drop us a line. We'd love to hear from you!