The design of this site shows the main menu twice. In the header it is a row of links that folds into a menu button on small screens. In the footer it is a plain list. With core's block layout, a theme gives each placement its own template file. Canvas places blocks its own way, though, and it leaves the theme nothing to tell the two apart.
The module, Canvas Block Twig Suggestions, has a project page on drupal.org and a short showcase in my portfolio. This post covers the problem, what the module does, how this site uses it and where it stops. Under the Hood, at the end, shows developers how the module hooks into the way Drupal picks a block template.
I wrote the module with AI-assisted development, in a Claude Code session one evening in February 2026, while theming this site. The first version tried to add the region to each block's template file name. But it never got a region back.
A deep dive with Claude Code into how Canvas renders a page showed why. Canvas never hands a block its region. So I took out the code that looked for the region. All the module did then was remove the broken file names built from each block's unique ID. That cleaned up the debug output but did nothing for the menu.
Later that evening, Claude Code and I found the region one level up, on the element that wraps each region. This time the region made it into the file names, and the footer menu got its own template. I created the project on drupal.org a couple of days later and released version 1.0.0 soon after.
The problem
When core's block layout places a block, the block's #id is its machine name, something like mytheme_main_menu. Core's BlockThemeHooks::themeSuggestionsBlock() turns that into a template suggestion, a file name Drupal looks for before it falls back to the default template. This one, block__mytheme_main_menu, has the highest priority, so a theme can target one placed block with one file.
Canvas does not place blocks through that system. Its BlockComponent::renderComponent() builds the block's render array itself and sets #id to the component instance's universally unique identifier (UUID). Core then builds a suggestion from it as usual. With Twig debug on and without the module, the footer menu gets these suggestions. The UUID is a placeholder, and I left out the markers Twig debug puts before each name.
<!-- FILE NAME SUGGESTIONS:
block--system-menu-block--main.html.twig
block--system-menu-block.html.twig
block--system.html.twig
block.html.twig
-->
<!-- INVALID FILE NAME SUGGESTIONS:
block__[component-uuid]
-->Two things are wrong with that UUID suggestion. A UUID is 36 characters with four hyphens, and Twig debug flags any suggestion with a hyphen in it as invalid. No template file can ever match one, because Drupal lists every template under its file name with the hyphens turned into underscores. Under the Hood shows the line that does it, in One menu, two files.
Even if a file could match, the UUID belongs to one placed instance. Place the block again and it gets a new one.
Custom blocks get two more of these. The block_content module builds block__block_content__id__[uuid] and block__block_content__id_view__[uuid]__[view_mode] from the same #id, and both are invalid for the same reason.
A second gap is the missing region. The render array Canvas builds has #theme, #plugin_id and #id, but no #region. So the theme has no way to know which copy of the menu it is rendering. The designs for this site draw the two very differently.
What the module does
For every block Canvas places, the module removes each suggestion that contains the UUID. Then it adds one new suggestion at the top, block__[region]__[plugin_id], with the colons and hyphens in the plugin ID turned into underscores. The same footer menu block now shows this list.
<!-- FILE NAME SUGGESTIONS:
block--footer--system-menu-block-main.html.twig
block--system-menu-block--main.html.twig
block--system-menu-block.html.twig
block--system.html.twig
block.html.twig
-->Core's own suggestions stay where they were, so a theme that ignores regions keeps working. The region suggestion only wins when its file exists.
The region file has a single hyphen in system-menu-block-main, and that is easy to miss. Core splits system_menu_block:main at the colon and joins the parts with a double underscore, which gives block--system-menu-block--main.html.twig. The module turns the colon into one underscore instead. That leaves two conventions in one list, and changing it now would rename the region file on every site that already has one.
How this site uses it
The theme has six block templates in templates/block/, and four of them exist only because of this module.
block--footer--system-menu-block-main.html.twigprints the main menu as a plain list of footer links.block--footer--system-menu-block-social.html.twigprints the social menu as icon links, with each icon class read from the link's attributes.block--footer-bottom--block-content-[block-uuid].html.twigprints the copyright line and puts the current year after the © sign.- A second
block--footer-bottom--block-content-[block-uuid].html.twigprints the "built with" line.
The last two show the plugin ID conversion at work. A custom block's plugin ID is block_content:[block-uuid], so its file name ends in block-content-[block-uuid]. That UUID belongs to the custom block itself, so it stays the same for as long as the block exists. The component instance UUID the module strips changes with every placement.
The header has no region files, so it falls back to core's suggestions. Its main menu renders through block--system-menu-block--main.html.twig, with the desktop links, the menu button and the mobile overlay. The branding block next to it uses block--system-branding-block.html.twig. Same menu block, two regions, two templates, and no region checks in Twig.
Installing it
Install it with Composer, then enable it with Drush.
composer require 'drupal/canvas_block_twig_suggestions:^1.0'
drush en canvas_block_twig_suggestions
drush crIt needs Drupal 11 and the Canvas module, and it has no settings. To see the new suggestions, turn on Twig debug mode on the Development settings page at /admin/config/development/settings. The file names then show up in the HTML comments above each block.
Where it stops
The region suggestion depends on the block rendering inside a theme region. Blocks in a Canvas page's own tree, like the contact form on my home page, get the UUID cleanup but no region suggestion. Core renders the main content of a page before any region, so there is no region to record yet. Those blocks fall back to core's plugin ID suggestions.
The module tells a Canvas block from a core block by a hyphen in #id. Core only allows lowercase letters, numbers, underscores and periods in a block's machine name, so a block from the block layout never has one.
Canvas is still marked @internal, and the way it renders a page changes between releases. In October 2026 this site moved to page variants, the editable full-page templates that replaced global regions in Canvas. Twig debug showed the same suggestions and the same templates before and after the move. Under the Hood explains why.
What comes next
Before the first release, I also opened an issue in the Canvas queue to ask the maintainers whether region-aware suggestions belong in Canvas itself. About two weeks later, a contributor answered with a merge request that builds them into Canvas, and a Canvas maintainer reviewed it.
In the review we agreed to add _region to the region name, so it is less likely to collide with a module or plugin name. In April the maintainer agreed with the proposed suggestion order. The merge request is still open, and my next step is to push those agreed changes to it.
When Canvas ships region-aware suggestions of its own, I plan to deprecate this module and point its users to Canvas. The module itself has a Drupal 12 release coming, and an automated compatibility patch is already in its issue queue.
Under the Hood
The sections above cover what a themer needs. This one is for developers who want to see, in code, how the module hooks into the way Drupal picks a block template.
A simple idea, hard to wire in
The idea was simple. I wanted Drupal to take two Twig file name suggestions for the same block, one for each region. Then one block could serve several areas of a Canvas page. Without the module, I would have created a footer main nav and duplicated the links from the main navigation menu. Keeping the same links in two menus is bad practice in my eyes.
But simple did not mean easy. I remember Claude Code doing a deep dive into how Drupal core and Canvas set up the template suggestions for a block. Then we hooked into it with two hooks and a small class, CanvasRegionTracker, that carries the region from one to the other. This section follows that path, with the code at each step.
What the deep dive found
Core builds the list in BlockThemeHooks::themeSuggestionsBlock(). It adds the provider first, then one entry for each part of the plugin ID, and the block's #id last.
#[Hook('theme_suggestions_block')]
public function themeSuggestionsBlock(array $variables): array {
$suggestions = [];
$suggestions[] = 'block__' . $variables['elements']['#configuration']['provider'];
// ...
$parts = explode(':', $variables['elements']['#plugin_id']);
$suggestion = 'block';
while ($part = array_shift($parts)) {
$suggestions[] = $suggestion .= '__' . strtr($part, '-', '_');
}
if (!empty($variables['elements']['#id'])) {
$suggestions[] = 'block__' . $variables['elements']['#id'];
}
return $suggestions;
}The order matters, because Drupal reads the list backwards. In the theme manager, buildThemeHookSuggestions() lets every module alter the list, then the active theme. Then render() walks it from the end and stops at the first suggestion the theme registry knows. So the last entry has the highest priority, and Twig debug prints it at the top.
$suggestions = $this->moduleHandler->invokeAll('theme_suggestions_' . $base_theme_hook, [$variables]);
// ...
$this->moduleHandler->alter($hooks, $suggestions, $variables, $base_theme_hook);
$this->alter($hooks, $suggestions, $variables, $base_theme_hook);
// ...
foreach (array_reverse($suggestions) as $suggestion) {
if ($theme_registry->has($suggestion)) {
$info = $theme_registry->get($suggestion);
// ...
break;
}
}For a Canvas block, that last entry is the UUID one. Canvas builds the block's render array in BlockComponent::renderComponent(), and this is the part that feeds the suggestions.
$build += [
'#theme' => 'block',
'#configuration' => $block->getConfiguration(),
'#plugin_id' => $block->getPluginId(),
'#base_plugin_id' => $block->getBaseId(),
'#derivative_plugin_id' => $block->getDerivativeId(),
'#id' => $componentUuid,
];The #id is the component instance's UUID, and nothing in the array says which region the block sits in. That finding is why I took out the code that looked for the region. It also showed that only blocks need the fix. Single Directory Components (SDCs) and JavaScript code components render through their own elements and never ask for block suggestions.
Catching the region
The region was there all along, one level up. Each region of the page is an element with #region set, and the blocks inside it render as its children. So the first hook, hook_preprocess_page(), adds a #pre_render callback to every region that has content.
function canvas_block_twig_suggestions_preprocess_page(array &$variables): void {
$regions = \Drupal::theme()->getActiveTheme()->getRegions();
foreach ($regions as $region) {
if (!empty($variables['page'][$region])) {
$variables['page'][$region]['#pre_render'][] = [
CanvasRegionTracker::class,
'preRenderRegion',
];
}
}
}The timing comes from the order in which Drupal's renderer handles an element. In Renderer::doRender() the #pre_render callbacks run first, then the children render, and the #theme_wrappers go around them last. So the callback fires before the first block in its region asks for suggestions.
if (isset($elements['#pre_render'])) {
foreach ($elements['#pre_render'] as $callable) {
$elements = $this->doCallback('#pre_render', $callable, [$elements]);
}
}
// ...
if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) {
foreach ($children as $key) {
$elements['#children'] .= $this->doRender($elements[$key], $context);
}
// ...
}
// ...
if (isset($elements['#theme_wrappers']) && !isset($elements['#render_children'])) {
// ...
}The region tracker
The callback is a method on CanvasRegionTracker, the small class that carries the region between the two hooks. When a region starts to render, preRenderRegion() reads #region from the element and stores the name. Then the blocks in that region render, and the second hook gets the name back from getCurrentRegion().
Drupal refuses a render callback unless it is an anonymous function or a method on a trusted class. So the class implements TrustedCallbackInterface and names the one method it trusts. This is the class without its comments.
final class CanvasRegionTracker implements TrustedCallbackInterface {
private static ?string $currentRegion = NULL;
public static function getCurrentRegion(): ?string {
return self::$currentRegion;
}
// ...
public static function preRenderRegion(array $element): array {
if (isset($element['#region'])) {
self::$currentRegion = (string) $element['#region'];
}
return $element;
}
public static function trustedCallbacks(): array {
return ['preRenderRegion'];
}
}The two hooks run at different moments and share no object. A static property belongs to the class itself, so the name preRenderRegion() stores is still there when the second hook asks for it.
Only the unit tests clear the property, with the resetForTesting() method the excerpt leaves out. The module never does, and it does not need to. Regions render one after another, and each one overwrites the value before its own blocks render. A block that renders before any region finds the value empty. It still loses its UUID suggestions, but it gets no region suggestion and falls back to core's suggestions.
Where the region comes from
The module never reads the region from Canvas. It reads #region from the wrapper element, and on this site that element has been built two ways.
I wrote the module against the Canvas development branch that became 1.1.0. Canvas then handed back the page as component trees keyed by region name. Core's HtmlRenderer::prepare() wrapped every region that was not empty, with the same loop the module's first hook uses.
$regions = $this->themeManager->getActiveTheme()->getRegions();
foreach ($regions as $region) {
if (!empty($page[$region])) {
$page[$region]['#theme_wrappers'][] = 'region';
$page[$region]['#region'] = $region;
}
}The same method renders the page's main content earlier, before the display variant builds any region. That is why blocks in a Canvas page's own tree get no region suggestion, as Where it stops describes.
Canvas 1.11.0 replaced global regions with page variants. When this site moved to Canvas 1.12.0 on October 12, 2026, the update rebuilt its regions as a page variant. Now the ThemePageTemplate component in Canvas renders the theme's page.html.twig and wraps each region itself, the way core does.
foreach ($slots as $region_name => $region_build) {
// ...
$build[$region_name] = [
'#theme_wrappers' => ['region'],
'#region' => $region_name,
'content' => $region_build,
];
}Both paths end in the same shape, a page render array whose regions carry #region. That array is what hook_preprocess_page() receives, so the module did not have to change. It calls no Canvas code. It depends on two things in the render array, a #region on each region element and a hyphenated UUID in each Canvas block's #id. If either one changes, the module will need a follow-up.
Rewriting the list
The second hook, hook_theme_suggestions_block_alter(), runs for every block after core has built the list. This is all of it, without the comments.
function canvas_block_twig_suggestions_theme_suggestions_block_alter(array &$suggestions, array $variables): void {
$elements = $variables['elements'];
$id = $elements['#id'] ?? NULL;
if ($id === NULL || !str_contains($id, '-')) {
return;
}
$suggestions = array_values(array_filter(
$suggestions,
static fn(string $s) => !str_contains($s, $id),
));
$plugin_id = $elements['#plugin_id'] ?? NULL;
if ($plugin_id === NULL) {
return;
}
$plugin_clean = strtr($plugin_id, [':' => '_', '-' => '_']);
$region = CanvasRegionTracker::getCurrentRegion();
if ($region !== NULL) {
$region_clean = strtr($region, ['-' => '_']);
$suggestions[] = 'block__' . $region_clean . '__' . $plugin_clean;
}
}A hyphen in #id marks a Canvas block, and the hook leaves every other block alone. For a Canvas block, the filter drops each suggestion that contains the UUID. Then the hook appends the region suggestion to the end of the list. That puts it in the spot the UUID held, the one with the highest priority. Core's own entries keep their order below it, which is why a theme without region files works as before.
The filter uses str_contains() rather than an exact match on block__[uuid], and that came out of a bug. Custom blocks still showed two invalid suggestions the day after I wrote the module. The block_content module builds them from the same #id, and the first filter only removed core's.
if (!empty($variables['elements']['#id'])) {
$suggestions_new[] = 'block__block_content__id__' . $variables['elements']['#id'];
$suggestions_new[] = 'block__block_content__id_view__' . $variables['elements']['#id'] . '__' . $view_mode;
}The wider filter catches those too, but only because block_content alters the list first. Both modules have the default weight of 0, and Drupal orders modules of equal weight by name, so block_content comes first. A module that adds a UUID suggestion after this one runs would slip past the filter.
Tests
Unit tests cover the tracker, the alter hook and the full flow. They extend PHPUnit's TestCase directly and call the real hook functions, so they run without booting Drupal. One test plays a page with the header region first and the footer second. Two copies of the branding block get block__header__system_branding_block in the header and block__footer__system_branding_block in the footer.
One menu, two files
Drupal finds template files when it builds the theme registry. It turns each file name into a hook name by swapping hyphens for underscores. So no file can ever match a suggestion with a hyphen in it. That is also why the cache needs a rebuild after you add a template.
$implementations[strtr($file, '-', '_')] = [
'template' => $file,
// ...
];Read the other way, each region suggestion has exactly one file name. For the main menu in the footer, block__footer__system_menu_block_main becomes block--footer--system-menu-block-main.html.twig. A region whose machine name has an underscore, like footer_bottom, shows up as footer-bottom.
On this site the header keeps core's file, block--system-menu-block--main.html.twig. A theme that wants a file for each region would add these two, each starting as a copy of core's block.html.twig.
templates/block/block--header--system-menu-block-main.html.twig
templates/block/block--footer--system-menu-block-main.html.twigThis is the footer file on this site, without its comment block.
{% set items = content['#items']|default({}) %}
<nav>
<ul class="footer__nav">
{% for item in items %}
<li>
<a href="{{ item.url }}" class="footer__nav-link">
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</nav>Both files render the same main menu block. The links live in one menu, and each place they appear gets its own template.
Sources
- Canvas Block Twig Suggestions: the module on drupal.org
- Canvas issue 3572709: the proposal to build region-aware suggestions into Canvas, and merge request 658
- Drupal Canvas: the page builder the module works with
- Canvas 1.11.0: the release that brought page variants
- hook_theme_suggestions_HOOK_alter: the hook the module uses to change the list
- Render callbacks must be a closure or implement TrustedCallbackInterface or RenderCallbackInterface: why the region tracker lists its callback as trusted
- Twig debugging / caching settings added to administrative user interface: the Development settings page that turns on Twig debug
- Locating Template Files with Debugging: how to read the file name suggestions

Comments.
No comments yet. Be the first to write one.