Analytics Architecture on Drupal
Drupal's analytics architecture centers on three mechanisms: contrib modules, the hook system, and Twig template rendering. Understanding how these interact determines whether your tracking fires correctly, in the right order, and with the data you expect.
Contrib modules are the primary method for adding analytics to Drupal. The google_tag module (formerly google_analytics) handles GTM container injection. The dataLayer module exposes page-level metadata to the window.dataLayer array before GTM loads. These modules use Drupal's render pipeline, meaning they respect caching layers, access controls, and asset aggregation settings.
hook_page_attachments is the PHP hook that lets custom modules inject JavaScript into the <head> of every page. This fires during Drupal's page build phase, before Twig renders the template. Scripts added here go through Drupal's asset library system, which means they are aggregated and cached alongside core JavaScript unless you explicitly mark them as external.
Twig templates control the final HTML output. Drupal 8+ uses Twig exclusively. The html.html.twig template contains the outer <html> and <head> structure, while page.html.twig handles the page body. Injecting scripts directly in Twig bypasses Drupal's library system, which means no aggregation, no cache busting, and no dependency management. This approach is occasionally necessary but should be avoided when a module-based method exists.
Drupal's caching is aggressive by default. Page Cache (for anonymous users) and Dynamic Page Cache (for authenticated users) will serve stale HTML unless cache tags and contexts are configured correctly. If your data layer includes user-specific or page-specific values, the cache must be aware of those variations or you will see incorrect data in your analytics.
BigPipe and Lazy Builders further complicate script execution order. BigPipe streams page content in chunks, which means scripts attached via #attached may execute before the full DOM is available. Plan your GTM trigger timing accordingly.
Installing Tracking Scripts
Via the google_tag Module
The google_tag module is the recommended method for injecting GTM containers. Install it with Composer:
composer require drupal/google_tag
drush en google_tag -y
drush cr
Configure the container at /admin/config/services/google-tag. Enter your GTM container ID (e.g., GTM-XXXXXX). The module supports multiple containers, each with visibility conditions based on path, role, or response status.
The module injects the GTM snippet into <head> and the <noscript> fallback after <body>. It respects Drupal's caching system and sets the correct cache tags so that changes to configuration invalidate cached pages.
Via hook_page_attachments (Custom Module)
For direct GA4 or other script injection without GTM, create a custom module. In your module's .module file:
<?php
/**
* Implements hook_page_attachments().
*/
function mysite_analytics_page_attachments(array &$attachments) {
$attachments['#attached']['html_head'][] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#attributes' => [
'async' => TRUE,
'src' => 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
],
],
'ga4_script_tag',
];
$attachments['#attached']['html_head'][] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#value' => "window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');",
],
'ga4_config',
];
}
Define the module in mysite_analytics.info.yml:
name: MySite Analytics
type: module
description: 'Injects analytics scripts via hook_page_attachments.'
core_version_requirement: ^10 || ^11
package: Custom
Enable with drush en mysite_analytics -y && drush cr.
Via Twig Template (Last Resort)
Edit html.html.twig in your theme's templates/ directory. Place the script immediately after <head>:
<head>
<!-- GTM container -->
<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>
<title>{{ head_title|safe_join(' | ') }}</title>
<css-placeholder token="{{ placeholder_token }}">
<js-placeholder token="{{ placeholder_token }}">
{{ page_top }}
</head>
This bypasses Drupal's asset pipeline entirely. You lose aggregation, cache metadata, and configuration management through the admin UI.
Data Layer Setup
Using the dataLayer Contrib Module
The dataLayer module pushes page metadata to window.dataLayer before GTM loads. Install it:
composer require drupal/datalayer
drush en datalayer -y
drush cr
Configure at /admin/config/search/datalayer. Enable entity metadata for content types, taxonomy terms, and user data. The module outputs structured data like this on every page:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
"drupalLanguage": "en",
"drupalCountry": "US",
"entityType": "node",
"entityBundle": "article",
"entityId": "42",
"entityLabel": "Getting Started with Analytics",
"entityTaxonomy": {
"tags": ["analytics", "implementation"]
},
"userUid": "0",
"userRoles": ["anonymous"]
});
Custom Data Layer via Preprocess Functions
For data the dataLayer module does not expose, use a preprocess function in your theme's .theme file:
<?php
/**
* Implements hook_preprocess_html().
*/
function mytheme_preprocess_html(array &$variables) {
$route_match = \Drupal::routeMatch();
$node = $route_match->getParameter('node');
$data = [
'pageType' => 'default',
'contentGroup' => 'other',
];
if ($node instanceof \Drupal\node\NodeInterface) {
$data['pageType'] = $node->getType();
$data['contentGroup'] = $node->getType();
$data['contentTitle'] = $node->getTitle();
$data['contentAuthor'] = $node->getOwner()->getDisplayName();
$data['contentPublished'] = date('Y-m-d', $node->getCreatedTime());
if ($node->hasField('field_category') && !$node->get('field_category')->isEmpty()) {
$term = $node->get('field_category')->entity;
$data['contentCategory'] = $term ? $term->getName() : '';
}
}
$variables['#attached']['html_head'][] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#value' => 'window.dataLayer = window.dataLayer || []; window.dataLayer.push(' . json_encode($data) . ');',
'#weight' => -100,
],
'custom_datalayer',
];
}
The #weight of -100 ensures this script renders before GTM's container snippet.
Ecommerce Tracking with Drupal Commerce
Drupal Commerce uses its own entity types (commerce_product, commerce_order, commerce_order_item). Push ecommerce data using an event subscriber:
<?php
namespace Drupal\mysite_analytics\EventSubscriber;
use Drupal\commerce_cart\Event\CartEntityAddEvent;
use Drupal\commerce_cart\Event\CartEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CommerceAnalyticsSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents() {
return [
CartEvents::CART_ENTITY_ADD => 'onCartAdd',
];
}
public function onCartAdd(CartEntityAddEvent $event) {
$order_item = $event->getOrderItem();
$product_variation = $order_item->getPurchasedEntity();
$product = $product_variation->getProduct();
$item_data = [
'event' => 'add_to_cart',
'ecommerce' => [
'currency' => $order_item->getTotalPrice()->getCurrencyCode(),
'value' => (float) $order_item->getTotalPrice()->getNumber(),
'items' => [[
'item_id' => $product_variation->getSku(),
'item_name' => $product->getTitle(),
'price' => (float) $order_item->getUnitPrice()->getNumber(),
'quantity' => (int) $order_item->getQuantity(),
]],
],
];
// Store in session for the next page load to push via dataLayer
$session = \Drupal::request()->getSession();
$pending = $session->get('analytics_events', []);
$pending[] = $item_data;
$session->set('analytics_events', $pending);
}
}
Register the subscriber in mysite_analytics.services.yml:
services:
mysite_analytics.commerce_subscriber:
class: Drupal\mysite_analytics\EventSubscriber\CommerceAnalyticsSubscriber
tags:
- { name: event_subscriber }
Then flush pending events on the next page load via hook_page_attachments:
function mysite_analytics_page_attachments(array &$attachments) {
$session = \Drupal::request()->getSession();
$events = $session->get('analytics_events', []);
if (!empty($events)) {
$js = 'window.dataLayer = window.dataLayer || [];';
foreach ($events as $event) {
$js .= 'window.dataLayer.push(' . json_encode($event) . ');';
}
$attachments['#attached']['html_head'][] = [
[
'#type' => 'html_tag',
'#tag' => 'script',
'#value' => $js,
'#weight' => -99,
],
'commerce_analytics_events',
];
$session->remove('analytics_events');
}
}
Common Errors
| Error | Cause | Fix |
|---|---|---|
| Data layer shows stale values on repeat visits | Drupal Page Cache serves cached HTML to anonymous users, including old dataLayer.push() calls |
Add cache contexts to your render elements: '#cache' => ['contexts' => ['url.path']] |
| GTM container not loading on some pages | The google_tag module has path or role visibility conditions excluding those pages |
Review conditions at /admin/config/services/google-tag and check the "All pages except listed" setting |
Duplicate dataLayer.push() calls |
Both the dataLayer module and custom preprocess code are pushing overlapping keys |
Choose one source of truth per data point; disable overlapping fields in the dataLayer module config |
Scripts missing after drush cr (cache rebuild) |
Asset aggregation regenerates files; old aggregated JS URLs return 404 until new ones are built | This is normal behavior; the first page request after cache rebuild triggers regeneration |
Ecommerce events fire with undefined values |
Commerce entity fields are not loaded in the current context (e.g., accessing the entity from an event subscriber before it is fully saved) | Use $event->getOrderItem() after the entity save is complete; check that the product variation has a SKU |
| BigPipe causes GTM to fire before data layer is ready | BigPipe streams content in chunks; the GTM script may execute before later chunks containing data layer values arrive | Set GTM triggers to fire on Window Loaded instead of DOM Ready, or use drupalSettings which loads in the initial response |
hook_page_attachments code does not run on cached pages |
Anonymous page cache serves the full response from cache, skipping all hooks | Use cache tags/contexts so the page varies by the data you need, or use Dynamic Page Cache for authenticated users |
| User role data missing from data layer for anonymous users | Anonymous users all share the same cached page, so role-based data layer values are meaningless | Use session cache context or move user-specific data to a separate AJAX endpoint loaded client-side |
| Google Tag module shows "Container ID not set" | The configuration was not saved after entering the ID, or configuration is overridden in settings.php |
Check /admin/config/services/google-tag and verify no config override exists in $config['google_tag.settings'] |
| Tracking scripts load twice | Both a contrib module and a Twig template are injecting the same script | Remove the Twig-based injection; always prefer the module approach |
Performance Considerations
Enable asset aggregation in
/admin/config/development/performance. Drupal concatenates and minifies JS files, reducing HTTP requests. Your analytics library added via#attachedbenefits from this automatically.Use the
asyncattribute on external script tags. Thegoogle_tagmodule addsasyncby default. If you inject scripts viahook_page_attachments, set'#attributes' => ['async' => TRUE]to prevent render blocking.Leverage Drupal's cache tags for data layer values that change per entity. Tag your render array with
'#cache' => ['tags' => ['node:' . $node->id()]]so the data layer updates only when the entity changes, not on every request.Avoid inline script injection in Twig templates. Scripts added directly in Twig bypass aggregation, cannot be deferred, and create an additional parser-blocking resource on every page load.
Defer non-critical tracking pixels (Meta Pixel, TikTok, etc.) by loading them after the
DOMContentLoadedevent. Use GTM's built-in trigger scheduling rather than injecting multiple<script>tags inhook_page_attachments.Monitor BigPipe interactions with your analytics stack. Run Lighthouse with BigPipe enabled and disabled to compare Total Blocking Time. If BigPipe-streamed content triggers excessive layout shifts that affect CLS, consider moving those elements to lazy builders instead.
Related Guides
- Google Analytics Setup on Drupal
- GA4 Ecommerce Tracking for Drupal Commerce
- Google Tag Manager Setup on Drupal
- GTM Data Layer Configuration for Drupal
- Meta Pixel Setup on Drupal
- Drupal Tracking Troubleshooting
- Fixing LCP Issues on Drupal
- Fixing CLS Issues on Drupal
- Drupal User Management and Permissions