Verified on Shopware 6.7
The Symptom
You have two plugins. Both need to inject custom templates into the Shopware admin - custom Vue components, login overlays, embedded panels. The natural approach is to override administration/index.html.twig from both:
{# PluginA/Resources/views/administration/index.html.twig #}
{% sw_extends '@Administration/administration/index.html.twig' %}
{% block administration_templates %}
<acme-plugin-a-component></acme-plugin-a-component>
{% endblock %}
{# PluginB/Resources/views/administration/index.html.twig #}
{% sw_extends '@Administration/administration/index.html.twig' %}
{% block administration_templates %}
<acme-plugin-b-component></acme-plugin-b-component>
{% endblock %}
You open the admin. One component appears. The other doesn't. You swap installation order. Now the other appears and the first disappears.
Only one plugin wins. Always.
Why sw_extends Usually Works Fine
Before explaining why it breaks here specifically, it helps to understand what sw_extends actually does - because it is not Twig's native extends.
sw_extends is a custom Twig tag handled by ExtendsTokenParser. When Twig encounters it during compilation, the parser calls TemplateFinder::find() to resolve the parent, then replaces the tag with a standard {% extends %} pointing at the resolved path:
// Shopware\Core\Framework\Adapter\Twig\TokenParser\ExtendsTokenParser
public function parse(Token $token): Node
{
$stream = $this->parser->getStream();
$source = $stream->getSourceContext()->getName(); // currently-compiling template
$options = $this->getOptions($stream);
// Resolves to the NEXT template in the bundle hierarchy
$parent = $this->finder->find($options['template'], false, $source);
do { $next = $stream->next(); }
while (!$next->test(Token::BLOCK_END_TYPE));
// Replaces sw_extends with a standard extends pointing to the resolved parent
$tokens = [
new Token(Token::BLOCK_START_TYPE, '', 2),
new Token(Token::NAME_TYPE, 'extends', 2),
new Token(Token::STRING_TYPE, $parent, 2),
new Token(Token::BLOCK_END_TYPE, '', 2),
];
if ($this->shouldEndFile($options['scopes'], $source)) {
$tokens[] = new Token(Token::EOF_TYPE, '', $token->getLine());
}
$stream->injectTokens($tokens);
return new EmptyNode($token->getLine());
}
The TemplateFinder::find() method - when called with a $source (as it is from ExtendsTokenParser) - builds a rotated namespace queue that skips the currently-compiling bundle, preventing infinite inheritance loops:
// Shopware\Core\Framework\Adapter\Twig\TemplateFinder
public function find(string $template, $ignoreMissing = false, ?string $source = null): string
{
$templatePath = $this->getTemplateName($template);
$sourcePath = $source ? $this->getTemplateName($source) : null;
$sourceBundleName = $source ? $this->getSourceBundleName($source) : null;
$originalTemplate = $source ? null : $template;
$queue = $this->getNamespaceHierarchy();
$modifiedQueue = $queue;
// Rotate queue to start AFTER the current source bundle - prevents infinite loops
if ($sourceBundleName !== null && $sourcePath === $templatePath) {
$index = array_search($sourceBundleName, $modifiedQueue, true);
if (is_int($index)) {
$modifiedQueue = array_merge(
array_slice($modifiedQueue, $index + 1),
array_slice($queue, 0, $index)
);
}
}
foreach ($modifiedQueue as $prefix) {
$name = '@' . $prefix . '/' . $templatePath;
if ($name === $originalTemplate) continue;
if (!$this->loader->exists($name)) continue;
return $name; // First match wins
}
// ...
}
The namespace hierarchy is built by BundleHierarchyBuilder, sorting all registered Shopware bundles by getTemplatePriority():
// Shopware\Core\Framework\Adapter\Twig\NamespaceHierarchy\BundleHierarchyBuilder
public function buildNamespaceHierarchy(array $namespaceHierarchy): array
{
$bundles = [];
foreach ($this->kernel->getBundles() as $bundle) {
if (!$bundle instanceof Bundle) continue;
if (!is_dir($bundle->getPath() . '/Resources/views')) continue;
$bundles[$bundle->getName()] = $bundle->getTemplatePriority();
}
$bundles = array_reverse($bundles); // Shopware registers bundles in reverse order
asort($bundles); // Lower priority number = earlier = higher precedence
// ...
}
For storefront templates this chain works exactly as intended. PluginA extends PluginB which extends Storefront core. Each plugin calls {{ parent() }} because the parent blocks contain meaningful HTML worth preserving.
What Breaks in the Admin
The rendering entry point
The admin index is served by AdministrationController::index():
// Shopware\Administration\Controller\AdministrationController
public function index(Request $request, Context $context): Response
{
$template = $this->finder->find('@Administration/administration/index.html.twig');
// ^ No $source argument
return $this->render($template, [...]);
}
This is the initial lookup - no $source is passed. With $originalTemplate set, the finder iterates the entire namespace hierarchy and returns the path of the single first bundle that provides this template. One template path. One winner.
The core template
{# vendor/shopware/administration/Resources/views/administration/index.html.twig #}
{% extends '@Administration/administration/layout/base.html.twig' %}
{% block administration_head_scripts %}
<script nonce="{{ cspNonce }}">
window._features_ = {{ features|json_encode|raw }};
</script>
{% endblock %}
{% block administration_content %}
<div id="app"></div>
{% block administration_templates %}{% endblock %}
{{ vite_entry_script_tags('administration', { attr: { nonce: cspNonce } }) }}
{% block administration_login_scripts %}{% endblock %}
<script nonce="{{ cspNonce }}">
window.startApplication = () => { Shopware.Application.start({...}); };
</script>
{% endblock %}
{% block administration_templates %}{% endblock %} is the block every plugin uses to inject custom Vue component tags into the SPA. It is completely empty in core. That is the root of the problem.
Why the block chain collapses
In Twig's inheritance model, the most-derived template's block wins. The reason plugins consistently call {{ parent() }} for storefront blocks is that the parent blocks contain valuable content - base HTML, canonical tags, meta information you do not want to lose.
administration_templates has no parent content whatsoever. The block is empty. No plugin calls {{ parent() }}:
{% sw_extends '@Administration/administration/index.html.twig' %}
{% block administration_templates %}
<acme-two-factor-login></acme-two-factor-login>
{#
Why call {{ parent() }} here?
The parent block is empty. You wrote the whole thing yourself.
#}
{% endblock %}
So even when sw_extends chains templates perfectly - PluginA → PluginB → Core - each plugin's administration_templates block replaces the previous one entirely. Only the highest-priority plugin in the hierarchy has its block rendered. Every other plugin is silently discarded.
If a third-party plugin (an authentication plugin, for example) occupies that highest-priority slot, your own plugins' admin templates never appear in the HTML regardless of what you do with sw_extends.
The scope mechanism is not the cause
ExtendsTokenParser has a shouldEndFile mechanism that can inject a Twig EOF token, truncating the template at the extends line:
private function shouldEndFile(array $scopes, string $source): bool
{
return !\array_intersect($this->templateScopeDetector->getScopes(), $scopes)
&& !str_starts_with($source, '@Storefront');
}
Storefront templates are unconditionally exempt. Admin templates depend on scope matching. In practice, both the request and the default sw_extends usage resolve to ['default'] scope - array_intersect returns a truthy result, shouldEndFile returns false, and no early EOF is injected.
The inheritance chain itself is structurally fine. The failure is in the block semantics: multiple plugins independently own the same empty block, and Twig's winner-takes-all model means only one contribution survives.
The Fix: Post-Render Block Aggregation
The correct solution abandons Twig inheritance for this use case entirely. Instead of forcing all plugins to share one block through a chain that none of them controls, render each plugin's administration_templates block independently and inject all results into the HTML response.
The subscriber
class AdminIndexTemplateInjectSubscriber implements EventSubscriberInterface
{
private const INJECT_AFTER = '<div id="app"></div>';
public function __construct(
private readonly AdminIndexContributorsFinder $contributorsFinder,
private readonly Environment $twig,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => ['onKernelResponse', -128]];
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) return;
if ($event->getRequest()->attributes->get('_route') !== 'administration.index') return;
$response = $event->getResponse();
if (!str_contains($response->headers->get('Content-Type', ''), 'text/html')) return;
$body = (string) $response->getContent();
if ($body === '') return;
$bundles = $this->contributorsFinder->getOrderedContributorBundleNames();
if ($bundles === []) return;
$aggregated = $this->renderAllBlocks($bundles, $event->getRequest());
if ($aggregated === '') return;
$pos = strpos($body, self::INJECT_AFTER);
if ($pos === false) return;
$insertPos = $pos + strlen(self::INJECT_AFTER);
$response->setContent(
substr($body, 0, $insertPos)
. "\n " . $aggregated . "\n "
. substr($body, $insertPos)
);
}
private function renderAllBlocks(array $bundleNames, Request $request): string
{
$context = [
'app' => ['request' => $request],
'cspNonce' => $request->attributes->get(PlatformRequest::ATTRIBUTE_CSP_NONCE, ''),
];
$out = [];
foreach ($bundleNames as $bundleName) {
$templateName = '@' . $bundleName . '/administration/index.html.twig';
try {
$template = $this->twig->load($templateName);
if (!$template->hasBlock('administration_templates')) continue;
$block = $template->renderBlock('administration_templates', $context);
if (trim($block) !== '') $out[] = $block;
} catch (\Throwable) {
continue;
}
}
return implode("\n ", $out);
}
}
renderBlock() renders only that specific block in the context of the given template as the most-derived class - it does not duplicate <html>, <head>, or any surrounding structure.
The contributor finder
class AdminIndexContributorsFinder
{
private const ADMIN_INDEX_TEMPLATE = 'administration/index.html.twig';
private const CORE_ADMIN_BUNDLE = 'Administration';
public function __construct(
private readonly KernelInterface $kernel,
private readonly LoaderInterface $twigLoader,
private readonly ThemeInheritanceBuilder $themeInheritanceBuilder,
private readonly DatabaseSalesChannelThemeLoader $themeLoader,
private readonly Connection $connection,
) {}
/** @return list<string> */
public function getOrderedContributorBundleNames(): array
{
$bundles = $this->buildBundleHierarchy();
$themes = $this->getThemesForFirstStorefrontSalesChannel();
$orderedNames = $themes !== []
? array_keys($this->themeInheritanceBuilder->build($bundles, $themes))
: array_keys($bundles);
$result = [];
foreach ($orderedNames as $name) {
if ($name === self::CORE_ADMIN_BUNDLE) continue;
if ($this->twigLoader->exists('@' . $name . '/' . self::ADMIN_INDEX_TEMPLATE)) {
$result[] = $name;
}
}
return $result;
}
private function buildBundleHierarchy(): array
{
$bundles = [];
foreach ($this->kernel->getBundles() as $bundle) {
if (!$bundle instanceof Bundle) continue;
if (!is_dir($bundle->getPath() . '/Resources/views')) continue;
$bundles[$bundle->getName()] = $bundle->getTemplatePriority();
}
$bundles = array_reverse($bundles);
asort($bundles);
return $bundles;
}
private function getThemesForFirstStorefrontSalesChannel(): array
{
$typeId = hex2bin(str_replace('-', '', Defaults::SALES_CHANNEL_TYPE_STOREFRONT));
$id = $this->connection->fetchOne(
'SELECT LOWER(HEX(id)) FROM sales_channel WHERE type_id = :t LIMIT 1',
['t' => $typeId]
);
if (!$id) return [];
$themeNames = $this->themeLoader->load((string) $id);
if (!$themeNames) return [];
return array_merge(
array_fill_keys($themeNames, true),
[StorefrontPluginRegistry::BASE_THEME_NAME => true]
);
}
}
DI wiring
<services>
<service id="Acme\AdminTwigHelper\Service\AdminIndexContributorsFinder">
<argument type="service" id="kernel"/>
<argument type="service" id="twig.loader"/>
<argument type="service" id="Shopware\Storefront\Theme\Twig\ThemeInheritanceBuilderInterface"/>
<argument type="service" id="Shopware\Storefront\Theme\DatabaseSalesChannelThemeLoader"/>
<argument type="service" id="Doctrine\DBAL\Connection"/>
</service>
<service id="Acme\AdminTwigHelper\Subscriber\AdminIndexTemplateInjectSubscriber">
<argument type="service" id="Acme\AdminTwigHelper\Service\AdminIndexContributorsFinder"/>
<argument type="service" id="twig"/>
<tag name="kernel.event_subscriber"/>
</service>
</services>
How Each Plugin Uses It
With this aggregation plugin active, each contributing plugin provides administration/index.html.twig with a populated administration_templates block. No sw_extends needed - the aggregator bypasses inheritance and renders the block directly:
{# PluginA/Resources/views/administration/index.html.twig #}
{% block administration_templates %}
<acme-plugin-a-component></acme-plugin-a-component>
{% endblock %}
{# PluginB/Resources/views/administration/index.html.twig #}
{% block administration_templates %}
<acme-plugin-b-component></acme-plugin-b-component>
{% endblock %}
Both components appear in the rendered HTML, in bundle priority order, immediately after <div id="app"></div>.
Edge Cases
Third-party plugins that use sw_extends: If a third-party plugin already wins the template hierarchy and renders administration_templates through the normal Twig chain, you may inject its block twice. Exclude it by bundle name in getOrderedContributorBundleNames():
private const EXCLUDED_BUNDLES = ['SomeThirdPartyPlugin'];
// in the loop:
if (in_array($name, self::EXCLUDED_BUNDLES, true)) continue;
CSP nonce: Pass cspNonce from PlatformRequest::ATTRIBUTE_CSP_NONCE in the render context. Any <script> tags inside administration_templates require it.
renderBlock() scope: The method renders exactly the block content - not the full template. No surrounding HTML is duplicated.
Summary
The failure comes down to a mismatch between Twig's inheritance model and the intended use of administration_templates:
- Twig inheritance is for overriding - one winner, with optional delegation via
{{ parent() }} administration_templatesis for aggregation - every plugin contributes independently, the block is empty in core, calling{{ parent() }}adds nothing
sw_extends builds a structurally correct chain. The chain is not the problem. The problem is that an empty block with independent contributors and no {{ parent() }} calls can only ever surface one plugin's output.
The fix: step outside Twig inheritance. Collect contributing plugins after rendering, render each plugin's block independently, inject all results into the response. Unbounded number of plugins, no coordination required between them.
Building a multi-plugin Shopware admin and running into issues like this? We can help.