Analytics Architecture on Joomla
Joomla's analytics architecture relies on three primary mechanisms: system plugins, template overrides, and module positions. Each approach has different execution timing, caching behavior, and access to Joomla's internal data.
System plugins are the most reliable method for injecting analytics scripts. A plugin listening to the onBeforeCompileHead event can add scripts to the document's <head> through Joomla's JDocument API. This fires after all components and modules have rendered but before the final HTML is compiled, giving you access to the complete page context. Plugins can also use onAfterRender to modify the final HTML output directly, which is useful for inserting <noscript> tags after <body>.
Template overrides in Joomla follow a hierarchical system. The index.php of your active template controls the outer HTML structure. jdoc:include statements pull in component output and module positions. You can inject scripts directly in index.php, but this bypasses Joomla's asset management. Template overrides for specific components (placed in templates/your_template/html/com_content/article/) control per-view output and can push view-specific data to the data layer.
Module positions let you place custom HTML modules containing tracking scripts in specific locations within the template. A custom HTML module assigned to a debug or analytics position at the top of the template loads on every page. This is the fastest method for site administrators who cannot write plugins, but it offers no programmatic access to Joomla's API.
Joomla's caching operates at the page level (System Cache plugin) and the module level (module cache settings). When the System Cache plugin is enabled, anonymous visitors receive fully cached HTML. Your data layer values become stale unless the cache is configured to vary by the parameters your analytics depend on. Module-level caching is controlled per module instance.
Joomla 4+ uses Web Asset Manager for script management. Plugins and templates should register scripts as web assets rather than using the legacy addScript() method. Web assets support dependencies, versioning, and proper load ordering.
Installing Tracking Scripts
Via System Plugin (Recommended)
Create a system plugin to inject GTM. The plugin structure:
plugins/system/analytics/
analytics.php
analytics.xml
The plugin file analytics.php:
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;
class PlgSystemAnalytics extends CMSPlugin
{
public function onBeforeCompileHead()
{
$app = Factory::getApplication();
// Only run on the frontend
if (!$app->isClient('site')) {
return;
}
$doc = $app->getDocument();
$containerId = $this->params->get('gtm_container_id', '');
if (empty($containerId)) {
return;
}
$gtmScript = "
(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','" . htmlspecialchars($containerId) . "');";
$doc->addScriptDeclaration($gtmScript);
}
public function onAfterRender()
{
$app = Factory::getApplication();
if (!$app->isClient('site')) {
return;
}
$containerId = $this->params->get('gtm_container_id', '');
if (empty($containerId)) {
return;
}
$body = $app->getBody();
$noscript = '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id='
. htmlspecialchars($containerId)
. '" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>';
$body = str_replace('<body', '<body' . "\n" . $noscript, $body);
// Move noscript after the opening body tag
$body = preg_replace('/(<body[^>]*>)/', '$1' . "\n" . $noscript, $body, 1);
$app->setBody($body);
}
}
The manifest analytics.xml:
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - Analytics</name>
<version>1.0.0</version>
<description>Injects GTM container and data layer</description>
<files>
<filename plugin="analytics">analytics.php</filename>
</files>
<config>
<fields name="params">
<fieldset name="basic">
<field name="gtm_container_id" type="text"
label="GTM Container ID"
description="Enter your GTM container ID (e.g., GTM-XXXXXX)"
default="" />
</fieldset>
</fields>
</config>
</extension>
Install via Extensions > Manage > Install, then enable at Extensions > Plugins.
Via Template index.php
Edit your active template's index.php file. Place the GTM snippet immediately after <head>:
<head>
<!-- GTM -->
<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>
<jdoc:include type="head" />
</head>
Place the <noscript> fallback immediately after <body>:
<body class="<?php echo $bodyClasses; ?>">
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<jdoc:include type="modules" name="top" />
This is simpler than a plugin but does not survive template changes and offers no admin UI for configuration.
Data Layer Setup
Via System Plugin
Extend the system plugin to push page metadata before GTM loads. Add this method to the plugin class:
public function onBeforeCompileHead()
{
$app = Factory::getApplication();
if (!$app->isClient('site')) {
return;
}
$doc = $app->getDocument();
$input = $app->getInput();
// Build data layer
$dataLayer = [
'pageLanguage' => $app->getLanguage()->getTag(),
'pageType' => $input->getCmd('option', 'unknown'),
'pageView' => $input->getCmd('view', 'default'),
];
// Get article data if viewing com_content article
if ($input->getCmd('option') === 'com_content' && $input->getCmd('view') === 'article') {
$id = $input->getInt('id', 0);
if ($id > 0) {
$article = $this->getArticleData($id);
if ($article) {
$dataLayer['contentTitle'] = $article->title;
$dataLayer['contentCategory'] = $article->category_title;
$dataLayer['contentAuthor'] = $article->author;
$dataLayer['contentId'] = (string) $article->id;
$dataLayer['contentPublished'] = $article->publish_up;
}
}
}
$script = 'window.dataLayer = window.dataLayer || [];'
. 'window.dataLayer.push(' . json_encode($dataLayer) . ');';
$doc->addScriptDeclaration($script);
// Then inject GTM (after data layer)
$containerId = $this->params->get('gtm_container_id', '');
if (!empty($containerId)) {
$gtmScript = "(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','" . htmlspecialchars($containerId) . "');";
$doc->addScriptDeclaration($gtmScript);
}
}
private function getArticleData($id)
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select(['a.id', 'a.title', 'a.publish_up', 'c.title AS category_title', 'u.name AS author'])
->from('#__content AS a')
->leftJoin('#__categories AS c ON c.id = a.catid')
->leftJoin('#__users AS u ON u.id = a.created_by')
->where('a.id = ' . (int) $id);
$db->setQuery($query);
return $db->loadObject();
}
This produces output like:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
"pageLanguage": "en-GB",
"pageType": "com_content",
"pageView": "article",
"contentTitle": "Summer Product Launch",
"contentCategory": "Blog",
"contentAuthor": "Jane Smith",
"contentId": "147",
"contentPublished": "2025-06-15 08:00:00"
});
Ecommerce Tracking with VirtueMart
VirtueMart is Joomla's primary ecommerce extension. It uses its own event system. Track add-to-cart and purchase events by hooking into VirtueMart's plugin events.
Create a VirtueMart plugin that listens to cart and order events:
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Factory;
class PlgVmextAnalytics extends CMSPlugin
{
/**
* Fires when a product is added to the cart.
*/
public function plgVmOnAddToCart(&$cart)
{
$session = Factory::getSession();
$lastProduct = end($cart->products);
if (!$lastProduct) {
return;
}
$event = [
'event' => 'add_to_cart',
'ecommerce' => [
'currency' => shopFunctions::getCurrencyByID($cart->pricesCurrency, 'currency_code_3'),
'value' => (float) $lastProduct->prices['salesPrice'],
'items' => [[
'item_id' => $lastProduct->product_sku,
'item_name' => $lastProduct->product_name,
'price' => (float) $lastProduct->prices['salesPrice'],
'quantity' => (int) $lastProduct->quantity,
]],
],
];
$pending = json_decode($session->get('analytics_events', '[]'), true);
$pending[] = $event;
$session->set('analytics_events', json_encode($pending));
}
/**
* Fires after an order is confirmed.
*/
public function plgVmConfirmedOrder($cart, $orderDetails)
{
$session = Factory::getSession();
$items = [];
foreach ($cart->products as $product) {
$items[] = [
'item_id' => $product->product_sku,
'item_name' => $product->product_name,
'price' => (float) $product->prices['salesPrice'],
'quantity' => (int) $product->quantity,
];
}
$event = [
'event' => 'purchase',
'ecommerce' => [
'transaction_id' => $orderDetails['details']['BT']->order_number,
'value' => (float) $orderDetails['details']['BT']->order_total,
'currency' => shopFunctions::getCurrencyByID(
$orderDetails['details']['BT']->order_currency, 'currency_code_3'
),
'items' => $items,
],
];
$pending = json_decode($session->get('analytics_events', '[]'), true);
$pending[] = $event;
$session->set('analytics_events', json_encode($pending));
}
}
Flush the queued events on the next page load using the system plugin's onBeforeCompileHead:
// Inside onBeforeCompileHead, before the GTM snippet
$session = Factory::getSession();
$events = json_decode($session->get('analytics_events', '[]'), true);
if (!empty($events)) {
$js = 'window.dataLayer = window.dataLayer || [];';
foreach ($events as $event) {
$js .= 'window.dataLayer.push(' . json_encode($event) . ');';
}
$doc->addScriptDeclaration($js);
$session->clear('analytics_events');
}
Common Errors
| Error | Cause | Fix |
|---|---|---|
| GTM fires but data layer is empty | Script execution order is wrong; GTM loads before the data layer push | Ensure addScriptDeclaration() for the data layer is called before the GTM snippet in onBeforeCompileHead |
| Tracking scripts missing on cached pages | Joomla's System Cache plugin serves pre-rendered HTML that was built before the plugin ran | Set the analytics plugin ordering to run before the cache plugin (lower order number), or exclude analytics-dependent pages from cache |
| Duplicate pageview events on article pages | Both the template and a custom HTML module inject the same tracking code | Remove the module-based injection; keep only the plugin-based approach |
| VirtueMart add-to-cart event never fires | The VirtueMart plugin group is not enabled or the plugin event name is misspelled | Verify the plugin is published under Extensions > Plugins, and the method name matches VirtueMart's expected signature exactly |
| Data layer values contain HTML entities | Joomla's htmlspecialchars was applied to article titles before pushing to the data layer |
Use raw values from the database query; do not pass them through Joomla's output filters before JSON encoding |
onAfterRender noscript injection breaks layout |
The regex replacement inserts the noscript tag in the wrong position, or there are multiple <body strings |
Use a more specific regex: /(<body[^>]*>)/ and limit replacement count to 1 |
| Scripts load on admin pages | The plugin does not check $app->isClient('site') and runs on the backend too |
Add the client check at the top of every event handler: if (!$app->isClient('site')) { return; } |
| Multilingual site shows wrong language in data layer | The language tag is pulled from the default language, not the active one | Use $app->getLanguage()->getTag() which returns the currently active language, not JFactory::getLanguage() which may return the default |
| Custom HTML module with tracking code causes CLS | The module renders visible content (even whitespace) before the page body loads | Set the module's "Show Title" to No, use a dedicated module position placed inside <head> or immediately after <body>, and ensure no visible output |
| Joomla update removes custom template modifications | Core and template updates overwrite index.php changes |
Use a child template or a system plugin instead of editing index.php directly; plugins survive updates |
Performance Considerations
Use
asyncfor all external scripts. When adding scripts via$doc->addScript(), pass['async' => 'async']as the third parameter. The system plugin approach ensures GTM loads asynchronously by default when using the standard snippet.Leverage Joomla's Web Asset Manager (Joomla 4+) instead of
addScript(). Register your analytics as a web asset with proper dependencies so Joomla loads them in the correct order without duplicating requests.Configure System Cache exclusions for pages where real-time data layer values matter. For most analytics use cases, cached pages with static data layer values are acceptable since GTM triggers on page load regardless.
Consolidate tracking through GTM rather than adding separate
<script>tags for each platform. Each additional parser-blocking script adds to Total Blocking Time. A single GTM container with multiple tags configured inside it loads once and dispatches events internally.Avoid Custom HTML modules for tracking code when a system plugin is available. Modules go through Joomla's rendering pipeline, adding overhead. A system plugin's
onBeforeCompileHeadinjects directly into the document head with minimal processing.Defer non-essential pixels to after page load. Use GTM's built-in "Window Loaded" trigger for secondary tracking pixels (Meta, TikTok, LinkedIn) so they do not compete with critical rendering resources during initial page load.