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
Method 1: Twig Base Layout (Recommended)
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.