ExpressionEngine: Analytics Implementation Guide | OpsBlu Docs

ExpressionEngine: Analytics Implementation Guide

Implement analytics on ExpressionEngine using template tags, snippets, channel entry variables, add-ons, and template routes for data layer injection.

Analytics Architecture on ExpressionEngine

ExpressionEngine (EE) renders pages through a tag-based template engine. Templates are organized into template groups (which map to URL segments) and individual templates within those groups. Analytics scripts inject via three mechanisms: global snippets/partials for site-wide code, template tags for dynamic data, and add-ons for server-side processing. Understanding EE's template parsing order and tag caching determines whether your data layer values are fresh or stale.

Template parsing in ExpressionEngine follows a specific order: PHP is processed first (if enabled), then EE tags are parsed from the inside out (innermost tags first), then conditionals are evaluated, and finally the output is sent to the browser. This means a data layer script that uses EE template tags will have its variables resolved during parsing, before the HTML reaches the client. If you nest tags incorrectly, EE may not parse them in the order you expect.

Snippets (called "Template Partials" in EE 5+) are reusable blocks of template code that are included in other templates. They are parsed as if they were inline in the calling template. A snippet containing your GTM code can be included in every template's <head> section. Snippets are stored in the database (or as files in system/user/templates/_partials/) and are editable in the control panel.

System variables and global variables provide access to site configuration and request context. {site_url}, {current_url}, {logged_in}, {member_id}, {screen_name}, and {member_group} are available in every template without any tag prefix. These are the building blocks of your data layer.

Channel entries are EE's content model. Each entry has a title, URL title, custom fields, categories, and status. When rendering a channel entry (via {exp:channel:entries}), all custom field tags are available inside the tag pair. Product pages, blog posts, and other content types are all channel entries with different field groups.

Template caching in EE caches the fully rendered template output. When caching is enabled on a template, the cached HTML (including inline scripts) is served to all visitors. Data layer values that include user-specific information (member ID, group) will show the first visitor's data to everyone. Use the {if logged_in} conditional with non-cached embeds or AJAX calls for user-specific data.

Add-ons (plugins, modules, extensions) can inject content into templates via custom tags, or hook into EE's extension system to modify output before it is sent to the browser. The template_post_parse hook is useful for injecting analytics code globally without editing every template.

Installing Tracking Scripts

Create a snippet in Developer > Template Partials (or Design > Snippets in older versions). Name it _gtm_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>

Create another snippet _gtm_body:

<!-- Google Tag Manager (noscript) -->
<noscript>
    <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
            height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>

Include them in every template's layout:

<!DOCTYPE html>
<html lang="en">
<head>
    {_gtm_head}
    <meta charset="utf-8">
    <title>{title} | {site_name}</title>
    {exp:channel:entries limit="1" dynamic="yes"}
        <meta name="description" content="{meta_description}">
    {/exp:channel:entries}
</head>
<body>
    {_gtm_body}
    <!-- page content -->
</body>
</html>

Via Layout Template

EE 5+ supports layouts. Create a layout template at _layouts/default:

{layout:set name="title"}Default{/layout:set}

<!DOCTYPE html>
<html lang="en">
<head>
    {_gtm_head}
    <meta charset="utf-8">
    <title>{layout:title} | {site_name}</title>
    {layout:contents name="head"}
</head>
<body>
    {_gtm_body}

    {layout:contents}

    {layout:contents name="footer_scripts"}
</body>
</html>

Child templates use:

{layout="layouts/default"}
{layout:set name="title"}{title}{/layout:set}

<!-- Page content here -->
<h1>{title}</h1>

Via Embed Template

If you need separate parsing context (e.g., to avoid variable conflicts), use an embed instead of a snippet:

<head>
    {embed="_includes/gtm_head"}
    <!-- rest of head -->
</head>

Create the template _includes/gtm_head:

<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>

Embeds are parsed in their own context, which means they cannot access the parent template's channel entry variables. Use snippets when you need access to parent variables; use embeds when you need isolation.

Via Add-On (Extension Hook)

Create an add-on that uses the template_post_parse hook to inject GTM on every page:

<?php
// system/user/addons/analytics_injector/ext.analytics_injector.php

class Analytics_injector_ext
{
    public $version = '1.0.0';

    public function template_post_parse($final_template, $is_partial, $site_id)
    {
        if ($is_partial) {
            return $final_template;
        }

        $gtm_id = 'GTM-XXXXXX';

        $head_script = <<<HTML
<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_id}');
</script>
HTML;

        $body_script = <<<HTML
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id={$gtm_id}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
HTML;

        $final_template = str_replace('</head>', $head_script . '</head>', $final_template);
        $final_template = str_replace('<body>', '<body>' . $body_script, $final_template);

        // Handle body tags with attributes
        $final_template = preg_replace(
            '/(<body[^>]*>)/',
            '$1' . $body_script,
            $final_template,
            1
        );

        return $final_template;
    }
}

Data Layer Implementation

Global Data Layer via Snippet

Create a snippet _data_layer that pushes page-level data using EE's system variables:

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'siteId': '{site_id}',
    'siteName': '{site_name}',
    'currentUrl': '{current_url}',
    'templateGroup': '{template_group}',
    'template': '{template}',
    'isLoggedIn': {if logged_in}true{if:else}false{/if},
    {if logged_in}
    'memberId': '{member_id}',
    'memberGroup': '{member_group}',
    'screenName': '{screen_name}',
    {/if}
    'charset': '{charset}'
});
</script>

Include it before the GTM snippet:

<head>
    {_data_layer}
    {_gtm_head}
    <!-- rest of head -->
</head>

Channel Entry Data Layer

For pages that display a channel entry (blog posts, products, articles), push entry-specific data inside the {exp:channel:entries} tag pair:

{exp:channel:entries channel="blog" limit="1" dynamic="yes"}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'contentType': 'blog_post',
    'entryId': '{entry_id}',
    'entryTitle': '{title}',
    'urlTitle': '{url_title}',
    'channelName': '{channel_short_name}',
    'entryDate': '{entry_date format="%Y-%m-%d"}',
    'author': '{author}',
    'status': '{status}',
    'categories': [
        {categories backspace="1"}'{category_name}',{/categories}
    ],
    'wordCount': {word_count}
});
</script>
{/exp:channel:entries}

Custom Field Data in Data Layer

When channel entries have custom fields (select dropdowns, text fields, grids), include them:

{exp:channel:entries channel="products" limit="1" dynamic="yes"}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'productSku': '{product_sku}',
    'productName': '{title}',
    'productPrice': {product_price},
    'productCategory': '{categories limit="1"}{category_name}{/categories}',
    'productBrand': '{product_brand}',
    'inStock': {if product_stock > 0}true{if:else}false{/if}
});
</script>
{/exp:channel:entries}

Search Results Data Layer

Track internal search with the {exp:search} module:

{exp:search:search_results}
    {if count == 1}
    <script>
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
        'event': 'view_search_results',
        'search_term': '{exp:search:keywords}',
        'search_results_count': {total_results}
    });
    </script>
    {/if}
{/exp:search:search_results}

Conditional Data Layer Based on Template

Use EE conditionals to push different data based on which template is rendering:

<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'pageType':
    {if template_group == "blog"}'article'
    {if:elseif template_group == "products"}'product'
    {if:elseif template == "index"}'homepage'
    {if:else}'page'{/if}
});
</script>

E-commerce Tracking

ExpressionEngine does not include built-in e-commerce. Sites use add-ons like CartThrob, Store, or Expresso Store, or build custom checkout flows with channel entries and the EE module system.

Product View (Channel-Based Products)

On a product detail template:

{exp:channel:entries channel="products" limit="1" dynamic="yes"}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'view_item',
    'ecommerce': {
        'currency': 'USD',
        'value': {product_price},
        'items': [{
            'item_id': '{product_sku}',
            'item_name': '{title}',
            'price': {product_price},
            'item_category': '{categories limit="1"}{category_name}{/categories}',
            'item_brand': '{product_brand}'
        }]
    }
});
</script>
{/exp:channel:entries}

Product Listing

{exp:channel:entries channel="products" limit="20"}
{if count == 1}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'view_item_list',
    'ecommerce': {
        'item_list_name': '{categories limit="1"}{category_name}{/categories}',
        'items': [
{/if}
            {
                'item_id': '{product_sku}',
                'item_name': '{title}',
                'price': {product_price},
                'item_category': '{categories limit="1"}{category_name}{/categories}',
                'index': {count}
            }{if count != total_results},{/if}
{if count == total_results}
        ]
    }
});
</script>
{/if}
{/exp:channel:entries}

CartThrob Add to Cart

If using CartThrob, hook into the cart form submission:

{exp:cartthrob:add_to_cart_form
    entry_id="{entry_id}"
    return="cart"
    id="add-to-cart-form"
}
    <input type="hidden" name="quantity" value="1" />
    <button type="submit">Add to Cart</button>
{/exp:cartthrob:add_to_cart_form}

<script>
document.getElementById('add-to-cart-form').addEventListener('submit', function() {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
        'event': 'add_to_cart',
        'ecommerce': {
            'currency': 'USD',
            'value': {product_price},
            'items': [{
                'item_id': '{product_sku}',
                'item_name': '{title}',
                'price': {product_price},
                'quantity': 1
            }]
        }
    });
});
</script>

Order Confirmation (CartThrob)

On the checkout completion template:

{exp:cartthrob:order_info}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    'event': 'purchase',
    'ecommerce': {
        'transaction_id': '{order_id}',
        'value': {order_total},
        'tax': {order_tax},
        'shipping': {order_shipping},
        'currency': 'USD',
        'items': [
            {exp:cartthrob:cart_items_info order_id="{order_id}"}
            {
                'item_id': '{entry_id}',
                'item_name': '{title}',
                'price': {price},
                'quantity': {quantity}
            }{if count != total_results},{/if}
            {/exp:cartthrob:cart_items_info}
        ]
    }
});
</script>
{/exp:cartthrob:order_info}

Common Issues

Issue Cause Fix
Data layer values show raw EE tags like {title} Data layer script is outside a {exp:channel:entries} tag pair, so channel fields are not parsed Wrap the data layer <script> block inside the {exp:channel:entries} tag pair where the fields are available
Template caching serves stale user data Template caching is enabled; the cached HTML includes {member_id} values from the first visitor Move user-specific data layer pushes to an uncached embed, or populate user data via a client-side AJAX call to an EE template that outputs JSON
Nested tag pairs do not parse correctly EE parses innermost tags first; nested {exp:channel:entries} within another tag pair may not resolve as expected Use embeds for nested tag pairs: {embed="_includes/data_layer" entry_id="{entry_id}"} passes the resolved entry_id to the embed
JavaScript syntax error from unparsed conditionals EE conditionals like {if count == 1} are rendered as-is if the conditional is not properly formatted Ensure conditionals are on their own lines and match EE's conditional syntax exactly; check for missing {/if} closings
Backspace parameter breaks JSON syntax Using backspace in tag pairs (e.g., {categories backspace="1"}) does not account for edge cases like single-item loops Use {if count != total_results},{/if} pattern instead of backspace for JSON array separators
Embed variables are not available in the parent Embeds parse in their own context; parent template variables are not accessible in the embed Pass needed values as embed parameters: {embed="_includes/analytics" entry_id="{entry_id}" title="{title}"}
AJAX-loaded content does not fire data layer pushes Content loaded via AJAX (e.g., load-more on product listings) is not parsed by EE with data layer scripts Include data attributes on the AJAX response elements and push data layer events from the JavaScript that handles the AJAX response
Multi-site installation sends data to wrong property Same GTM snippet across all MSM sites Use {if site_id == "1"}GTM-AAAAAA{if:elseif site_id == "2"}GTM-BBBBBB{/if} in the GTM snippet, or use separate snippets per site

Platform-Specific Considerations

Template parsing order. EE parses templates in a specific sequence: PHP (if enabled) > module/plugin tags (innermost first) > conditionals > variables > typography. This means a {if} conditional that depends on a module tag output must be written so the module tag resolves first. For analytics, this matters when conditionally pushing data based on channel entry values: the channel entry tag pair must wrap the conditional, not the other way around.

Template routes. EE maps URLs to templates using template groups and template names. /blog/my-post routes to the blog template group, my-post template (or the blog/index template with {segment_2} containing my-post). For analytics, use {template_group} and {template} in your data layer to identify page types. Segment variables ({segment_1} through {segment_n}) give you the raw URL path.

Multiple Site Manager (MSM). EE supports multiple sites from a single installation. Each site has its own channels, templates, and configuration. Snippets can be shared across sites or site-specific. For analytics, use site-specific snippets with different GTM container IDs, or use {site_id} conditionals in a shared snippet.

PHP in templates. EE allows PHP in templates (disabled by default for security). While you could use PHP to generate complex data layer output, this is discouraged. PHP in templates bypasses EE's template security model and makes templates harder to maintain. Use add-ons (plugins or modules) for server-side logic that needs to feed the data layer.

Low variables and Grid fields. EE's Grid and Fluid field types output data using tag pair syntax with row iteration. To include Grid field data in a data layer (e.g., product variants), iterate within the {exp:channel:entries} block:

{exp:channel:entries channel="products" limit="1" dynamic="yes"}
<script>
var variants = [
    {product_variants}
    {
        'sku': '{product_variants:variant_sku}',
        'size': '{product_variants:variant_size}',
        'price': {product_variants:variant_price}
    }{if product_variants:count != product_variants:total_rows},{/if}
    {/product_variants}
];
</script>
{/exp:channel:entries}

EE version considerations. EE 7 (current) uses Composer and is PHP 8.1+ compatible. EE 5-6 have slightly different template partial/snippet naming and add-on architecture. If upgrading from EE 2.x (legacy), template syntax is largely compatible but add-on hooks and the control panel interface changed significantly. Test analytics templates after any major version upgrade.

Content Delivery Network. EE does not have built-in CDN support. If you use a CDN (Cloudflare, AWS CloudFront) in front of EE, cached pages will include the inline analytics scripts. Ensure the CDN respects Vary headers for logged-in vs anonymous users if your data layer includes user-specific values. Use short TTLs or bypass caching for pages with dynamic analytics data.