Skip to main content

How do theme template overrides in Drupal work?

Published on

In Drupal, with themes, you can override default template output by copying, pasting, and customizing those templates. It works, but how? I thought I always knew how this worked. But I realized I didn't when I dove into supporting Drupal 7 themes with Retrofit.

I know Drupal builds the theme registry and theme hook suggestions. In doing so, it scans templates, and part of that process includes scanning the active theme for template and theme hook template implementations. But when reading the \Drupal\Core\Theme\Registry code, I was coming up blank.

Components of Drupal's theme registry

The theme registry in Drupal contains all theming information about defined templates and preprocess hooks. There is a base registry of theme hooks defined by modules. Then, there are versions of the registry for individual themes. That is because each theme may implement preprocess hooks or template overrides.

  • \Drupal\Core\Theme\Registry – Maintains all theme hook, preprocess, template, etc. information
  • \Drupal\Core\Utility\ThemeRegistry – runtime registry cache collector, decorator of \Drupal\Core\Theme\Registry and instantiated by \Drupal\Core\Theme\Registry::getRuntime creating a theme-specific subset of the registry.
  • \Drupal\Core\Template\Loader\ThemeRegistryLoader – Twig loader that loads templates based on the ThemeRegistry runtime registry.

It's somewhat confusing. But it all revolves around \Drupal\Core\Theme\Registry. The \Drupal\Core\Utility\ThemeRegistry class is a way to try and optimize caching for the registry data.

Building the theme registry

The magic happens in \Drupal\Core\Theme\Registry::build. The theme registry will first try to retrieve cached registry data about theme information provided by modules, consistent across any theme being used.

if ($cached = $this->cache->get('theme_registry:build:modules')) {
    $cache = $cached->data;
}

If that results in a cache miss, Drupal invokes hook_theme for all installed modules to create the base theme registry. The processExtension method invokes the extensions' hook_theme implementation and discovers any preprocess function hooks provided by that extension.

$this->moduleHandler->invokeAllWith('theme', function (callable $callback, string $module) use (&$cache) {
    $this->processExtension($cache, $module, 'module', $module, $this->moduleList->getPath($module));
});
$this->cache->set("theme_registry:build:modules", $cache, Cache::PERMANENT, ['theme_registry']);

The next part of building the registry considers the current theme, its theme engine, and base themes. This is where I got confused and a bit lost. The code is calling processExtension, again. 

foreach (array_reverse($this->theme->getBaseThemeExtensions()) as $base) {
    $base_path = $base->getPath();
    if ($this->theme->getEngine()) {
        $this->processExtension($cache, $this->theme->getEngine(), 'base_theme_engine', $base->getName(), $base_path);
    }
    $this->processExtension($cache, $base->getName(), 'base_theme', $base->getName(), $base_path);
}

The theme's engine (Twig) is then processed. Again, it is calling processExtension, and I didn't think anything of it.

if ($this->theme->getEngine()) {
    $this->processExtension($cache, $this->theme->getEngine(), 'theme_engine', $this->theme->getName(), $this->theme->getPath());
}

Then the theme itself is processed.

$this->processExtension($cache, $this->theme->getName(), 'theme', $this->theme->getName(), $this->theme->getPath());

I was perplexed. I know themes do not need to invoke hook_theme to provide template overrides. They're automatically detected. Everything I read within processExtension for themes was primarily handling their preprocess hooks.

Then I took a step back. I fired up Xdebug and truly stepped through the process. And I realized I overlooked a crucial part of the processing — handling of the theme_engine.

The twig_theme hook implementation

Located in the twig.engine file is the twig_theme theme hook implementation. It gets invoked when processExtension is called for theme_engine the extension.

function twig_theme($existing, $type, $theme, $path) {
  return drupal_find_theme_templates($existing, '.html.twig', $path);
}

The drupal_find_theme_templates function lives in includes/theme.inc. It scans the directories of all themes available to Drupal and scans their directories for template files based on the given extension, .html.twig. The templates are then matched in two ways

  • Convert template file names with - to _ for the function naming scheme of theme hooks
  • Check if the template file name matches the template file name in an existing theme hook.

If a match exists, the theme registry will use that template file rather than the default template.

Once I discovered this tidbit, I could support PHPTemplate template overrides with Retrofit by overriding logic in processExtension.

if ($type === 'theme_engine') {
    $templates = drupal_find_theme_templates($cache, '.tpl.php', $path);
    foreach ($templates as $theme_hook => $info) {
        $cache[$theme_hook]['phptemplate'] = $info['path'] . '/' . $info['template'] . '.tpl.php';
        $cache[$theme_hook]['template'] = 'theme-phptemplate';
        $cache[$theme_hook]['path'] = '@retrofit';
    }
}