Skip to content

Replacement flow internals

This page documents the runtime behaviour for readers who want to understand or extend the plugin. None of this is needed to configure it.

The plugin listens to three storefront page-loaded events and two entity-loaded events:

EventHandlerPage kind
ProductPageLoadedEventonProductPageLoadedProduct detail
NavigationPageLoadedEventonNavigationPageLoadedCategory / listing
LandingPageLoadedEventonLandingPageLoadedLanding page
CategoryEvents::CATEGORY_LOADED_EVENTonCategoryEntityLoadedCMS navigation widget (XHR)
LandingPageEvents::LANDING_PAGE_LOADED_EVENTonLandingPageEntityLoadedLanding page entity load

All five live in Subscriber\PageLoadedSubscriber.

The page-loaded handlers cover normal storefront navigation. The two entity-loaded handlers cover the CMS navigation widget XHR (the call your storefront makes to lazy-load category content) and direct landing-page entity loads, so the replacement behaves consistently whether the layout is rendered as part of the main page or as part of a widget XHR.

For every relevant request, the handler does roughly:

salesChannelId = resolve from request
1. Has the base layout passed the replacement-mode gate?
- Product page → ProductPageReplaceModeEnum (4 values)
- Category page → NavigationPageReplaceModeEnum (3 values)
- Landing page → LandingPageReplaceModeEnum (3 values)
If no → return.
2. alternativeTypes = SettingsService::getAlternativeReplaceTypes(baseType, salesChannelId)
If empty → return.
3. Walk every p2lab_cms_assigner_cms_page_to_rule whose cmsPage.type IN alternativeTypes:
- Ordered by rule.priority ASC (Shopware's stock ordering).
- In pages of 100 (LIMIT_PER_REQUEST), up to 500 total (LIMIT_TOTAL).
- Group rows by cmsPageId — a single layout may have multiple rules attached.
4. For each cmsPageId, call RuleConditionService::match(rules, page, salesChannelContext):
- All attached rules must match (AND semantics).
- A rule is built from its conditions, filtered to ConditionNameEnum::CONDITIONS and CONTAINERS.
- The matcher dispatches on page type → ProductRuleScope / CategoryRuleScope / LandingRuleScope.
5. First match wins. The plugin loads the CmsPage via SalesChannelCmsPageLoaderInterface
with an EntityResolverContext built around the product / category / landingPage,
then assigns it back to the page object.

The p2lab_cms_assigner_cms_page_to_rule table

Section titled “The p2lab_cms_assigner_cms_page_to_rule table”

The plugin stores rule ↔ layout assignments in a single junction table. Each row holds:

  • id — primary key.
  • cms_page_id + cms_page_version_id — the alternative layout.
  • rule_id — the rule it depends on.

A layout with N attached rules has N rows; a rule attached to M layouts has M rows. Read in either direction.

The plugin defines three custom rule scopes (under Rule\Scope\) so its custom conditions (and Shopware’s stock ones) can introspect what’s being viewed:

  • ProductRuleScope — holds the SalesChannelProductEntity plus the sales-channel context. Used by product page handlers and shared by InCategoryRule / InCategoryWithChildrenRule when they evaluate against a product’s categoryIds.
  • CategoryRuleScope — holds the categoryId plus the sales-channel context. Exposes isHomepage() (used by IsHomepageRule).
  • LandingRuleScope — holds the LandingPage page plus the sales-channel context.

All three inherit from AbstractRuleScope, which holds the SalesChannelContext and the category repository (so rules that traverse the tree can search).

The plugin ships three custom rule classes (under Rule\):

  • IsHomepageRule — boolean check against CategoryRuleScope::isHomepage(). Returns false on non-category scopes.
  • InCategoryRule — UUID comparison against the product’s categoryIds or the category’s own id.
  • InCategoryWithChildrenRule extends InCategoryRule — additionally walks the category path (Shopware stores the ancestor chain as |id1|id2|id3|) to match descendants.

There is also Rule\Container\MatchAllRule — a stand-in for Shopware’s “All line items container”, whose product-only semantics make no sense in a CMS layout selection context. The plugin substitutes its own implementation that returns true only when every child rule matches against the current scope.

ConditionNameEnum defines two arrays: CONTAINERS and CONDITIONS. Anything not in those arrays is silently ignored when a rule is built. The exclusion list is wide on purpose — many of Shopware’s stock conditions (cart totals, line item attributes, shipping method) target the checkout flow and have no meaning when picking a layout for a page that hasn’t been built yet.

To find out exactly what is supported, see Custom rule conditions.

onCategoryEntityLoaded is the most subtle. It runs on category.loaded events and only acts when the request looks like a CMS navigation widget XHR — the path matches ^/widgets/cms/navigation/<32-hex>$ and the query string carries navigationId + cms-page-id. This is how the plugin keeps replacement consistent when the storefront fetches a category’s content via the widget XHR (in addition to the initial page render).

It does the same mode check as the main onNavigationPageLoaded handler before overriding the entity’s cmsPageId.

onLandingPageEntityLoaded runs on landing_page.loaded events and applies the default landing page layout if the landing page has no layout of its own. This is the entry point for the Landing page card’s default-layout setting.

  • It does not modify the CMS page itself — just which CmsPageEntity is rendered.
  • It does not touch the category / product / landing-page entity in the database — the override happens in memory, per request.
  • It does not cache its decisions; every request re-evaluates the rules. The repository search hits Shopware’s standard caches, but the rule evaluation is per-request.