Analytics Architecture on Concrete CMS
Concrete CMS (formerly concrete5) renders pages through a theme system that combines PHP page templates with a block-based content model. Analytics scripts enter the rendering pipeline through the theme's page_theme.php configuration, individual view.php templates, the on_page_view event, or the built-in dashboard interface for header/footer code injection. Understanding how Concrete's full-page caching and block caching interact with script injection determines whether your tracking data is accurate.
Theme structure in Concrete CMS is built around a page_theme.php class and a set of page type templates. The page_theme.php class defines registered asset groups, CSS/JS includes, and layout-level configuration. Each page type (e.g., default.php, full.php, blog_entry.php) is a PHP template that renders the page body. The outer HTML wrapper comes from either elements/header.php and elements/footer.php files within the theme, or from the default.php template itself if it includes the full HTML structure.
The block system is Concrete's content model. Content editors place blocks (rich text, image, HTML, etc.) into areas defined in page templates. Each block type has a view.php template that renders the block's output. Custom blocks can include JavaScript in their view.php, which fires when the block renders. For analytics, you can create a custom block type that outputs data layer pushes based on the page context.
Events in Concrete CMS use a Symfony EventDispatcher-based system. The on_page_view event fires every time a page is viewed, even for cached pages (in Concrete 9+). This is the cleanest hook for server-side analytics logic. Register an event listener in a package's on_start() method or in application/bootstrap/app.php.
Full-page caching in Concrete CMS stores the complete rendered HTML output. When enabled (via Dashboard > System & Settings > Optimization > Cache & Speed Settings), the cached page includes all inline scripts. Data layer values that vary per user or session will be frozen at cache time. Concrete's cache can be configured per page: "No caching," "Use custom settings," or "Use global settings." For pages with dynamic analytics data, either disable caching or populate the data layer client-side.
The Dashboard provides a code injection interface at Dashboard > System & Settings > SEO & Statistics > Tracking Codes. This lets administrators paste GTM or GA4 code into the header or footer of every page without touching theme files.
Installing Tracking Scripts
Via Dashboard Tracking Code Interface
The simplest method. Navigate to Dashboard > System & Settings > SEO & Statistics > Tracking Codes. Paste the GTM snippet into the "Header" field:
<!-- 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>
Paste the noscript fallback into the "Footer" field (or a "Body Open" field if your version supports it):
<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
This approach stores the code in the database and injects it on every page render. It survives theme changes and updates.
Via Theme Header/Footer Elements
Edit your theme's elements/header.php file. This file is included at the top of every page template:
<?php defined('C5_EXECUTE') or die('Access Denied.'); ?>
<!DOCTYPE html>
<html lang="<?php echo Localization::activeLanguage(); ?>">
<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>
<?php View::element('header_required', [
'pageTitle' => isset($pageTitle) ? $pageTitle : '',
'pageDescription' => isset($pageDescription) ? $pageDescription : '',
]); ?>
</head>
<body>
<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
The View::element('header_required') call renders Concrete's required head assets (meta tags, CSS, JS). Place GTM before it so the container loads as early as possible.
Via page_theme.php Asset Registration
Register analytics scripts as theme assets for Concrete to manage loading:
<?php
// themes/my_theme/page_theme.php
namespace Application\Theme\MyTheme;
use Concrete\Core\Page\Theme\Theme;
class PageTheme extends Theme
{
public function registerAssets()
{
// Register GTM as an external asset
$this->requireAsset('javascript', 'gtm-head');
}
public function getThemeName()
{
return t('My Theme');
}
}
Register the asset in application/bootstrap/app.php:
<?php
use Concrete\Core\Asset\AssetList;
$al = AssetList::getInstance();
$al->register('javascript', 'gtm-head',
'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXX',
['position' => 'header', 'local' => false]
);
This registers GTM as a managed asset but does not include the initialization snippet. Combine this with inline code in elements/header.php for the full GTM setup.
Via Package
Create a package that injects analytics globally. This is the most portable approach:
<?php
// packages/analytics_tracking/controller.php
namespace Concrete\Package\AnalyticsTracking;
use Concrete\Core\Package\Package;
use Concrete\Core\Page\Event as PageEvent;
class Controller extends Package
{
protected $pkgHandle = 'analytics_tracking';
protected $appVersionRequired = '9.0.0';
protected $pkgVersion = '1.0.0';
public function getPackageName()
{
return t('Analytics Tracking');
}
public function getPackageDescription()
{
return t('Injects GTM and data layer on all pages.');
}
public function on_start()
{
$this->app->make('director')->addListener(
'on_page_view',
function ($event) {
$page = $event->getPageObject();
$view = $event->getArgument('view');
// Tracking logic here
}
);
}
}
Data Layer Implementation
Global Data Layer via elements/header.php
Push page-level metadata before the GTM snippet in your theme's header element:
<?php
// elements/header.php (before GTM snippet)
$c = Page::getCurrentPage();
$pageType = $c ? $c->getCollectionTypeHandle() : 'unknown';
$pageName = $c ? $c->getCollectionName() : '';
$pageId = $c ? $c->getCollectionID() : 0;
$user = new User();
$isLoggedIn = $user->isRegistered();
$parentPage = $c ? $c->getCollectionParentID() : 0;
// Get page attributes
$pageCategory = '';
if ($c && $c->getAttribute('page_category')) {
$pageCategory = $c->getAttribute('page_category');
}
?>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'pageType': '<?php echo htmlspecialchars($pageType); ?>',
'pageName': '<?php echo htmlspecialchars($pageName); ?>',
'pageId': <?php echo (int)$pageId; ?>,
'parentPageId': <?php echo (int)$parentPage; ?>,
'isLoggedIn': <?php echo $isLoggedIn ? 'true' : 'false'; ?>,
'pageCategory': '<?php echo htmlspecialchars($pageCategory); ?>'
});
</script>
Data Layer via on_page_view Event
For a cleaner separation of concerns, push data layer content via an event listener in a package or application/bootstrap/app.php:
<?php
// application/bootstrap/app.php
use Concrete\Core\Page\Page;
use Concrete\Core\User\User;
use Concrete\Core\View\View;
$app->make('director')->addListener('on_page_view', function ($event) {
$page = $event->getPageObject();
if (!$page || $page->isAdminArea()) {
return; // Skip dashboard pages
}
$user = new User();
$data = [
'pageType' => $page->getCollectionTypeHandle(),
'pageName' => $page->getCollectionName(),
'pageId' => $page->getCollectionID(),
'pagePath' => $page->getCollectionPath(),
'isLoggedIn' => $user->isRegistered(),
];
// Add page attributes
$category = $page->getAttribute('page_category');
if ($category) {
$data['pageCategory'] = is_object($category) ? $category->__toString() : $category;
}
$tags = $page->getAttribute('tags');
if ($tags && is_array($tags)) {
$data['pageTags'] = $tags;
}
$json = json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS);
$view = $event->getArgument('view');
$view->addHeaderItem("<script>window.dataLayer=window.dataLayer||[];window.dataLayer.push({$json});</script>");
});
Block-Level Data Layer
Create a custom block type that pushes context-specific data. In blocks/analytics_data/view.php:
<?php defined('C5_EXECUTE') or die('Access Denied.');
$page = Page::getCurrentPage();
$area = $this->getAreaObject();
?>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'block_view',
'blockArea': '<?php echo htmlspecialchars($area ? $area->getAreaHandle() : ''); ?>',
'blockType': '<?php echo htmlspecialchars($this->getBlockTypeHandle()); ?>',
'blockId': <?php echo (int)$this->getBlockID(); ?>
});
</script>
Page Attributes for Custom Dimensions
Concrete CMS page attributes let editors set per-page metadata. Create custom attributes via Dashboard > Pages & Themes > Attributes:
analytics_content_group(Select) - Content grouping for GA4analytics_author(Text) - Content authoranalytics_campaign(Text) - Associated campaign
Access them in the data layer:
$contentGroup = $c->getAttribute('analytics_content_group');
$author = $c->getAttribute('analytics_author');
$campaign = $c->getAttribute('analytics_campaign');
E-commerce Tracking
Concrete CMS does not include built-in e-commerce. Sites typically integrate with external commerce solutions or use community packages. For sites using a custom shopping cart built with Concrete's Express objects or external APIs, analytics hooks are implemented in the checkout flow.
Product View (Custom Implementation)
If you use Concrete page types for product pages:
<?php
// themes/my_theme/product_detail.php
$c = Page::getCurrentPage();
$productSku = $c->getAttribute('product_sku');
$productName = $c->getCollectionName();
$productPrice = (float) $c->getAttribute('product_price');
$productCategory = $c->getAttribute('product_category');
?>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'view_item',
'ecommerce': {
'currency': 'USD',
'value': <?php echo $productPrice; ?>,
'items': [{
'item_id': '<?php echo htmlspecialchars($productSku); ?>',
'item_name': '<?php echo htmlspecialchars($productName); ?>',
'price': <?php echo $productPrice; ?>,
'item_category': '<?php echo htmlspecialchars($productCategory); ?>'
}]
}
});
</script>
Cart and Purchase Events
For AJAX-based cart operations, push data layer events from JavaScript:
// When add-to-cart AJAX succeeds
function trackAddToCart(product) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'add_to_cart',
'ecommerce': {
'currency': product.currency,
'value': product.price * product.quantity,
'items': [{
'item_id': product.sku,
'item_name': product.name,
'price': product.price,
'quantity': product.quantity
}]
}
});
}
For the purchase confirmation, render server-side in the thank-you page template or via a controller response:
<?php
// In the order confirmation page controller or single_page
$order = $this->get('order'); // passed from controller
if ($order) {
$items = [];
foreach ($order->getItems() as $item) {
$items[] = [
'item_id' => $item->getSku(),
'item_name' => $item->getName(),
'price' => (float) $item->getPrice(),
'quantity' => (int) $item->getQuantity(),
];
}
$ecommerce = [
'event' => 'purchase',
'ecommerce' => [
'transaction_id' => $order->getId(),
'value' => (float) $order->getTotal(),
'tax' => (float) $order->getTax(),
'shipping' => (float) $order->getShipping(),
'currency' => $order->getCurrency(),
'items' => $items,
],
];
echo '<script>window.dataLayer=window.dataLayer||[];window.dataLayer.push('
. json_encode($ecommerce, JSON_HEX_TAG)
. ');</script>';
}
?>
Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Tracking code disappears after theme update | Code was added directly to theme files that were overwritten during update | Use the Dashboard Tracking Codes interface or a package instead of editing theme files |
| Data layer values are the same for all users | Full-page caching stores the HTML with the first user's data layer values | Disable full-page caching for pages with user-specific data, or load user data via AJAX |
| GTM fires but data layer is empty | The data layer script renders after the GTM container script in the HTML | Ensure addHeaderItem() for the data layer uses a lower priority or place the inline script before GTM in elements/header.php |
| Scripts do not fire on the dashboard | Dashboard pages use a different layout that does not include theme header/footer elements | Dashboard pages should not have marketing analytics; check $page->isAdminArea() to exclude them |
on_page_view event does not fire on cached pages |
In Concrete < 9, full-page cache serves the response before the event fires | Upgrade to Concrete 9+ where the event fires regardless of cache, or disable full-page cache |
| Duplicate tracking on edit mode | Edit mode renders the page with toolbars; tracking scripts fire in both view and edit mode | Check $c->isEditMode() and skip analytics injection when true |
| Block data layer push fires multiple times | Same block placed in multiple areas on the page, each rendering its view.php |
Use block ID or area name to deduplicate pushes, or consolidate into a single page-level data layer |
| Page attributes return objects instead of strings | Select and other attribute types return objects, not scalar values | Cast attribute values: use ->__toString() for select attributes or ->getSelectAttributeOptionDisplayOrder() for sortable values |
Platform-Specific Considerations
Concrete 9 vs legacy versions. Concrete 9 (based on Symfony components) changed the event system, asset registration, and package structure. If running Concrete 5.7 or 8, the Events::addListener() syntax differs from the Symfony EventDispatcher used in v9. Theme file locations also changed (themes/ directory structure is slightly different).
Single Pages for custom routes. Concrete CMS uses Single Pages for custom URL routes (similar to controllers). Single Pages at application/single_pages/ render via view.php files and can include page-specific data layer pushes. For checkout flows built as Single Pages, inject ecommerce tracking in the Single Page's view.php.
Multilingual sites. Concrete's multilingual system creates separate page trees per language. Each tree has its own home page and content. The on_page_view event fires with the correct locale. Include Localization::activeLanguage() in your data layer for language-based GA4 reporting.
Express Objects. Express is Concrete's custom data modeling system (similar to custom post types). Express objects display through Express Entry Detail blocks. To track Express entity views, create a custom Express Entry Detail block template that includes a data layer push with the entity's attributes.
CDN and reverse proxy. When Concrete CMS runs behind a CDN (Cloudflare, AWS CloudFront), the CDN may cache full HTML responses including inline scripts. Configure the CDN to respect Vary headers or set cache-control headers on pages with dynamic data layer content.
Marketplace packages. Concrete CMS has a marketplace with analytics packages (Google Analytics, Tag Manager, Matomo). These packages typically use the Dashboard configuration approach internally. Review the package source to ensure it does not conflict with your custom analytics implementation.