0034. A block or inline content type declares its template's theme variables on its attribute¶
Status: Accepted
Date: 2026-10-30
Context¶
A block plugin ships its own template through the template property of #[BlockNoteBlock], and BlockNoteThemeHooks::theme() registers a blocknote_block__<type> theme hook for it. Every one of those hooks was registered with one fixed variable set, which is what lets a template written for blocknote_block work for any type. Core's ThemeManager::render() copies a # key out of a render element only for a variable the hook declares, so a variable the set did not name was dropped without a message.
The media block needs one more variable, media, for the item it defers behind a placeholder. 0006 added it from blocknote_media with hook_theme_registry_alter(), because a second hook_theme() implementation naming the same hook wins only while its module sorts after blocknote, and a shipped contract may not depend on module order. That record called the alter a gap in the extension contract and left the fix to a later release rather than adding it on one consumer's evidence.
The evidence has grown. docs/extending.md told every block author with a template of their own to repeat the alter. The inline content plugin type that 0027 added has the same template property and the same fixed set (inline_type, props, attributes and content), and its documentation sent a type's author to the same alter. Two plugin types now have the gap, and one of them has a consumer in the repository.
BlockNoteBlockBase::themedBlock() and BlockNoteInlineContentBase::themedInline() already take an $extra array of render keys and join it to the standard keys with a union, so a standard key keeps the value the base class built for it, which BlockPluginContractTest::testExtraCannotReplaceStandardVariable() holds. The render side has the rule; the registration side lacked the matching way to declare the names.
Decision¶
#[BlockNoteBlock] and #[BlockNoteInlineContent] each have an optional variables property. It is an array of default values keyed by variable name, [] by default. BlockNoteThemeHooks::theme() adds it to the standard set when it registers the plugin's hook, blocknote_block__<type> for a block and blocknote_inline__<type> for an inline content type, as the standard set plus the declared entries.
A declared variable never replaces a standard one. The merge is the union of the standard set with the declaration, so a name both have keeps the standard default, the same rule themedBlock() and themedInline() apply to $extra. An entry whose key is not a non-empty string is dropped, since a theme hook cannot name it. The plugin still passes the value itself, through $extra, keyed with a leading #.
The shared base hooks are unchanged. blocknote_block and blocknote_inline keep exactly the standard set, so a template written against either still works for any type, and a declared variable reaches only the hook of the plugin that declared it.
blocknote_media declares media on DrupalMedia and has no registry alter. variables: ['media' => NULL] replaces BlockNoteMediaThemeHooks::themeRegistryAlter(), and the class is deleted. A kernel test pins the result: the registry holds media in blocknote_block__drupal_media, and the submodule implements no hook_theme_registry_alter(). The test module's variable sample block proves the mechanism for a plugin outside this repository, and a kernel test drives the inline registration with a definition that declares variables, because a further inline type in the test module would change the counts the format form tests assert.
This supersedes the paragraph of 0006 headed "The media theme variable is added through hook_theme_registry_alter()". The reasoning there against a second hook_theme() implementation stands, and it is why the declaration lives on the attribute and not in a hook. What changes is the remedy: the gap it describes is closed, and the variable is declared, not altered in. The rest of 0006 is unchanged.
Alternatives considered¶
Keep hook_theme_registry_alter() as the documented answer. Rejected. It works, but every author with a template of their own has to know that the base module drops undeclared variables in silence, that a second hook_theme() is the wrong answer, and that the alter has to rebuild a hook name from the plugin id by the base module's own suffix rule. The attribute already holds the template name, and the variables are the other half of the same declaration.
Discover the variables by reading the template. Rejected. Twig does not give the registry a list of the names a template uses, and a name used in a template is not the same as a name the plugin passes. A declaration is explicit and readable without parsing.
Let a plugin method return the variables instead of an attribute property. Rejected. The theme registry is built from definitions without instantiating plugins, which can have injected services, and template is already an attribute property for the same reason.
Let a declared variable replace a standard one. Rejected. The standard set is what the base template and every theme override of it rely on, and themedBlock() already refuses the same replacement for the render keys. Letting the registration disagree with the render would give one name two defaults.
Do blocks now and inline content later. Rejected. The two registrations are two loops in the same method with the same merge, and shipping one half would leave the documentation telling inline authors to use the alter that the block half had just made unnecessary.
Consequences¶
A third-party block or inline content type with a template of its own declares what the template needs in one place, with no theme hook implemented, and its default is the value the template sees when the plugin passes nothing.
A new optional property on two attributes is an addition to the extension contract, and variables sits between template and deriver in the constructor of #[BlockNoteBlock] and #[BlockNoteInlineContent], so deriver moves one place along. The attributes are written with named arguments throughout the repository, and a module that passed deriver by position would have to name it.
A module that already adds a variable with hook_theme_registry_alter() keeps working, since the alter runs after the registration. Nothing in this module does.
A name collision with a standard variable is silent, as it is for $extra. A block author who declares content learns it from the template receiving the standard value, which the documentation says.
References¶
src/Attribute/BlockNoteBlock.php,src/Attribute/BlockNoteInlineContent.php,src/Hook/BlockNoteThemeHooks.php,src/BlockNoteBlockBase.php::themedBlock(),src/BlockNoteInlineContentBase.php::themedInline().modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php,tests/src/Kernel/MediaRenderTest.php,tests/src/Kernel/ExtensionRenderTest.php,tests/src/Kernel/ExtensionInlineRenderTest.php,tests/modules/blocknote_test/src/Plugin/BlockNoteBlock/VariableSample.php.- Drupal core 11.4.11:
core/lib/Drupal/Core/Theme/ThemeManager.php::render(),core/lib/Drupal/Core/Theme/Registry.php. - 0005, 0006, 0027.