Analytics Architecture on MODX
MODX Revolution separates content from presentation through a layered system of templates, chunks, snippets, and plugins. Analytics implementation leverages this architecture at multiple levels:
- Templates define page structure and include chunk references for tracking scripts
- Chunks store reusable HTML/JS fragments (tracking code lives here)
- Snippets execute PHP logic and return output (dynamic data layer generation)
- Plugins attach to system events for server-side tracking hooks
- Template Variables (TVs) store per-resource metadata exposed to analytics
The MODX tag syntax [[*field]] and [[+placeholder]] provides direct access to resource fields and snippet output within templates, making data layer population straightforward without additional PHP.
Installing Tracking Scripts
Method 1: Chunk in Template (Recommended)
Create a chunk called analyticsHead containing your tracking scripts:
<!-- analyticsHead chunk -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
</script>
Reference it in your template before </head>:
<head>
<title>[[*pagetitle]]</title>
[[$analyticsHead]]
</head>
This approach lets you update tracking code in one chunk and have it propagate across all templates that reference it.
Method 2: Plugin on System Event
For scripts that need server-side logic or conditional loading, create a plugin bound to the OnWebPagePrerender event:
<?php
// Plugin: AnalyticsInjector
// System Events: OnWebPagePrerender
$output = &$modx->resource->_output;
// Skip for admin previews or specific contexts
if ($modx->context->get('key') === 'mgr') return;
$trackingCode = '
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag("js", new Date());
gtag("config", "G-XXXXXXX");
</script>';
$output = str_replace('</head>', $trackingCode . '</head>', $output);
Method 3: OnLoadWebDocument for Early Injection
Use OnLoadWebDocument when you need to modify resource properties before rendering:
<?php
// Plugin: EarlyAnalytics
// System Events: OnLoadWebDocument
$resource = &$modx->resource;
$templateId = $resource->get('template');
// Only inject on specific templates
if (in_array($templateId, [1, 3, 5])) {
$modx->setPlaceholder('analytics_enabled', true);
}
Then in your template:
[[!If? &subject=`[[+analytics_enabled]]` &operator=`=` &operand=`1` &then=`[[$analyticsHead]]`]]
Data Layer Implementation
Using Snippets for Dynamic Data
Create a snippet called buildDataLayer that assembles page metadata:
<?php
// Snippet: buildDataLayer
$resource = $modx->resource;
$data = [
'page_type' => $resource->get('class_key'),
'page_id' => $resource->get('id'),
'page_title' => $resource->get('pagetitle'),
'template' => $resource->get('template'),
'parent_id' => $resource->get('parent'),
'published' => $resource->get('publishedon'),
'content_type' => $resource->get('content_type'),
];
// Pull Template Variable values
$tvValues = ['product_category', 'page_section', 'author_name'];
foreach ($tvValues as $tvName) {
$tv = $modx->getObject('modTemplateVar', ['name' => $tvName]);
if ($tv) {
$data[$tvName] = $tv->getValue($resource->get('id'));
}
}
// Build context from resource groups
$groups = $resource->getResourceGroupNames();
if (!empty($groups)) {
$data['content_groups'] = $groups;
}
return '<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(' . json_encode($data, JSON_UNESCAPED_SLASHES) . ');
</script>';
Call it in your template before the GTM container:
[[!buildDataLayer]]
[[$gtmContainer]]
Using MODX Tags Directly
For simpler data layers, use MODX resource tags inline:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'page_title': '[[*pagetitle]]',
'page_id': '[[*id]]',
'parent_id': '[[*parent]]',
'template_id': '[[*template]]',
'longtitle': '[[*longtitle]]',
'description': '[[*description]]',
'alias': '[[*alias]]',
'published_on': '[[*publishedon]]',
'product_sku': '[[*productSKU]]',
'page_section': '[[*pageSection]]'
});
</script>
Template variable values are accessed with [[*tvName]] just like built-in resource fields.
User Context Data
Expose logged-in user data via a snippet:
<?php
// Snippet: userDataLayer
if (!$modx->user->hasSessionContext($modx->context->get('key'))) {
return '';
}
$profile = $modx->user->getOne('Profile');
$data = [
'user_logged_in' => true,
'user_id' => $modx->user->get('id'),
'user_groups' => array_values($modx->user->getUserGroupNames()),
];
return '<script>window.dataLayer.push(' . json_encode($data) . ');</script>';
Common Issues
Cached vs Uncached Tags
MODX caches output by default. Analytics snippets that depend on user state or request data must use uncached syntax:
<!-- Cached (wrong for user-specific data) -->
[[buildDataLayer]]
<!-- Uncached (correct) -->
[[!buildDataLayer]]
Chunks containing only static tracking scripts can remain cached ([[$analyticsHead]]). Snippets generating per-request data must be uncached ([[!snippetName]]).
Plugin Execution Order
When multiple plugins bind to the same system event, execution order matters. Set priority in the plugin properties:
- OnWebPagePrerender fires after template rendering but before output. Ideal for modifying final HTML.
- OnLoadWebDocument fires when the resource is loaded. Ideal for setting placeholders consumed by templates.
- OnPageNotFound fires on 404 responses. Use this for 404 tracking.
If your analytics plugin modifies $modx->resource->_output and another plugin does the same, the last plugin to execute wins. Check the Events tab in each plugin to verify execution order.
Context-Aware Tracking
MODX supports multiple contexts (e.g., web, mobile, staging). Ensure tracking code respects context boundaries:
<?php
// In a plugin or snippet
$ctx = $modx->context->get('key');
if ($ctx === 'staging') {
// Use debug/staging GA property
$propertyId = 'G-STAGING';
} else {
$propertyId = 'G-PRODUCTION';
}
Friendly URLs and Virtual Pageviews
MODX friendly URLs rewrite /index.php?id=5 to /about/. Verify that your analytics tool receives the friendly URL, not the query-string version. Check [[~[[*id]]]] output in your data layer to confirm the resolved URL.
For AJAX-loaded content or tabbed interfaces, fire virtual pageviews manually:
// After AJAX content load
gtag('event', 'page_view', {
page_path: '/products/detail-tab',
page_title: 'Product Detail Tab'
});
Platform-Specific Considerations
System Events for Analytics Hooks: MODX exposes 100+ system events. Key ones for analytics:
OnWebPagePrerender-- modify final HTML output (script injection)OnLoadWebDocument-- set placeholders before template renderingOnPageNotFound-- track 404 errorsOnHandleRequest-- earliest hook for request-level trackingOnWebLogin/OnWebLogout-- user authentication tracking
Template Variable Performance: Accessing TVs via $tv->getValue() in a snippet runs a database query per TV. For pages with many TVs, use $modx->getCollection('modTemplateVarResource', ['contentid' => $resourceId]) to batch-fetch values.
MIGX and Custom TVs: If using MIGX (multi-item grid) TVs for repeating data like product variants, parse the JSON value in your data layer snippet:
$migxData = json_decode($modx->resource->getTVValue('product_variants'), true);
$data['variant_count'] = is_array($migxData) ? count($migxData) : 0;
Multi-Context Sites: For MODX sites serving multiple domains or languages through contexts, ensure each context has its own analytics property or uses a single property with context-based custom dimensions.
Static Elements vs Database Elements: MODX can load chunks, snippets, and plugins from static files (core/components/). If your analytics elements are stored as static files, changes require cache clearing (Site > Clear Cache) to take effect.