Contao Analytics: Symfony Templates, Insert Tags, | OpsBlu Docs

Contao Analytics: Symfony Templates, Insert Tags,

Implement analytics on Contao CMS. Covers page layout script injection, Symfony event listeners, Contao insert tags for dynamic data layers, and...

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 (.html5 files) 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

Contao's page layout system provides dedicated fields for external scripts. In the Contao backend:

  1. Navigate to Themes > Page Layouts
  2. Edit your layout and scroll to Custom layout sections
  3. 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_BODY are cached. Use the page layout's custom code fields for scripts that should persist across cache rebuilds
  • Async loading: Always use async on 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 requestIdleCallback API to avoid blocking LCP
  • Symfony Profiler: In dev mode, use the Symfony web profiler toolbar to inspect which hooks fire and in what order