Applies to Shopware versions before 6.6
If you worked with Shopware 6 before version 6.6.0.0, you know the frustration: every product you create automatically gets assigned Shopware's default product detail layout, and there is no admin setting anywhere to change that default. If your project uses a custom CMS layout for product detail pages, you have to manually reassign every new product - or write something that does it for you.
Shopware 6.6 introduced a native setting for this. But if you are on an older version, or you want the default tied to your theme configuration rather than a buried system setting, a theme subscriber solves the problem cleanly.
This article breaks down exactly how to build one.
What We Are Solving
Shopware stores the CMS layout for each product in the cms_page_id column of the product table. When a product has null there, Shopware falls back to whatever is set in core.cms.default_product_cms_page in system config - and prior to 6.6, that value was always Shopware's built-in product detail page, with no UI to change it.
We want three things:
- A field in the theme configuration panel where an admin can select a CMS layout to use as the default for all products
- When that field is saved, automatically update all products that are currently using
nullor the previous default to the new selection - When any new product is created, automatically assign the theme's default layout to it
Step 1: Add the Config Field to theme.json
First, expose the setting in the theme admin panel by adding a field to theme.json:
"config": {
"fields": {
"sw-product-page-default-layout": {
"label": { "en-GB": "Default product detail layout" },
"type": "single-entity-id-select",
"custom": { "entity": "cms_page" },
"block": "Product Detail",
"editable": true
}
}
}
The single-entity-id-select type renders a searchable dropdown populated with CMS pages from the database. The selected value is stored as a UUID in the theme's config_values JSON column.
Step 2: The Subscriber
Create src/Storefront/Subscriber/ThemeConfigSubscriber.php inside your theme plugin:
<?php declare(strict_types=1);
namespace Acme\Theme\Storefront\Subscriber;
use Shopware\Core\Content\Product\ProductEvents;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWriteEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Shopware\Core\Defaults;
class ThemeConfigSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityRepository $themeRepository,
private readonly EntityRepository $productRepository,
private readonly SystemConfigService $config
) {}
public static function getSubscribedEvents(): array
{
return [
EntityWriteEvent::class => 'onEntityWrite',
ProductEvents::PRODUCT_WRITTEN_EVENT => 'onProductWritten',
];
}
}
Step 3: Handling Theme Config Save
The first method listens to EntityWriteEvent - a generic event fired before any entity is written. We need to filter it down to only our theme config being saved:
public function onEntityWrite(EntityWriteEvent $event): void
{
$themeIdsToUpdate = $event->getIds('theme');
if (empty($themeIdsToUpdate)) {
return;
}
$context = Context::createDefaultContext();
$criteria = new Criteria($themeIdsToUpdate);
$criteria->addFilter(new EqualsFilter('theme.name', 'AcmeTheme'));
if (!$this->themeRepository->search($criteria, $context)->getEntities()->first()) {
return;
}
// Confirm the write is coming from ThemeService::updateTheme()
$trace = $event->getWriteContext()->getExceptions()->getTraceAsString();
if (!str_contains($trace, 'updateTheme')) {
return;
}
// Read the new layout ID from the write payload
$payload = $event->getCommandsForEntity('theme')[0]->getPayload();
$newLayoutId = null;
if ($payload['config_values'] ?? null) {
$configValues = json_decode($payload['config_values'], true);
$newLayoutId = $configValues['sw-product-page-default-layout']['value'] ?? null;
}
// Remember what the default was BEFORE this change
$previousLayoutId = $this->config->get('core.cms.default_product_cms_page');
// Sync the new default into system config
// If the field was cleared, fall back to Shopware's built-in default
$this->config->set(
'core.cms.default_product_cms_page',
$newLayoutId ?? Defaults::CMS_PRODUCT_DETAIL_PAGE
);
// Update all products that were using null or the previous default
$criteria = new Criteria();
$criteria->addFilter(new MultiFilter(MultiFilter::CONNECTION_OR, [
new EqualsFilter('product.cmsPageId', null),
new EqualsFilter('product.cmsPageId', $previousLayoutId),
]));
$ids = array_keys($this->productRepository->search($criteria, $context)->getElements());
if (!empty($ids)) {
$this->productRepository->update(
array_map(fn($id) => ['id' => $id, 'cmsPageId' => $newLayoutId], $ids),
$context
);
}
}
Breaking this down:
$event->getIds('theme') - EntityWriteEvent covers writes to any entity. This filters it to only theme writes, and returns the IDs of affected theme records.
Theme name filter - Multiple themes may be installed. We only care about our own.
Trace string check - Theme config is written in multiple contexts (installation, CLI commands, etc.). The trace string check str_contains($trace, 'updateTheme') confirms that this specific write is coming from ThemeService::updateTheme() - the method triggered when an admin saves theme configuration in the panel. This prevents the subscriber from firing during theme installation or other write paths.
Reading the payload directly - Rather than re-fetching the theme after the write, we read config_values directly from the write command payload. This is the raw JSON string being written to the database, so we decode it and extract our field's value.
$previousLayoutId - Before updating system config to the new value, we capture the current value. We need it to find products that were already set to the old default, so they get migrated to the new one alongside the null products.
MultiFilter with OR - We target two groups of products: those with null (never had a layout assigned) and those explicitly set to the previous default (were already managed by this subscriber). Products with a manually chosen layout - set by an admin individually - are left untouched.
Defaults::CMS_PRODUCT_DETAIL_PAGE - When the field is cleared in the theme panel, $newLayoutId is null. Rather than leaving system config with the old value, we reset it to Shopware's built-in default constant.
Step 4: Handling New Product Creation
The second method ensures that any product created after the default is set also gets the right layout:
public function onProductWritten(EntityWrittenEvent $event): void
{
$updateData = [];
$currentDefault = false; // false = not yet fetched
foreach ($event->getWriteResults() as $result) {
if ($result->getEntityName() !== 'product' || $result->getOperation() !== 'insert') {
continue;
}
// Lazy-load the default layout ID - fetch only once per event
if ($currentDefault === false) {
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('theme.name', 'AcmeTheme'));
$theme = $this->themeRepository->search($criteria, Context::createDefaultContext())->getEntities()->first();
$configValues = $theme?->getConfigValues() ?? [];
$currentDefault = $configValues['sw-product-page-default-layout']['value'] ?? null;
}
$updateData[] = [
'id' => $result->getPrimaryKey(),
'cmsPageId' => $currentDefault,
];
}
if (!empty($updateData)) {
$this->productRepository->update($updateData, Context::createDefaultContext());
}
}
EntityWrittenEvent vs EntityWriteEvent - Note the different event. EntityWriteEvent fires before the write (used above to read payload data). EntityWrittenEvent fires after the write is complete - correct here because we need the product to already exist before we can update it.
$result->getOperation() === 'insert' - We only act on new products, not updates. Without this check, every product edit would reset its layout to the theme default, overriding manual selections.
Lazy fetch with $currentDefault = false - The theme config lookup happens at most once per event, regardless of how many products are being inserted in a single batch operation. false as the initial value allows distinguishing "not yet fetched" from a legitimate null value (theme field not configured).
Batch update - All inserts in the event are collected into a single update() call rather than one call per product.
Step 5: Register the Subscriber
In your plugin's services.xml:
<service id="Acme\Theme\Storefront\Subscriber\ThemeConfigSubscriber">
<argument type="service" id="theme.repository"/>
<argument type="service" id="product.repository"/>
<argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
<tag name="kernel.event_subscriber"/>
</service>
The Full Flow
Once everything is in place:
- Admin opens the theme configuration panel and selects a CMS layout in the "Default product detail layout" field
- On save,
onEntityWritefires - The subscriber reads the new layout ID from the write payload, saves it to system config, then bulk-updates all products with
nullor the previous default layout to the new one - From that point on, every newly created product - whether via admin, import, or API - automatically gets the selected layout assigned via
onProductWritten - Products with a manually selected layout are never touched
A Note on Shopware 6.6+
From Shopware 6.6.0.0 onward, you can set any CMS layout as the default for product detail pages directly from the Shopping Experiences list: open the context menu on any non-default layout and select "Set as default layout for product pages". If your project is on 6.6 or later, you do not need this subscriber for the core functionality. However, the theme-panel integration is still useful if you want the setting to live alongside your other theme configuration rather than buried in a separate settings screen.