Craft CMS Analytics: Twig Templates, Plugin Hooks, | OpsBlu Docs

Craft CMS Analytics: Twig Templates, Plugin Hooks,

Implement analytics tracking on Craft CMS. Covers Twig template script injection, Commerce plugin data layer, custom module hooks, and GTM integration...

Analytics Architecture on Craft CMS

Craft CMS uses the Twig templating engine with a layout-based inheritance system. Templates extend a base layout via {% extends %}, and child templates override named {% block %} regions. This inheritance model gives you a single location to inject tracking scripts across every page.

Analytics data flows through three layers in a Craft project:

  • Twig templates render structured data from element queries (entries, categories, products) directly into dataLayer.push() calls.
  • Craft Commerce (if installed) provides order, cart, and product variant objects that map to GA4 ecommerce schemas.
  • Custom modules (PHP, loaded via Composer) hook into Craft's event system to inject scripts programmatically without editing templates.

Craft's element query system is the key differentiator. Queries like craft.entries.section('products') return typed objects with fields, relations, and metadata. You build data layers by iterating these objects in Twig rather than scraping DOM content client-side.

Environment-aware configuration is built in. Craft reads .env files and exposes values via craft.app.env and getenv(), so you can swap GTM container IDs between staging and production without template changes.


Installing Tracking Scripts

The base layout template is the single injection point for global scripts. Place GTM in the <head> block and the noscript fallback immediately after <body>:

{# templates/_layouts/base.twig #}
<!DOCTYPE html>
<html lang="{{ craft.app.language }}">
<head>
  <meta charset="utf-8">
  {% block head %}
    {% if craft.app.env == 'production' %}
    <!-- 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','{{ getenv("GTM_CONTAINER_ID") }}');</script>
    {% endif %}
  {% endblock %}
</head>
<body>
  {% if craft.app.env == 'production' %}
  <noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{ getenv('GTM_CONTAINER_ID') }}"
  height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  {% endif %}

  {% block content %}{% endblock %}

  {% block scripts %}{% endblock %}
</body>
</html>

For scripts that do not need to load in the <head>, use Craft's built-in {% js %} tag. It collects JavaScript and renders it at the end of the body:

{# Any child template #}
{% js %}
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({ 'page_type': 'article', 'author': '{{ entry.author.fullName|e("js") }}' });
{% endjs %}

Store the container ID in your .env file (GTM_CONTAINER_ID=GTM-XXXXXX) and reference it with getenv(). This keeps credentials out of version control and lets each environment use its own container.


Building a Data Layer with Twig

Craft's element queries return structured objects that map directly to analytics schemas. Build the data layer in Twig rather than parsing the DOM with JavaScript.

Single entry page (product, article, or any channel entry):

{# templates/_entries/product.twig #}
{% set product = entry %}
{% if product is defined and product %}
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'event': 'view_item',
    'ecommerce': {
      'items': [{
        'item_id': '{{ product.sku ?? product.id }}',
        'item_name': '{{ product.title|e("js") }}',
        'item_category': '{{ product.productCategory.one().title|default("Uncategorized")|e("js") }}',
        'price': {{ product.defaultPrice ?? 0 }}
      }]
    }
  });
</script>
{% endif %}

Category or listing page with multiple entries:

{# templates/_categories/listing.twig #}
{% set entries = craft.entries.section('products').relatedTo(category).limit(20).all() %}
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'event': 'view_item_list',
    'ecommerce': {
      'item_list_name': '{{ category.title|e("js") }}',
      'items': [
        {% for item in entries %}
        {
          'item_id': '{{ item.sku ?? item.id }}',
          'item_name': '{{ item.title|e("js") }}',
          'index': {{ loop.index0 }},
          'price': {{ item.defaultPrice ?? 0 }}
        }{% if not loop.last %},{% endif %}
        {% endfor %}
      ]
    }
  });
</script>

Always escape strings with |e("js") when injecting into JavaScript contexts. The default |e filter applies HTML escaping, which does not protect against quote-breaking in JS strings.


Craft Commerce Tracking

Craft Commerce exposes order, line item, and variant objects in templates. The order confirmation page is the primary purchase tracking point.

{# templates/shop/order-confirmation.twig #}
{% set order = craft.commerce.orders
  .number(craft.app.request.getParam('number'))
  .isCompleted(true)
  .one() %}

{% if order %}
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'event': 'purchase',
    'ecommerce': {
      'transaction_id': '{{ order.shortNumber }}',
      'value': {{ order.totalPrice }},
      'tax': {{ order.totalTax }},
      'shipping': {{ order.totalShippingCost }},
      'currency': '{{ order.currency }}',
      'coupon': '{{ order.couponCode|default("")|e("js") }}',
      'items': [
        {% for item in order.lineItems %}
        {
          'item_id': '{{ item.sku|e("js") }}',
          'item_name': '{{ item.description|e("js") }}',
          'price': {{ item.salePrice }},
          'quantity': {{ item.qty }},
          'discount': {{ item.discount|abs }}
        }{% if not loop.last %},{% endif %}
        {% endfor %}
      ]
    }
  });
</script>
{% endif %}

For add-to-cart tracking, hook into the Commerce add form submission. A common pattern is a JavaScript event listener on the cart form:

{# templates/shop/_partials/add-to-cart.twig #}
{% js %}
  document.querySelector('.add-to-cart-form')?.addEventListener('submit', function() {
    dataLayer.push({
      'event': 'add_to_cart',
      'ecommerce': {
        'items': [{
          'item_id': '{{ product.defaultVariant.sku|e("js") }}',
          'item_name': '{{ product.title|e("js") }}',
          'price': {{ product.defaultVariant.price }},
          'quantity': parseInt(this.querySelector('[name="qty"]')?.value || 1)
        }]
      }
    });
  });
{% endjs %}

Custom Module for Analytics

For projects where you need analytics injection without editing every template, create a Craft module. Modules are PHP classes loaded via Composer's autoloader.

// modules/analyticsmodule/src/AnalyticsModule.php
namespace modules\analyticsmodule;

use Craft;
use craft\web\View;
use yii\base\Event;
use yii\base\Module;

class AnalyticsModule extends Module
{
    public function init()
    {
        parent::init();

        if (Craft::$app->getRequest()->getIsSiteRequest()) {
            Event::on(
                View::class,
                View::EVENT_END_BODY,
                function (Event $event) {
                    $env = Craft::$app->env;
                    if ($env !== 'production') {
                        return;
                    }

                    $entry = Craft::$app->urlManager->getMatchedElement();
                    $pageData = [
                        'content_type' => $entry ? $entry->section->handle : 'static',
                        'page_id' => $entry ? $entry->id : null,
                    ];

                    $js = "window.dataLayer=window.dataLayer||[];dataLayer.push("
                        . json_encode($pageData) . ");";

                    Craft::$app->getView()->registerJs($js, View::POS_END);
                }
            );
        }
    }
}

Register the module in config/app.php:

return [
    'modules' => [
        'analytics-module' => \modules\analyticsmodule\AnalyticsModule::class,
    ],
    'bootstrap' => ['analytics-module'],
];

This approach keeps analytics logic centralized in PHP. It is useful when multiple developers work on templates and you need guaranteed script injection regardless of which template renders.


Common Errors

Error Cause Fix
Quotes break JavaScript strings Using |e (HTML escape) instead of |e("js") in Twig Always use |e("js") for values inside JavaScript string literals
Data layer empty on cached pages Static cache plugin (Blitz) serves stale HTML with no dynamic data Use Blitz's {% cache %} tag invalidation, or load dynamic data via AJAX after page load
Commerce order data undefined Template renders before Commerce completes the order Filter with .isCompleted(true) on the order query before accessing line items
entry variable null on 404 pages No entry matched the URL, but template still runs data layer code Wrap in {% if entry is defined and entry %} guard
Scripts blocked by CSP headers Content Security Policy missing GTM/gtag domains Add https://www.googletagmanager.com and https://www.google-analytics.com to your CSP script-src directive
Price shows 0 for variant products Accessing defaultPrice on a product with variants but no default price Use product.defaultVariant.price to get the first variant's price
Tags fire twice in Live Preview Live Preview loads the template alongside the published page Check {% if not craft.app.request.isLivePreview %} before injecting scripts
Production tags fire on staging Environment check missing from template Wrap scripts in {% if craft.app.env == 'production' %}
Data layer values show Twig syntax Twig not processing inside <script> tag Confirm the file extension is .twig (not .html) and the template is rendered through Craft's routing
Duplicate gtm.js requests GTM snippet included in both base layout and a child template block Place GTM only in the base layout {% block head %}; child templates should not redefine this block

Performance Considerations

Tracking scripts add HTTP requests and JavaScript execution time. On Craft CMS, use these strategies to minimize the performance impact:

  • Async and defer attributes. GTM loads async by default. For standalone scripts (gtag.js, Meta Pixel), add async or defer to prevent render blocking.
  • Template caching. Wrap expensive element queries in {% cache %} blocks. The data layer output caches with the template, so only use this for content that changes infrequently (category listings, site-wide metadata).
  • Blitz static caching. If you use the Blitz plugin for full-page static caching, dynamic data layer values (logged-in user, cart contents) must load via a client-side AJAX call to a controller endpoint. Static HTML cannot contain per-user data.
  • Template profiling. Enable Craft's debug toolbar (devMode: true) to identify slow Twig renders. Element queries inside loops are a common bottleneck. Eager-load relations with .with(['productCategory']) to reduce query count.
  • Script placement. Place non-critical tracking scripts (heatmaps, session replay) in the {% block scripts %} region at the end of <body> rather than in <head>.