Unlike platforms like Shopify that provide a native data layer, Concrete CMS requires you to implement a custom data layer. This guide shows you how to structure and populate a data layer for effective GTM integration.
Data Layer Overview
The data layer is a JavaScript object that holds information about your page, user, and interactions. GTM reads this data to trigger tags and pass information to analytics platforms.
How the Data Layer Works
- Page loads → Custom code pushes initial data to data layer
- User interactions → JavaScript pushes events to data layer
- GTM listens → Captures events and variables
- Tags fire → Send data to analytics platforms
Implementing the Base Data Layer
Add this to your page template before the GTM container code:
<?php
if (!$c->isEditMode() && !$this->controller->isControllerTaskInstanceOf('DashboardPageController')) {
$app = \Concrete\Core\Support\Facade\Application::getFacadeApplication();
$c = Page::getCurrentPage();
$u = $app->make(\Concrete\Core\User\User::class);
// Get page information
$pageType = $c->getPageTypeHandle() ?: 'single_page';
$pageID = $c->getCollectionID();
$pageName = $c->getCollectionName();
$pagePath = $c->getCollectionPath();
// Get user information
$userType = $u->isRegistered() ? 'registered' : 'guest';
$userID = $u->isRegistered() ? $u->getUserID() : '';
?>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'page_loaded',
'page': {
'type': '<?php echo $pageType; ?>',
'id': '<?php echo $pageID; ?>',
'name': '<?php echo addslashes($pageName); ?>',
'path': '<?php echo $pagePath; ?>',
'url': '<?php echo $c->getCollectionLink(); ?>'
},
'user': {
'type': '<?php echo $userType; ?>',
'id': '<?php echo $userID; ?>'
}
});
</script>
<?php
}
?>
Page Information Variables
Page Type
Different page types in Concrete CMS:
| Page Type Handle | Description | Example |
|---|---|---|
page |
Standard page | About, Services |
blog_entry |
Blog post | Article, News post |
home |
Homepage | Landing page |
portfolio_project |
Portfolio item | Work sample |
single_page |
System pages | Login, Search |
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
page.type - Name:
CMS - Page Type
Page ID
Unique identifier for the page:
$pageID = $c->getCollectionID();
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
page.id - Name:
CMS - Page ID
Page Name
Human-readable page title:
$pageName = $c->getCollectionName();
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
page.name - Name:
CMS - Page Name
Page Path
URL path of the page:
$pagePath = $c->getCollectionPath();
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
page.path - Name:
CMS - Page Path
User Information Variables
User Type
Whether user is logged in:
$u = $app->make(\Concrete\Core\User\User::class);
$userType = $u->isRegistered() ? 'registered' : 'guest';
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
user.type - Name:
CMS - User Type
User ID
For registered users (privacy-safe):
$userID = $u->isRegistered() ? $u->getUserID() : '';
GTM Variable:
- Type: Data Layer Variable
- Data Layer Variable Name:
user.id - Name:
CMS - User ID
Additional User Properties
<?php
if ($u->isRegistered()) {
$userInfo = $u->getUserInfoObject();
$userEmail = $userInfo->getUserEmail(); // Hash before sending to analytics
$userGroups = [];
foreach ($u->getUserGroups() as $group) {
$userGroups[] = $group->getGroupName();
}
?>
<script>
dataLayer.push({
'user': {
'email_hash': '<?php echo hash('sha256', strtolower($userEmail)); ?>',
'groups': <?php echo json_encode($userGroups); ?>,
'registration_date': '<?php echo $userInfo->getUserDateAdded(); ?>'
}
});
</script>
<?php
}
?>
Content Category Variables
Blog Post Information
For blog pages:
<?php
if ($pageType === 'blog_entry') {
// Get blog-specific data
$author = $c->getVersionObject()->getVersionAuthorUserName();
$publishDate = $c->getCollectionDatePublic('Y-m-d');
// Get categories/tags if using taxonomy
$categories = [];
// Custom code to get your taxonomy terms
?>
<script>
dataLayer.push({
'content': {
'type': 'blog_post',
'author': '<?php echo addslashes($author); ?>',
'publish_date': '<?php echo $publishDate; ?>',
'categories': <?php echo json_encode($categories); ?>
}
});
</script>
<?php
}
?>
GTM Variables:
Content Type:
- Data Layer Variable Name:
content.type - Name:
CMS - Content Type
Content Author:
- Data Layer Variable Name:
content.author - Name:
CMS - Content Author
Publish Date:
- Data Layer Variable Name:
content.publish_date - Name:
CMS - Publish Date
E-Commerce Variables (Community Store)
If using Community Store add-on:
Product Page Data Layer
<?php
// On product page
if (isset($product) && is_object($product)) {
?>
<script>
dataLayer.push({
'event': 'product_detail_view',
'ecommerce': {
'detail': {
'products': [{
'id': '<?php echo $product->getID(); ?>',
'name': '<?php echo addslashes($product->getName()); ?>',
'price': '<?php echo $product->getPrice(); ?>',
'brand': '<?php echo addslashes($product->getManufacturer()); ?>',
'category': '<?php echo addslashes($product->getGroupName()); ?>'
}]
}
}
});
</script>
<?php
}
?>
Cart Data Layer
<?php
use \Concrete\Package\CommunityStore\Src\CommunityStore\Cart\Cart as StoreCart;
$cart = StoreCart::getCart();
if ($cart && count($cart) > 0) {
$cartItems = [];
$cartTotal = 0;
foreach ($cart as $item) {
$product = $item['product'];
$cartItems[] = [
'id' => $product->getID(),
'name' => $product->getName(),
'price' => $item['product_price'],
'quantity' => $item['product_qty']
];
$cartTotal += $item['product_price'] * $item['product_qty'];
}
?>
<script>
dataLayer.push({
'cart': {
'items': <?php echo json_encode($cartItems); ?>,
'total': <?php echo $cartTotal; ?>,
'item_count': <?php echo count($cart); ?>
}
});
</script>
<?php
}
?>
Event Tracking via Data Layer
Form Submission Event
Add to form block template or JavaScript:
<script>
document.addEventListener('DOMContentLoaded', function() {
const forms = document.querySelectorAll('form[action*="/ccm/system/form/submit"]');
forms.forEach(function(form) {
form.addEventListener('submit', function(e) {
dataLayer.push({
'event': 'form_submit',
'form': {
'name': form.getAttribute('data-form-name') || 'Unknown',
'id': form.id || 'no-id',
'page_path': window.location.pathname
}
});
});
});
});
</script>
GTM Trigger:
- Type: Custom Event
- Event name:
form_submit
GTM Variables:
form.name→ Form Nameform.id→ Form ID
File Download Event
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileLinks = document.querySelectorAll('a[href*="/download_file/"]');
fileLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
const fileName = link.textContent.trim() || link.href.split('/').pop();
const fileExtension = fileName.split('.').pop();
dataLayer.push({
'event': 'file_download',
'file': {
'name': fileName,
'extension': fileExtension,
'url': link.href
}
});
});
});
});
</script>
GTM Trigger:
- Type: Custom Event
- Event name:
file_download
Search Event
<script>
// Check if on search results page
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('query') || urlParams.get('search_paths');
if (searchQuery) {
dataLayer.push({
'event': 'search',
'search': {
'term': searchQuery,
'page': window.location.pathname
}
});
}
</script>
GTM Trigger:
- Type: Custom Event
- Event name:
search
Video Play Event
<script>
// For embedded YouTube videos
window.onYouTubeIframeAPIReady = function() {
document.querySelectorAll('iframe[src*="youtube.com"]').forEach(function(iframe) {
const player = new YT.Player(iframe, {
events: {
'onStateChange': function(event) {
if (event.data === YT.PlayerState.PLAYING) {
dataLayer.push({
'event': 'video_start',
'video': {
'title': iframe.title || 'Unknown',
'provider': 'YouTube'
}
});
}
}
}
});
});
};
</script>
Creating GTM Variables
Method 1: Data Layer Variables (Simple)
For simple values that exist directly in data layer:
- GTM → Variables → New
- Variable Type: Data Layer Variable
- Data Layer Variable Name: Enter the path (e.g.,
page.type) - Data Layer Version: Version 2
- Name: Give it a descriptive name (e.g.,
CMS - Page Type)
Method 2: Custom JavaScript (Complex)
For values that need processing:
// Variable: Get Page Category from Path
function() {
const path = {{Page Path}} || '';
const segments = path.split('/').filter(Boolean);
return segments[0] || 'home';
}
Method 3: Lookup Tables
For mapping Concrete CMS values to custom values:
- Variable Type: Lookup Table
- Input Variable:
\{\{CMS - Page Type\}\} - Mappings:
blog_entry→Blogpage→Standard Pagehome→Homepage
- Default Value:
Other
Common GTM Triggers for Concrete CMS
All Pages Trigger
Page Type Trigger
- Type: Custom Event
- Event name:
page_loaded - Condition:
Page Type equals blog_entry - Use for: Blog-specific tags
Form Submission Trigger
- Type: Custom Event
- Event name:
form_submit - Use for: GA4 form events, conversion tracking
File Download Trigger
- Type: Custom Event
- Event name:
file_download - Use for: Tracking document downloads
Advanced Data Layer Implementation
Conditional Data by Page Type
<?php
if (!$c->isEditMode() && !$this->controller->isControllerTaskInstanceOf('DashboardPageController')) {
$pageType = $c->getPageTypeHandle();
// Base data layer
?>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'page_loaded',
'page': {
'type': '<?php echo $pageType; ?>',
'id': '<?php echo $c->getCollectionID(); ?>'
}
});
<?php
// Blog-specific data
if ($pageType === 'blog_entry') {
?>
dataLayer.push({
'content': {
'type': 'blog_post',
'author': '<?php echo addslashes($c->getVersionObject()->getVersionAuthorUserName()); ?>'
}
});
<?php
}
// Product-specific data (if using e-commerce)
if ($pageType === 'product') {
// Add product data
}
?>
</script>
<?php
}
?>
User Interaction Tracking
<script>
// Track scroll depth
(function() {
let depths = [25, 50, 75, 90];
let tracked = {};
window.addEventListener('scroll', function() {
const percent = Math.round(
((window.scrollY + window.innerHeight) / document.body.scrollHeight) * 100
);
depths.forEach(function(depth) {
if (percent >= depth && !tracked[depth]) {
tracked[depth] = true;
dataLayer.push({
'event': 'scroll_depth',
'scroll': {
'depth': depth,
'page_path': window.location.pathname
}
});
}
});
});
})();
</script>
Debugging Data Layer
Console Commands
View entire data layer:
console.table(window.dataLayer);
Find specific event:
window.dataLayer.filter(obj => obj.event === 'form_submit');
Monitor new pushes:
const originalPush = window.dataLayer.push;
window.dataLayer.push = function() {
console.log('New Data Layer Push:', arguments[0]);
originalPush.apply(window.dataLayer, arguments);
};
GTM Preview Mode
- Enable Preview in GTM
- Navigate to your Concrete CMS site
- Click Data Layer tab in Tag Assistant
- Inspect each data layer push
- Verify values populate correctly
Best Practices
1. Initialize Data Layer Early
Always initialize before GTM loads:
<script>
window.dataLayer = window.dataLayer || [];
// Push initial data
dataLayer.push({...});
</script>
<!-- GTM container code comes after -->
2. Use Consistent Naming
Use a consistent structure:
{
'event': 'event_name',
'category': {
'property': 'value'
}
}
3. Sanitize Data
Always escape strings:
'name': '<?php echo addslashes($name); ?>'
4. Handle Missing Data
Provide fallback values:
$pageType = $c->getPageTypeHandle() ?: 'unknown';
5. Don't Include PII
Hash email addresses and other PII:
'email_hash': '<?php echo hash('sha256', $email); ?>'
Troubleshooting
Data Layer is Undefined
Check:
- Data layer initialized before GTM
- No JavaScript errors blocking execution
- Concrete CMS cache cleared
Variables Return Undefined
Check:
- Variable name matches exact data layer path (case-sensitive)
- Event has fired before variable is accessed
- Data exists on current page type
Events Fire Multiple Times
Cause: Multiple data layer pushes or duplicate event listeners.
Fix: Debug with console to identify source, remove duplicates.
Next Steps
- GTM Setup - Install GTM on Concrete CMS
- GA4 Event Tracking - Use data layer for GA4
- Events Not Firing - Debug tracking issues
For general data layer concepts, see Data Layer Guide.