Shopware 6 Analytics: Twig Templates, Symfony Events, | OpsBlu Docs

Shopware 6 Analytics: Twig Templates, Symfony Events,

Implement analytics on Shopware 6. Covers Twig template extension for script injection, Symfony event subscribers for server-side data layers,...

Analytics Architecture on Shopware 6

Shopware 6 is built on Symfony and uses Twig as its template engine. The platform separates Storefront (customer-facing) from Administration (backend). Analytics tracking integrates through four mechanisms:

  • Twig template inheritance allows plugins and themes to extend or override base templates. The base.html.twig template defines blocks (base_head, base_body) where tracking scripts are injected without modifying core files
  • Symfony event subscribers hook into the request lifecycle (kernel events) and Shopware-specific events (checkout, cart updates) to prepare analytics data server-side before templates render
  • Storefront JavaScript plugins are Shopware's client-side component system. Each plugin attaches to a DOM element and has access to lifecycle hooks (init, destroy). Analytics plugins listen for cart interactions, form submissions, and page transitions
  • Flow Builder is Shopware's visual automation engine that triggers actions on business events (order placed, customer registered). Flows can call webhooks to external analytics endpoints

Shopware 6 uses HTTP cache (Varnish-compatible reverse proxy cache) and ESI (Edge Side Includes) for dynamic fragments. Cached pages serve static HTML, so data layer values embedded in Twig are frozen until the cache invalidates. Dynamic values like cart count or logged-in state should use ESI blocks or client-side AJAX calls to the Store API.

For headless implementations (Shopware PWA / Frontends), analytics is handled entirely in the Vue.js or Nuxt frontend using the Store API responses.


Installing Tracking Scripts

Create a plugin that extends the base template to inject GTM in the head and body:

{# src/Resources/views/storefront/base.html.twig #}
{% sw_extends '@Storefront/storefront/base.html.twig' %}

{% block base_head %}
    {{ parent() }}

    {# Google Tag Manager #}
    {% set gtmId = config('MyAnalyticsPlugin.config.gtmContainerId') %}
    {% if gtmId %}
        <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
        new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
        j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
        'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
        })(window,document,'script','dataLayer','{{ gtmId }}');</script>
    {% endif %}
{% endblock %}

{% block base_body %}
    {% set gtmId = config('MyAnalyticsPlugin.config.gtmContainerId') %}
    {% if gtmId %}
        <noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{ gtmId }}"
        height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
    {% endif %}

    {{ parent() }}
{% endblock %}

Plugin Configuration for GTM ID

Store the container ID in your plugin's configuration so shop administrators can change it without code:

<!-- src/Resources/config/config.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/platform/trunk/src/Core/System/SystemConfig/Schema/config.xsd">
    <card>
        <title>Analytics Settings</title>
        <input-field type="text">
            <name>gtmContainerId</name>
            <label>GTM Container ID</label>
            <placeholder>GTM-XXXXXX</placeholder>
        </input-field>
    </card>
</config>

Access this value in Twig with config('MyAnalyticsPlugin.config.gtmContainerId') or in PHP with the SystemConfigService:

// src/Service/AnalyticsConfigService.php
namespace MyAnalyticsPlugin\Service;

use Shopware\Core\System\SystemConfig\SystemConfigService;

class AnalyticsConfigService
{
    public function __construct(private readonly SystemConfigService $config) {}

    public function getGtmId(?string $salesChannelId = null): ?string
    {
        return $this->config->get(
            'MyAnalyticsPlugin.config.gtmContainerId',
            $salesChannelId
        );
    }
}

Data Layer Setup

Symfony Event Subscriber for Page Data

Create an event subscriber that listens to Shopware's StorefrontRenderEvent and adds analytics data to the Twig template context:

// src/Subscriber/AnalyticsDataSubscriber.php
namespace MyAnalyticsPlugin\Subscriber;

use Shopware\Storefront\Event\StorefrontRenderEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class AnalyticsDataSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            StorefrontRenderEvent::class => 'onStorefrontRender',
        ];
    }

    public function onStorefrontRender(StorefrontRenderEvent $event): void
    {
        $request = $event->getRequest();
        $salesChannel = $event->getSalesChannelContext();
        $customer = $salesChannel->getCustomer();

        $analyticsData = [
            'page_language' => $request->getLocale(),
            'sales_channel' => $salesChannel->getSalesChannel()->getName(),
            'currency' => $salesChannel->getCurrency()->getIsoCode(),
            'customer_logged_in' => $customer !== null,
            'customer_group' => $salesChannel->getCurrentCustomerGroup()->getName(),
        ];

        $event->setParameter('analyticsData', $analyticsData);
    }
}

Register the subscriber in your plugin's service configuration:

<!-- src/Resources/config/services.xml -->
<service id="MyAnalyticsPlugin\Subscriber\AnalyticsDataSubscriber">
    <tag name="kernel.event_subscriber"/>
</service>

Render the data layer in a Twig template block that fires before the GTM snippet:

{# src/Resources/views/storefront/base.html.twig #}
{% block base_head %}
    {% if analyticsData is defined %}
        <script>
            window.dataLayer = window.dataLayer || [];
            dataLayer.push({{ analyticsData|json_encode(constant('JSON_HEX_TAG') b-or constant('JSON_HEX_APOS'))|raw }});
        </script>
    {% endif %}

    {{ parent() }}
{% endblock %}

Product Detail Page Data

Extend the product detail page template to push product data:

{# src/Resources/views/storefront/page/product-detail/index.html.twig #}
{% sw_extends '@Storefront/storefront/page/product-detail/index.html.twig' %}

{% block page_product_detail %}
    {% set product = page.product %}
    <script>
        window.dataLayer = window.dataLayer || [];
        dataLayer.push({
            'event': 'view_item',
            'ecommerce': {
                'items': [{
                    'item_id': '{{ product.productNumber }}',
                    'item_name': '{{ product.translated.name|e('js') }}',
                    'item_brand': '{{ product.manufacturer.translated.name|default('')|e('js') }}',
                    'item_category': '{{ product.seoCategory.translated.name|default('')|e('js') }}',
                    'price': {{ product.calculatedPrice.unitPrice }},
                    'currency': '{{ page.header.activeCurrency.isoCode }}'
                }]
            }
        });
    </script>

    {{ parent() }}
{% endblock %}

Ecommerce Tracking

Purchase Event via Checkout Finish Subscriber

Subscribe to the CheckoutOrderPlacedEvent to store purchase data for the confirmation page:

// src/Subscriber/OrderAnalyticsSubscriber.php
namespace MyAnalyticsPlugin\Subscriber;

use Shopware\Core\Checkout\Cart\Event\CheckoutOrderPlacedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;

class OrderAnalyticsSubscriber implements EventSubscriberInterface
{
    public function __construct(private readonly RequestStack $requestStack) {}

    public static function getSubscribedEvents(): array
    {
        return [
            CheckoutOrderPlacedEvent::class => 'onOrderPlaced',
        ];
    }

    public function onOrderPlaced(CheckoutOrderPlacedEvent $event): void
    {
        $order = $event->getOrder();
        $items = [];

        foreach ($order->getLineItems() as $lineItem) {
            $items[] = [
                'item_id' => $lineItem->getPayload()['productNumber'] ?? $lineItem->getIdentifier(),
                'item_name' => $lineItem->getLabel(),
                'price' => $lineItem->getUnitPrice(),
                'quantity' => $lineItem->getQuantity(),
            ];
        }

        $purchaseData = [
            'transaction_id' => $order->getOrderNumber(),
            'value' => $order->getAmountTotal(),
            'currency' => $event->getSalesChannelContext()->getCurrency()->getIsoCode(),
            'tax' => $order->getAmountNet() !== $order->getAmountTotal()
                ? round($order->getAmountTotal() - $order->getAmountNet(), 2)
                : 0,
            'items' => $items,
        ];

        $session = $this->requestStack->getSession();
        $session->set('analytics_purchase', json_encode($purchaseData));
    }
}

On the checkout finish page, render the stored purchase event:

{# src/Resources/views/storefront/page/checkout/finish/index.html.twig #}
{% sw_extends '@Storefront/storefront/page/checkout/finish/index.html.twig' %}

{% block page_checkout_finish %}
    {% set purchaseJson = app.session.get('analytics_purchase') %}
    {% if purchaseJson %}
        <script>
            window.dataLayer = window.dataLayer || [];
            dataLayer.push({ 'ecommerce': null });
            dataLayer.push({
                'event': 'purchase',
                'ecommerce': {{ purchaseJson|raw }}
            });
        </script>
        {# Clear to prevent duplicate fires on refresh #}
        {% do app.session.remove('analytics_purchase') %}
    {% endif %}

    {{ parent() }}
{% endblock %}

Add-to-Cart via Storefront JavaScript Plugin

Create a Storefront JS plugin that listens for the add-to-cart AJAX response:

// src/Resources/app/storefront/src/analytics-cart-plugin/analytics-cart.plugin.js
import Plugin from 'src/plugin-system/plugin.class';
import DomAccess from 'src/helper/dom-access.helper';

export default class AnalyticsCartPlugin extends Plugin {
    init() {
        this._registerEvents();
    }

    _registerEvents() {
        document.$emitter.subscribe('addToCart', this._onAddToCart.bind(this));
    }

    _onAddToCart(event) {
        const form = event.detail;
        if (!form) return;

        const productCard = form.closest('[data-product-id]');
        if (!productCard) return;

        const productId = productCard.dataset.productId;
        const productName = DomAccess.querySelector(productCard, '.product-name', false)?.textContent?.trim() || '';
        const priceEl = DomAccess.querySelector(productCard, '.product-price', false);
        const price = priceEl ? parseFloat(priceEl.textContent.replace(/[^0-9.]/g, '')) : 0;

        window.dataLayer = window.dataLayer || [];
        dataLayer.push({ 'ecommerce': null });
        dataLayer.push({
            'event': 'add_to_cart',
            'ecommerce': {
                'items': [{
                    'item_id': productId,
                    'item_name': productName,
                    'price': price,
                    'quantity': 1
                }]
            }
        });
    }
}

Register the plugin in your plugin's main entry file:

// src/Resources/app/storefront/src/main.js
import AnalyticsCartPlugin from './analytics-cart-plugin/analytics-cart.plugin';

const PluginManager = window.PluginManager;
PluginManager.register('AnalyticsCart', AnalyticsCartPlugin, '[data-add-to-cart]');

Build the Storefront assets:

cd custom/plugins/MyAnalyticsPlugin
bin/build-storefront.sh

Flow Builder Integration

Shopware's Flow Builder can send analytics events to external services. Create a custom flow action that posts order data to a measurement endpoint:

// src/Flow/SendAnalyticsAction.php
namespace MyAnalyticsPlugin\Flow;

use Shopware\Core\Content\Flow\Dispatching\Action\FlowAction;
use Shopware\Core\Content\Flow\Dispatching\StorableFlow;

class SendAnalyticsAction extends FlowAction
{
    public static function getName(): string
    {
        return 'action.send.analytics';
    }

    public function handleFlow(StorableFlow $flow): void
    {
        $orderNumber = $flow->getData('order')['orderNumber'] ?? null;
        if (!$orderNumber) return;

        // Post to GA4 Measurement Protocol or webhook
        // Implementation depends on your analytics backend
    }

    public static function getSubscribedEvents(): array
    {
        return [
            self::getName() => 'handleFlow',
        ];
    }
}

Common Errors

Error Cause Fix
Scripts not appearing after plugin install Storefront assets not rebuilt after adding Twig templates Run bin/build-storefront.sh and clear the Shopware cache with bin/console cache:clear
Data layer values show Twig syntax Twig template not recognized as an override Verify the template path matches the Storefront bundle structure and uses {% sw_extends %} instead of {% extends %}
Purchase event fires twice Checkout finish page reloaded by the customer Remove the session value after the first render with app.session.remove('analytics_purchase')
JS plugin init() not called Plugin not registered in main.js or the PluginManager selector does not match any DOM element Check PluginManager.register() call and verify the CSS selector matches elements on the page
HTTP cache serves stale data layer Full-page Varnish cache includes inline scripts with static values Use ESI tags ({{ render_esi() }}) for dynamic data layer fragments, or load the data layer via a client-side AJAX call to the Store API
config() returns null in Twig Plugin configuration key does not match the plugin's technical name The key format is PluginTechnicalName.config.fieldName. Check the plugin name in composer.json and the field name in config.xml
Product data undefined on listing pages page.product is only available on detail pages, not category listings On listing pages, iterate over page.listing.elements to build an item_list event instead
Sales Channel-specific GTM ID not loading Configuration saved at the global level but not for the specific Sales Channel In the admin, switch to the target Sales Channel before saving the configuration value
Storefront event emitter not triggering Custom JavaScript executes before the Shopware PluginManager initializes Wrap your event subscription in document.addEventListener('DOMContentLoaded', ...) or use the plugin system lifecycle

Performance Considerations

  • HTTP cache compatibility: Shopware's built-in HTTP cache (or Varnish) caches full pages. Inline data layer scripts with user-specific values (customer group, login state) prevent caching. Extract these into ESI fragments or load them via the Store API after page load
  • Storefront JS bundle size: Shopware compiles all Storefront plugins into a single JavaScript bundle. Analytics plugins should be lightweight (under 3KB uncompressed). Avoid importing external SDK libraries in the plugin; load them asynchronously via the GTM container instead
  • Twig rendering overhead: Each sw_extends block adds a template resolution step. Keep analytics templates simple with minimal Twig logic. Pre-compute values in the event subscriber and pass them as flat strings to avoid repeated Twig filter calls
  • Session storage for purchase data: Storing purchase analytics in the PHP session works for standard traffic volumes. For high-concurrency stores with Redis session handling, the overhead is negligible. For file-based sessions, ensure session writes are fast
  • Async GTM loading: The GTM snippet loads asynchronously by default. Do not add defer or type="module" to the GTM script tag, as these alter execution timing and can cause the data layer to be read before it is populated
  • Disable built-in analytics if unused: Shopware 6 includes a basic analytics module (Google Analytics app in the Store). If you manage all analytics through GTM, remove the built-in analytics app to avoid duplicate page view tracking