Analytics Architecture on Contao
Contao is a Symfony-based PHP CMS that uses a page layout system to control which scripts load on which pages. Analytics tracking integrates through three mechanisms:
- Page layouts define header and footer script zones where tracking snippets are injected globally
- Contao templates (
.html5files) allow page-type-specific tracking code in the theme's template directory - Symfony event listeners hook into the request lifecycle for server-side data preparation
- Insert tags (
{{env::page_title}},{{page::alias}}) inject dynamic content values into templates without PHP
Contao's built-in caching system (HTTP cache with Symfony HttpKernel) serves full-page caches, which means data layer values baked into HTML remain static until the cache expires. Dynamic values need client-side resolution or ESI (Edge Side Includes).
For ecommerce tracking, Contao relies on the Isotope eCommerce extension, which provides product catalogs, cart management, and checkout workflows with its own event hooks.
Installing Tracking Scripts
Via Page Layout (Recommended)
Contao's page layout system provides dedicated fields for external scripts. In the Contao backend:
- Navigate to Themes > Page Layouts
- Edit your layout and scroll to Custom layout sections
- Add your GTM snippet to the Additional
<head>tags field
For direct template control, edit the fe_page.html5 template:
<!-- templates/fe_page.html5 -->
<!DOCTYPE html>
<html lang="<?= $this->language ?>">
<head>
<?= $this->head ?>
<!-- Google Tag Manager -->
<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','GTM-XXXXXX');</script>
</head>
<body>
<!-- GTM noscript -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<?= $this->body ?>
</body>
</html>
Via Contao Manager Extension
The contao-analytics-bundle provides a backend interface for managing tracking IDs without template editing:
composer require terminal42/contao-analytics-bundle
This adds a settings panel under System > Settings where you enter your GA4 Measurement ID or GTM Container ID.
Building a Data Layer with Insert Tags
Contao's insert tag system lets you inject dynamic values directly in templates without writing PHP. Use these in your fe_page.html5 template:
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'page_title': '{{page::pageTitle}}',
'page_alias': '{{page::alias}}',
'page_language': '{{page::language}}',
'page_layout': '{{page::layout}}',
'root_page': '{{page::rootTitle}}',
'environment': '{{env::host}}'
});
</script>
For article-level tracking on content pages, use the article template:
<!-- templates/mod_article.html5 -->
<?php $this->extend('block_searchable'); ?>
<?php $this->block('content'); ?>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'article_view',
'article_id': '<?= $this->id ?>',
'article_title': '<?= htmlspecialchars($this->title, ENT_QUOTES) ?>',
'article_section': '<?= $this->inColumn ?>'
});
</script>
<?= $this->body ?>
<?php $this->endblock(); ?>
Symfony Event Listeners for Server-Side Data
Since Contao 4.x runs on Symfony, you can use event listeners to prepare analytics data before the template renders:
// src/EventListener/AnalyticsDataListener.php
namespace App\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\PageModel;
use Contao\LayoutModel;
/**
* @Hook("generatePage")
*/
class AnalyticsDataListener
{
public function __invoke(PageModel $pageModel, LayoutModel $layout): void
{
$analyticsData = json_encode([
'page_type' => $pageModel->type,
'page_id' => $pageModel->id,
'page_alias' => $pageModel->alias,
'language' => $pageModel->language,
'root_id' => $pageModel->rootId,
'cache_enabled' => (bool) $pageModel->includeCache,
], JSON_HEX_TAG | JSON_HEX_APOS);
$GLOBALS['TL_HEAD'][] = sprintf(
'<script>window.dataLayer=window.dataLayer||[];dataLayer.push(%s);</script>',
$analyticsData
);
}
}
Register the listener in your service configuration:
# config/services.yaml
services:
App\EventListener\AnalyticsDataListener:
tags:
- { name: contao.hook, hook: generatePage }
Isotope Ecommerce Tracking
Contao's primary ecommerce solution is the Isotope eCommerce extension. Track purchases on the order confirmation page using Isotope's post-checkout hook:
// src/EventListener/IsotopeCheckoutListener.php
namespace App\EventListener;
use Isotope\Model\ProductCollection\Order;
class IsotopeCheckoutListener
{
public function onPostCheckout(Order $order): void
{
$items = [];
foreach ($order->getItems() as $item) {
$items[] = [
'item_id' => $item->getSku(),
'item_name' => $item->getName(),
'price' => $item->getPrice(),
'quantity' => $item->quantity,
];
}
$data = json_encode([
'event' => 'purchase',
'ecommerce' => [
'transaction_id' => $order->getDocumentNumber(),
'value' => $order->getTotal(),
'currency' => $order->getCurrency(),
'items' => $items,
],
], JSON_HEX_TAG);
$GLOBALS['TL_BODY'][] = sprintf(
'<script>window.dataLayer=window.dataLayer||[];dataLayer.push(%s);</script>',
$data
);
}
}
Register with the Isotope hook:
// contao/config/config.php
$GLOBALS['ISO_HOOKS']['postCheckout'][] = [
\App\EventListener\IsotopeCheckoutListener::class, 'onPostCheckout'
];
For cart interactions (add-to-cart, remove), use the addProductToCollection and deleteItemFromCollection hooks:
$GLOBALS['ISO_HOOKS']['addProductToCollection'][] = function ($product, $quantity) {
// Push add_to_cart event to session for next page load
$_SESSION['analytics_events'][] = [
'event' => 'add_to_cart',
'ecommerce' => [
'items' => [[
'item_id' => $product->getSku(),
'item_name' => $product->getName(),
'price' => $product->getPrice(),
'quantity' => $quantity,
]]
]
];
};
Form Tracking
Contao's built-in form generator uses the processFormData hook. Track form submissions server-side:
// src/EventListener/FormTrackingListener.php
/**
* @Hook("processFormData")
*/
class FormTrackingListener
{
public function __invoke(array $submittedData, array $formData, ?array $files): void
{
$_SESSION['analytics_events'][] = [
'event' => 'form_submit',
'form_id' => $formData['formID'],
'form_title' => $formData['title'] ?? 'unknown',
];
}
}
Then flush queued events on the next page load in your generatePage hook.
Multilingual and Multi-Domain Tracking
Contao natively supports multiple root pages for different languages and domains. Each root page can have its own analytics configuration:
// In your generatePage hook, check the root page
$rootPage = PageModel::findByPk($pageModel->rootId);
if ($rootPage->dns === 'de.example.com') {
// German property
$measurementId = 'G-GERMAN123';
} else {
// Default property
$measurementId = 'G-DEFAULT456';
}
For cross-domain tracking between language subdomains, configure linked domains in your GTM GA4 tag or add the linker parameter:
gtag('config', 'G-XXXXXX', {
'linker': {
'domains': ['example.com', 'de.example.com', 'fr.example.com']
}
});
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Scripts missing on cached pages | Contao HTTP cache serves static HTML without dynamic TL_HEAD |
Use Cache-Control: no-store on tracked pages or inject scripts via page layout fields |
| Insert tags not resolving | Using insert tags in JavaScript context without proper escaping | Use PHP template syntax (<?= ?>) instead of insert tags for JS values |
| Isotope purchase fires twice | Order confirmation page reloaded or back-button navigated | Check $order->isLocked() before pushing purchase event |
| Data layer values HTML-encoded | PHP htmlspecialchars applied to JSON output |
Use JSON_HEX_TAG | JSON_HEX_APOS flags in json_encode |
| GTM blocked by CSP | Contao's response.csp configuration missing GTM domains |
Add *.googletagmanager.com and *.google-analytics.com to script-src in config/config.yaml |
| Form hook not triggering | Listener not registered in service container | Verify contao.hook tag in services.yaml and clear cache with vendor/bin/contao-console cache:clear |
| Different data on dev vs production | Environment-specific root pages | Use {{env::host}} insert tag or Symfony %kernel.environment% to conditionally load scripts |
| Maintenance mode still tracks | Contao maintenance page bypasses layout scripts | Check $pageModel->maintenanceMode in your analytics listener |
Performance Considerations
- Contao HTTP cache: Full-page cache means scripts in
TL_HEAD/TL_BODYare cached. Use the page layout's custom code fields for scripts that should persist across cache rebuilds - Async loading: Always use
asyncon external script tags. Contao's$GLOBALS['TL_HEAD']array renders synchronously in<head> - Combine scripts: Contao's asset combiner (
config/config.yaml > contao.assets) can merge your analytics scripts with other JS assets - Lazy third-party tags: Load analytics scripts after user interaction using the
requestIdleCallbackAPI to avoid blocking LCP - Symfony Profiler: In dev mode, use the Symfony web profiler toolbar to inspect which hooks fire and in what order