Skip to content

Template runtime and block composition - #4917

Open
fabpot wants to merge 19 commits into
twigphp:3.xfrom
fabpot:template-runtime-and-block-composition
Open

Template runtime and block composition#4917
fabpot wants to merge 19 commits into
twigphp:3.xfrom
fabpot:template-runtime-and-block-composition

Conversation

@fabpot

@fabpot fabpot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

This PR addresses the Symfony compatibility break from #4910

It introduces runtime composition of templates used as collections of named block renderers: The renderer provides an ordered set of unrelated templates. The first matching block wins, nested block() calls see the complete composed set, and parent() remains within the block’s own inheritance or use hierarchy.

This feature is going to be useful for more than just Symfony.

Strong non-Symfony use cases

Ibexa Core

Project: ibexa/core
Feature: CMS field rendering through FieldBlockRenderer

Ibexa maintains prioritized field templates, selects blocks such as ibexa_string_field, walks parent templates, constructs a block map and passes it to renderBlock().

This is the strongest independent fit for BlockChain:

$blocks = new BlockChain($twig, [
    $localTemplate,
    ...$projectFieldThemes,
    ...$vendorFieldThemes,
]);

return $blocks->renderBlock($fieldType.'_field', $context);

Data-grid and listing renderers

The audit found the same broad mechanism in:

  • Prezent/prezent-grid, src/Twig/GridRenderer.php
  • pawellen/listing, Renderer/ListingRenderer.php
  • Braunstetter/data-grid-bundle, src/GridRendererEngine.php
  • AnoDataGrid, DataGridExtension.php

Their common feature is layered grid themes:

  1. Configure default grid templates.
  2. Add per-grid or per-view overrides.
  3. Map a column type to a block name.
  4. Walk template inheritance.
  5. Merge or cache available blocks.
  6. Render the selected cell, header or filter block.

Several accessed unwrap(), getBlocks() or getParent() directly; others passed manually assembled block maps into renderBlock() or displayBlock().

Adjacent use cases

The audit also found block-library patterns that could benefit if they grow into multi-template composition:

  • iTop: plugin-contributed login blocks such as login_input, login_submit, login_form_footer and login_links; independently renders body, script, ready_script and css.
  • Email renderers: independently render subject, body_text and body_html blocks.
  • Runtime theme overlays: tenant branding, application skins, email themes, reports and configurable admin interfaces.
  • Extension-provided block libraries: enabled modules contribute blocks such as toolbar, field_text, dashboard_metric or login_footer.
  • Testing and preview tooling: render a block against an exact theme stack without generating a synthetic host template.

Important negative finding

Shopware-style plugin inheritance, and similar Drupal or Sylius layering, are not considered a direct fit. Those systems expect parent() to call the next plugin override. BlockChain deliberately keeps parent() inside the defining template’s normal lineage.

@fabpot
fabpot force-pushed the template-runtime-and-block-composition branch from e8e4cea to 5ba9acb Compare September 5, 2026 17:14
@twigphp twigphp deleted a comment from upsun-dispatch Bot Sep 5, 2026
@twigphp twigphp deleted a comment from upsun-dispatch Bot Sep 5, 2026
@twigphp twigphp deleted a comment from upsun-dispatch Bot Sep 5, 2026
@fabpot

fabpot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

@upsun-dispatch review

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 1 warning · 🔵 1 minor point · ⚪ 1 nitpick

🔍 Full review · 19 files reviewed

⚪ Nitpick

  • src/BlockChain.php:84streamBlock() is a generator, so getBlock() — and therefore the RuntimeError for an unknown block name — only runs on first iteration, not when the method is called. $chain->streamBlock('missing') returns normally and only throws once the caller starts consuming it (confirmed by running it), unlike renderBlock()/displayBlock() which fail immediately.
Verification
  • Chain composition is first-wins in template order including each template's parents — verified block-name order and that shared resolves to parent1, not theme2.
  • Freezing does not mutate the loaded template: after building a chain with parent=parent1, $twig->load('theme')->renderBlock('field', ['parent'=>'parent2']) still renders two.
  • The clone rebinds only blocks owned by the original, so use-provided trait blocks keep their own parent() lineage (aliased trait block renders trait/base).
  • The new block/macro prologue falls back to $this->macros when macroImportSource is null, leaving normal (unfrozen) templates unchanged.
  • Circular inheritance is caught by beginFreeze and raises the LogicException naming the entry template instead of recursing.

The diff adds tests/BlockChainTest.php (636 lines, most cases run in both yield modes) and a TemplateTest case for the renderParentBlock buffer unwinding, plus updated Node compile-output expectations; the tests and tests-phpunit-11 CI jobs run PHPUnit on PHP 8.1-8.5 (with a use_yield => true variant on 8.2) and a phpstan job covers src. No test covers exceptions escaping BlockChain construction unwrapped, nor streamBlock's missing globals beyond asserting the current asymmetry.

Review details
  • Commit: 5ba9acb
  • Model: claude-opus-5

View the full run

Comment thread src/BlockChain.php
throw new \TypeError(\sprintf('Block chain templates must be strings or "%s" instances, "%s" given.', TemplateWrapper::class, get_debug_type($template)));
}

$current = $template->unwrap()->freezeLineage($resolution);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning — Chain construction loses template/line context and bypasses Twig's error contract.

Parent resolution during construction runs user expressions ({% extends parent|filter %}, dynamic parent names) outside of any Twig error handling, so a non-Twig\Error\Error exception escapes new BlockChain(...) raw. Verified on this checkout: with 'theme' => '{% extends parent|boom %}...' where boom throws, $twig->render('theme') yields Twig\Error\RuntimeError: An exception has been thrown during the rendering of a template ("kaboom") in "theme" at line 1., while new BlockChain($twig, ['theme'], ['parent' => 'parent']) propagates a bare DomainException: kaboom with no template name or line. The same happens for a dynamic parent expression that evaluates to null (Template::load(): Argument #1 ... null given TypeError escapes construction, where rendering reports it as a RuntimeError in "theme" at line 1). Callers that catch Twig\Error\Error around chain construction will not catch these, and the failing template is not identified.

Comment thread src/BlockChain.php Outdated
*/
public function streamBlock(string $name, array $context = []): iterable
{
yield from $this->getBlock($name)->yieldBlock($name, $context, $this->blocks);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Minor — Silent output difference between the class's own render and stream paths.

streamBlock() passes the caller context through unchanged while renderBlock() and displayBlock() merge $this->env->getGlobals(). The same block rendered through the three entry points therefore sees different variables: with a global global => 'GLOBAL', renderBlock('field', ['local' => 'L']) produces L:GLOBAL but streaming the same block produces L:none (and raises an undefined-variable error under strict_variables). Switching from renderBlock() to streamBlock() for memory reasons silently changes output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant