Drupal 9: Loading All Routes From A Module

When creating Drupal modules I like to keep the hard coded components to a minimum. This helps when changing parts of the module in the future as hard coded links and other elements will require manual intervention and slow down maintenance. Sometimes, though, this isn't an option as you just need to have a few routes in your *.routing.yml file that point to controllers or forms within your module.

I had a situation today where I was looking to load all of the routes that are contained in a module. I could then construct a page of links that would handily point to different parts of the module or feed those links into a sitemap. This meant that I wouldn't need to hard code this list into a controller, I just needed to load all the routes and print that list out instead. Especially handy if I ever added or removed a route as that would mean that list would update without me having to do it manually.

Using Core Services

As it happens, Drupal doesn't have a service that allows you to search for routes that have a similar signature or structure. There are a couple of things that look like they might work, but end up not being what I was looking for. I'll go through them here for completeness.

The first option I found was the getRouteByName() method from the router.route_provider service. This does a one-to-one match of a given route against the routes you have within a site. Because the searching is done as lookup on an array of routes the method doesn't accept wildcard searching.

The following example would only retrieve a single route.

$route = \Drupal::service('router.route_provider')->getRouteByName('some_route');

Next, I tried the getRoutesByPattern() method, which is part of the same service. Despite the name, this is actually a wrapper around a method called getRoutesByPath() that does a database lookup for routes that match against a given path that contain wildcard parameters. The basic idea is that you can find the routes that surround a given path. For example, you could pass in "/comment/%" and retrieve routes like "/comment/{comment}/approve" and "/comment/{comment}/delete". This turned out unsuitable for my needs as very few of the paths I had in the module were wildcard paths.

The method should be used like this, passing a wildcard path in to retrieve one or more routes that are connected to this path.

$routes = \Drupal::service('router.route_provider')->getRoutesByPattern('/somepath/%');

The next service I looked at is actually used during the bootstrap process by Drupal. The mat() method on the router.no_access_checks service is used to find the current route based on the given path. The difference here is that this isn't a searching method, it will match against the given path or not.

$route = \Drupal::service('router.no_access_checks')->match('/mymodule');

Be careful of this one as if your path doesn't exist then it will throw an exception.

The Solutions

The solution to this problem fell into two routes (if you'll forgive the pun). Either pull the routes directly out of the database, or fetch them from the module file routing itself.

For these examples I am going to assume that we have a module called "my_module" that contains a number of routes in the routing file (called my_module.routing.yml) that would look something like this.

my_module_page:
  path: '/page'
  defaults:
    _controller: '\Drupal\my_module\Controller\MyModuleController::page'
    _title: 'Page'
  requirements:
    _access: 'TRUE'

my_module_form:
  path: '/form'
  defaults:
    _form: '\Drupal\my_module\Form\MyModuleForm'
    _title: 'Form'
  requirements:
    _access: 'TRUE'

I have included this snippet here to give some context to these examples.

1. Get Routes From The Database

The most obvious solution to this is to search the database for the routes that we want.

When Drupal builds its internal routing structure (after a cache clear) it will pick up the routes from your module and create records of them in the router table in your database. This is ultimately where the Drupal core route matching methods were getting their information from so it makes sense to pull the data from this table.

Using the code below we are querying the database for all routes that start with the string "my_module".

$database = \Drupal::database();
$query = $database->query("SELECT name, path FROM {router} WHERE name LIKE :name", [":name" => 'my_module%']);
$results = $query->fetchAll();

This will return an array of results, so the next step is to use getRouteByName() to turn this into a route object. This is a legitimate use of this method as we have a one to one match being done here.

foreach ($results as $id => $result) {
    $routeName = $result->name;
    /** @var $route \Symfony\Component\Routing\Route */
    $route = \Drupal::service('router.route_provider')->getRouteByName($routeName);
    
    $text => $route->getDefault('_title'),

    // Do things with route name and text.
}

The $route variable in the above code now contains a standard Route object that we can use to extract information about the route.

2. Get Routes From The Module routing.yml File

Another solution to this problem is to pull data out of the module's *.routing.yml file.

To do this we need to load the contents of the file from the module and use the \Drupal\Core\Serialization\Yaml::decode() static method to convert the file data into a PHP array. This can be done using the following code.

$routingFilePath = DRUPAL_ROOT . '/' . drupal_get_path('module', 'my_module') . '/my_module.routing.yml';
$routingFileContents = file_get_contents($routingFilePath);
$results = \Drupal\Core\Serialization\Yaml::decode($routingFileContents);

Note that this can also be written as.

$modulePath = \Drupal::service('extension.path.resolver')->getPath('module', 'my_module');
$routingFilePath = DRUPAL_ROOT . '/' . $modulePath . '/my_module.routing.yml';
$routingFileContents = file_get_contents($routingFilePath);
$results = \Drupal\Core\Serialization\Yaml::decode($routingFileContents);

With this PHP array in hand we can then go about loading the routes in the same way as the previous example. Note that all we really need to know about the route is the name, which we can then use to pull information about the route from Drupal.

foreach ($results as $id => $result) {
    $routeName = $id;
    /** @var $route \Symfony\Component\Routing\Route */
    $route = \Drupal::service('router.route_provider')->getRouteByName($routeName);
    
    $text => $route->getDefault('_title'),

    // Do things with route name and text.
}

The downside here is that we now have all of the routes in the module, regardless of what their original name was. We therefore need to use some logic to remove the ones we don't want. Not a big issue, but if you have administration pages in your module that you don't want to print out then you'll need to filter them out here.

A Real World Example: Adding Routes To The sitemap.xml File

Rather than just leave it there I thought it would be a good idea to add a real world example of this approach.

The Simple XML Sitemap Drupal module is used to generate sitemap.xml files. I use this module as standard on all my Drupal sites as it is very stable, feature rich, and is good at generating sitemap.xml files.

It is possible to add arbitrary links to the file through the user interface in your Drupal site, but the module also provides a couple of hooks to alter the sitemap.xml file generation. The hook hook_simple_sitemap_arbitrary_links_alter() can be used to inject additional links to the sitemap.xml generation process. This gives us a handy mechanism for us to load in the routes from a module and inject them into the sitemap.xml file.

The following code (which would be in a file called my_module.module) implements the hook_simple_sitemap_arbitrary_links_alter() hook and uses the database technique above to load routes that start with the text "my_module". These routes are then iterated over and each one is placed into the sitemap.xml file as an absolute URL.

We do this by adding each link to the $arbitrary_links array that is passed by reference to the hook. This means that anything we add to this array will be seen by the Simple XML Sitemap module as a new link.

<?php

use Drupal\Core\Url;

/**
 * Implements hook_simple_sitemap_arbitrary_links_alter().
 */
function my_module_simple_sitemap_arbitrary_links_alter(array &$arbitrary_links, $sitemap_variant) {
  if ($sitemap_variant != 'default') {
    // Ignore anything that isn't the default sitemap.
    return;
  }

  // Extract routes from database.
  $database = \Drupal::database();
  $query = $database->query("SELECT name, path FROM {router} WHERE name LIKE :name", [":name" => 'my_module%']);
  $results = $query->fetchAll();

  // Loop through routes and add to sitemap.
  foreach ($results as $id => $result) {
    $routeName = $result->name;

    /** @var $route \Symfony\Component\Routing\Route */
    $route = \Drupal::service('router.route_provider')->getRouteByName($routeName);

    if ($route->getRequirement('_access') == 'TRUE') {
      // This is a public link, so add to the sitemap.xml file.
      $url = Url::fromRoute($routeName, [], ['absolute' => TRUE, 'https' => TRUE]);
      $arbitrary_links[] = [
        'url' => $url->toString(),
        'priority' => '0.3',
      ];
    }
  }
}

One important thing to realise from the above example is that we are performing a permission check on the route. In this case we are ensuring that the route is publicly available before attempting to place it into the sitemap.xml file. This is critical to remember as the links you load directly from the database are done so without any knowledge of the permission of the user. You must implement that check yourself before showing the link to the user (or in this case, the Simple XML Sitemap module). The only issue is that the user would receive a 403 error code as the page itself is still permission checked, it does, however, lead to a poor user experience.

After regenerating the sitemap.xml file you will now see that it contains extra (publicly available) routes that come from the module's routing file.

Conclusion

Whilst this functionality is not built into Drupal it is simple to implement using Drupal classes and services. You just need to be very careful about the routes that you fetch from your module as you will be responsible for checking the permissions on those routes before showing them to your users.

Selecting between using the database or the file system is up to you, but I think the file based mechanism might be slightly easier to unit test as it doesn't rely on a database layer. The downside of using the file system is that you return all of the links, so you need to add more code to filter out the ones you don't need.

If you want to know more about how to create routes in Drupal modules take a look at the structure of routes documentation on Drupal.org. That documentation page is a good grounding in getting to grips with routes.

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
3 + 0 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.