Skip to content

How stages render

This page traces a stage from the storefront page load to the moment it’s mounted in the DOM. Useful when you’re debugging “why isn’t my popup appearing” or designing a custom theme integration.

P2Lab Stage extends a single Shopware template: the storefront layout footer. The plugin adds a hidden context container to the existing layout_footer_inner_container block:

{% sw_extends '@Storefront/storefront/layout/footer/footer.html.twig' %}
{% block layout_footer_inner_container %}
{{ parent() }}
{% if p2labStageContext %}
<div data-p2lab-stage
data-p2lab-stage-context="{{ context|json_encode }}"
hidden></div>
{% endif %}
{% endblock %}

The p2labStageContext extension is populated by a subscriber that listens to every storefront page load and computes the current page’s identity (page type, product / category / manufacturer / CMS page IDs, request URL, configured GDPR mode, the route URLs the JS plugin needs to call).

No other base templates are overridden. Popups are not embedded in Twig — they’re fetched client-side from /p2lab-stage/resolve.

P2LabStagePlugin is registered against the [data-p2lab-stage] selector. On every page where that container exists, Shopware’s PluginManager instantiates the plugin. The plugin then:

  1. Reads data-p2lab-stage-context into a JS object.
  2. Reads suppressed-popup IDs from sessionStorage / localStorage (subject to GDPR mode — see below).
  3. POSTs /p2lab-stage/resolve with the page context + suppressed IDs.
  4. For each popup returned, schedules it according to its trigger.

POST /p2lab-stage/resolve does the heavy lifting server-side.

{
"pageType": "product",
"requestUrl": "/product/spring-jacket",
"productId": "<uuid>",
"productIds": [],
"categoryId": "<uuid>",
"manufacturerId": "<uuid>",
"productStreamIds": [],
"cmsPageId": null,
"clientSuppressedIds": ["<popup-uuid-1>", "<popup-uuid-2>"]
}

Sales channel and customer group are not sent — they’re read server-side from SalesChannelContext, which can’t be lied to from the client.

Server-side, the plugin walks every active stage and applies these filters in order:

  1. Active toggle + schedule in range (active_from / active_to).
  2. Sales channel match.
  3. Customer group match (against the logged-in customer’s group, or the default group for anonymous).
  4. Rule evaluation (with rule_operator OR / AND).
  5. Categories (with includeChildren per row, taking the category’s path into account).
  6. Products / Manufacturers / Product streams / CMS pages matches.
  7. Page types + URL patterns with pages_operator.
  8. Client-suppressed IDs (popups the client says it’s already dismissed in this session).
  9. Server-side dismiss state (PHP session, when GDPR consent is missing for storage).

Surviving stages are sorted by priority.

The remaining list is then capped by the system settings:

  • displayMode = only-first (default) — only the first (highest-priority) stage is kept; the rest are dropped for this page.
  • displayMode = queue — up to maxPerSession stages are kept (or all of them if maxPerSession = 0).

This cap is a bandwidth optimization — the client also tracks its own session-wide distinct-popup counter.

{
"popups": [
{
"id": "<uuid>",
"displayType": "modal",
"triggerType": "delay",
"triggerValue": 5,
"frequencyType": "session",
"closeButton": true,
"overlayClose": true,
"permanentDismissButton": false,
"statsEnabled": true,
"html": "<!-- pre-rendered stage HTML -->"
}
],
"displayMode": "only-first",
"maxPerSession": 3
}

html is pre-rendered server-side from stage-content.html.twig — the client just inserts it into the DOM. Every layer’s positioning / styling / animation data lives in data-* attributes for the JS engines to read.

For each popup in the resolve response:

  • immediate → mount right away.
  • delaysetTimeout(triggerValue × 1000) then mount.
  • scroll → scroll listener on window, fires at the threshold, removes itself.

When the trigger fires, the popup is appended to document.body, the entrance animation runs, the parallax / animation / countdown / coupon engines wire up, and an impression event is sent.

While the popup is mounted, the client sends impression / CTA-click / close / permanent-dismiss events to POST /p2lab-stage/track:

{
"popupId": "<uuid>",
"eventType": "impression",
"pageType": "product",
"requestUrl": "/product/spring-jacket"
}

If statsEnabled is false on the stage, the server silently no-ops (defensive backstop — the client already skips most calls).

On dismissal, the client also calls POST /p2lab-stage/dismiss to persist the suppression server-side (PHP session — useful when client storage is unavailable):

{ "popupId": "<uuid>", "frequencyType": "session", "frequencyValue": null }

Unknown frequency types fall back to session so a misbehaving client can’t write arbitrary strings.

On consent change (visitor accepts cookies after a popup has been server-dismissed), the client calls GET /p2lab-stage/dismissed to fetch the server-side suppression list and migrate it into client storage.

Slider stages don’t use the resolve pipeline. The CMS element p2lab-stage-slider renders inline via Shopware’s normal Shopping Experiences pipeline — the slider Twig partial is part of the page’s HTML response. The slider’s JS plugin (P2LabStageSliderPlugin, registered against [data-p2lab-stage-slider]) handles autoplay, transitions, swipe, dots, arrows.

Every storefront route in the plugin is marked _httpCache: false — resolve / track / dismiss / dismissed must never be HTTP-cached because their responses vary per session, customer, and frequency state. The footer template itself stays cacheable because it only emits the static context container; the actual stage data is fetched dynamically.