eZ Platform / Ibexa DXP Analytics Guide | OpsBlu Docs

eZ Platform / Ibexa DXP Analytics Guide

Install tracking scripts, build data layers, and debug analytics on eZ Platform (Ibexa DXP) using Twig templates, Symfony bundles, and SiteAccess.

Analytics Architecture on eZ Platform

eZ Platform (now Ibexa DXP) is built on the Symfony framework. Analytics implementation follows Symfony conventions layered with eZ-specific content delivery:

  • Twig templates handle all frontend rendering with full access to the content object and location tree
  • Symfony bundles package reusable functionality including analytics services
  • SiteAccess configuration defines per-site or per-channel settings (language, design, analytics property IDs)
  • Content types and field definitions expose structured data for data layers
  • REST API provides headless content delivery for decoupled frontends
  • Event subscribers hook into kernel and content events for server-side tracking

The eZ content model separates content (Content objects) from placement (Location objects), so your data layer must reference both for complete context.


Installing Tracking Scripts

Add tracking scripts to your base Twig layout that all page templates extend:

{# templates/themes/my_theme/pagelayout.html.twig #}

<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
  <meta charset="utf-8">
  <title>{% block title %}{{ ez_content_name(content) }}{% endblock %}</title>

  {% block analytics_head %}
  <script async src="https://www.googletagmanager.com/gtag/js?id={{ siteaccess_config('analytics.property_id') }}"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', '{{ siteaccess_config("analytics.property_id") }}');
  </script>
  {% endblock %}

  {% block data_layer %}{% endblock %}
</head>
<body>
  {% block content %}{% endblock %}
</body>
</html>

Method 2: SiteAccess-Specific Configuration

Define analytics property IDs per SiteAccess in your configuration:

# config/packages/ezplatform.yaml (or ibexa.yaml)

ezplatform:
    system:
        site_en:
            analytics:
                property_id: 'G-ENGLISH'
                gtm_id: 'GTM-ENGLISH'
        site_fr:
            analytics:
                property_id: 'G-FRENCH'
                gtm_id: 'GTM-FRENCH'
        default:
            analytics:
                property_id: 'G-DEFAULT'

Access these values in Twig via a custom Twig extension or parameter bag:

<?php
// src/Twig/AnalyticsExtension.php

namespace App\Twig;

use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class AnalyticsExtension extends AbstractExtension
{
    public function __construct(
        private SiteAccessServiceInterface $siteAccessService,
        private array $analyticsConfig
    ) {}

    public function getFunctions(): array
    {
        return [
            new TwigFunction('siteaccess_config', [$this, 'getSiteAccessConfig']),
        ];
    }

    public function getSiteAccessConfig(string $key): string
    {
        $siteAccess = $this->siteAccessService->getCurrent()->name;
        return $this->analyticsConfig[$siteAccess][$key]
            ?? $this->analyticsConfig['default'][$key]
            ?? '';
    }
}

Method 3: Symfony Event Subscriber

Inject tracking scripts via a kernel response listener:

<?php
// src/EventSubscriber/AnalyticsSubscriber.php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class AnalyticsSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::RESPONSE => 'onKernelResponse'];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) return;

        $response = $event->getResponse();
        $content = $response->getContent();

        if (str_contains($content, '</head>')) {
            $script = '<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>';
            $content = str_replace('</head>', $script . '</head>', $content);
            $response->setContent($content);
        }
    }
}

Data Layer Implementation

Twig-Based Data Layer

Build the data layer directly in Twig using content and location objects:

{# templates/themes/my_theme/blocks/data_layer.html.twig #}

{% set content_type = content.contentInfo.contentTypeId %}
{% set location = content.contentInfo.mainLocation %}

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  'content_id': {{ content.id }},
  'content_type': '{{ ez_content_type_identifier(content) }}',
  'content_name': '{{ ez_content_name(content)|e('js') }}',
  'location_id': {{ location.id }},
  'location_depth': {{ location.depth }},
  'location_path': '{{ location.pathString }}',
  'language_code': '{{ content.contentInfo.mainLanguageCode }}',
  'section_id': {{ content.contentInfo.sectionId }},
  'published_date': '{{ content.contentInfo.publishedDate|date('Y-m-d') }}',
  'modified_date': '{{ content.contentInfo.modificationDate|date('Y-m-d') }}',
  'owner_id': {{ content.contentInfo.ownerId }}
});
</script>

Include it in the base layout:

{% block data_layer %}
  {% include 'themes/my_theme/blocks/data_layer.html.twig' %}
{% endblock %}

Content Type-Specific Data

Override the data layer block in content type templates to add type-specific fields:

{# templates/themes/my_theme/full/article.html.twig #}

{% extends 'themes/my_theme/pagelayout.html.twig' %}

{% block data_layer %}
  {{ parent() }}
  <script>
  window.dataLayer.push({
    'article_author': '{{ ez_field_value(content, 'author')|e('js') }}',
    'article_category': '{{ ez_field_value(content, 'category').text|default('')|e('js') }}',
    'article_tags': {{ ez_field_value(content, 'tags').tags|default([])|json_encode|raw }},
    'word_count': {{ ez_field_value(content, 'body').text|default('')|split(' ')|length }}
  });
  </script>
{% endblock %}

Service-Based Data Layer Builder

For complex data layer logic, use a dedicated service:

<?php
// src/Service/DataLayerBuilder.php

namespace App\Service;

use Ibexa\Contracts\Core\Repository\ContentService;
use Ibexa\Contracts\Core\Repository\LocationService;
use Ibexa\Contracts\Core\Repository\Values\Content\Content;

class DataLayerBuilder
{
    public function __construct(
        private ContentService $contentService,
        private LocationService $locationService
    ) {}

    public function build(Content $content): array
    {
        $location = $this->locationService->loadLocation(
            $content->contentInfo->mainLocationId
        );

        $data = [
            'content_id' => $content->id,
            'content_type' => $content->getContentType()->identifier,
            'location_depth' => $location->depth,
            'path_string' => $location->pathString,
        ];

        // Add children count for landing pages
        if ($content->getContentType()->identifier === 'landing_page') {
            $data['child_count'] = $this->locationService->getLocationChildCount($location);
        }

        return $data;
    }
}

Common Issues

SiteAccess Matching and Mixed Properties

When SiteAccess matching uses URI prefixes (/en, /fr), the wrong analytics property can load if matching rules overlap. Verify the active SiteAccess in your data layer:

<script>
window.dataLayer.push({
  'siteaccess': '{{ ezpublish.siteaccess.name }}',
  'siteaccess_group': '{{ ezpublish.siteaccess.groups|first }}'
});
</script>

Content View Cache and User Data

eZ Platform aggressively caches content views via HTTP cache (Varnish/Symfony HttpCache). User-specific data layer values will be cached and served to all users.

Solutions:

  • Use ESI (Edge Side Includes) for user-specific fragments:
{{ render_esi(controller('App\\Controller\\AnalyticsController::userDataLayer')) }}
  • Use client-side JavaScript to fetch user state from an uncached API endpoint

Field Type Value Access

Different field types require different Twig access patterns:

{# Text fields #}
{{ ez_field_value(content, 'title') }}

{# Rich text (returns XML) -- extract plain text #}
{{ ez_field_value(content, 'body').xml|striptags }}

{# Selection fields #}
{{ ez_field_value(content, 'category').selection|join(', ') }}

{# Relation fields (returns Content IDs) #}
{% set related = ez_field_value(content, 'related_articles').destinationContentIds %}

{# Date fields #}
{{ ez_field_value(content, 'publish_date').date|date('Y-m-d') }}

REST API and Headless Tracking

For decoupled frontends consuming the REST API, the server does not control script injection. The frontend application must handle analytics:

// Fetch content via REST API
const response = await fetch('/api/ezp/v2/content/objects/42', {
  headers: { 'Accept': 'application/json' }
});
const content = await response.json();

// Push to data layer in the SPA
window.dataLayer.push({
  content_id: content.ContentInfo.Content._id,
  content_type: content.ContentInfo.Content.ContentType._href.split('/').pop(),
  page_title: content.ContentInfo.Content.Name
});

Platform-Specific Considerations

Content/Location Duality: Every piece of content can exist in multiple locations (multi-placement). Your data layer should track both content_id (what the content is) and location_id (where it appears in the tree). The same article appearing in two site sections should generate different location-level data.

Translation Tracking: eZ Platform stores translations per content object. Track which translation is being viewed:

{% set current_language = app.request.locale %}
{% set available_languages = content.versionInfo.languageCodes %}
<script>
window.dataLayer.push({
  'content_language': '{{ current_language }}',
  'available_translations': {{ available_languages|json_encode|raw }}
});
</script>

Symfony Profiler in Dev: The Symfony debug toolbar in development mode can interfere with DOM-based analytics testing. Disable it for analytics QA or test in production mode (APP_ENV=prod).

Page Builder / Landing Pages: Ibexa DXP's Page Builder uses drag-and-drop blocks. Each block renders via its own Twig template. If you need per-block analytics (e.g., block visibility tracking), add data-block-type attributes to block wrapper elements and track via JS observers.

URL Aliases: eZ Platform generates URL aliases from content names. Renamed content creates new aliases while old ones redirect (301). Ensure your analytics tool handles these redirects correctly and does not double-count pageviews on redirect chains.

Permission System: Content visibility depends on user roles and section policies. If anonymous users cannot see certain content, those pages won't generate pageviews. Track permission-denied events separately via the 403 error handling in your Symfony configuration.