Skip to main content

Ensuring Drupal route lookups by path are cached by domain

Published on

In this episode of "Matt does ridiculous things with Drupal," I found out that the inbound processing of a path and the lookup for its route gets cached. That makes sense, it can be an expensive process to say /foo is actually an alias of /node/1 and should go to the entity view controller. What I did not know is that the route provider in Drupal creates a cache ID based on the path and language code, only.

Here's where the cache ID for the request's route collection is generated (link to the full source.)

  protected function getRouteCollectionCacheId(Request $request) {
    // Include the current language code in the cache identifier as
    // the language information can be elsewhere than in the path, for example
    // based on the domain.
    $this->addExtraCacheKeyPart('language', $this->getCurrentLanguageCacheIdPart());

    // Sort the cache key parts by their provider in order to have predictable
    // cache keys.
    ksort($this->extraCacheKeyParts);
    $key_parts = [];
    foreach ($this->extraCacheKeyParts as $provider => $key_part) {
      $key_parts[] = '[' . $provider . ']=' . $key_part;
    }

    return 'route:' . implode(':', $key_parts) . ':' . $request->getPathInfo() . ':' . $request->getQueryString();
  }

That means if you have two domains pointed at the same Drupal site and want to process an inbound path differently, you cannot. Out of the box, that is. However, there is a bit of an escape hatch. The route provider doesn't use cache contexts or other tricks in Drupal's cache system. I am assuming it is due to the fact we're in the routing system.

The route provider implements CacheableRouteProviderInterface which has an interesting (and its only) public method:

  /**
   * Adds a cache key part to be used in the cache ID of the route collection.
   *
   * @param string $cache_key_provider
   *   The provider of the cache key part.
   * @param string $cache_key_part
   *   A string to be used as a cache key part.
   */
  public function addExtraCacheKeyPart($cache_key_provider, $cache_key_part);

This allows other code to influence the cache keys used to generate the cache ID. Huzzah! But... when should we even set this? Turns out the Workspaces module needed to also expand the cache key parts. It adds the current workspace ID into the cache key, so each path lookup has its own cache entry per workspace.

Here's the code from the subscriber method in WorkspaceRequestSubscriber:

  public function onKernelRequest(RequestEvent $event) {
    if (
        $this->workspaceManager->hasActiveWorkspace() 
        && $this->routeProvider instanceof CacheableRouteProviderInterface
    ) {
      $this->routeProvider->addExtraCacheKeyPart('workspace', $this->workspaceManager->getActiveWorkspace()->id());
    }
  }

This method is subscribed to the KernelEvents::REQUEST event at a priority of 33. Why 33? This gets the event subscriber to fire before the route listener is fired and routing begins.

Adding cache key parts to the route provider

Okay! That's the back story and wonderful exploration to figure out what in the what was happening. You'll see apixus in the code. This is a decoupled build I am playing around with. It enforces an api and admin domain, with any other domain serving the single page application.

First, the services definition!

  apixus.request_subscriber:
    class: Drupal\apixus\EventSubscriber\RequestSubscriber
    arguments: ['@router.route_provider']
    tags:
      - { name: event_subscriber }

Next, the event subscriber! Note: This is PHP 8 syntax.

<?php declare(strict_types=1);

namespace Drupal\apixus\EventSubscriber;

use Drupal\Core\Routing\CacheableRouteProviderInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class RequestSubscriber implements EventSubscriberInterface {

  public function __construct(
    private RouteProviderInterface $routeProvider
  ) {
  }

  public static function getSubscribedEvents() {
    return [
      // Use a priority of 33 in order to run before Symfony's router listener.
      // @see \Symfony\Component\HttpKernel\EventListener\RouterListener::getSubscribedEvents()
      KernelEvents::REQUEST => ['onRequest', 33],
    ];
  }

  public function onRequest(RequestEvent $event) {
    if ($this->routeProvider instanceof CacheableRouteProviderInterface) {
      $this->routeProvider->addExtraCacheKeyPart('apixus', $event->getRequest()->getHost());
    }
  }

}

And voila! If you want to have dynamic replacements for your front page (/) based on the domain using an inbound path processor, you can!

I hope this was a neat little trick you found interesting or useful!

#