Concrete CMS Data Layer Structure | OpsBlu Docs

Concrete CMS Data Layer Structure

Complete reference for implementing a custom data layer in Concrete CMS for Google Tag Manager integration.

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

  1. Page loads → Custom code pushes initial data to data layer
  2. User interactions → JavaScript pushes events to data layer
  3. GTM listens → Captures events and variables
  4. 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 Name
  • form.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:

  1. GTMVariablesNew
  2. Variable Type: Data Layer Variable
  3. Data Layer Variable Name: Enter the path (e.g., page.type)
  4. Data Layer Version: Version 2
  5. 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:

  1. Variable Type: Lookup Table
  2. Input Variable: \{\{CMS - Page Type\}\}
  3. Mappings:
    • blog_entryBlog
    • pageStandard Page
    • homeHomepage
  4. Default Value: Other

Common GTM Triggers for Concrete CMS

All Pages Trigger

  • Type: Custom Event
  • Event name: page_loaded
  • Use for: GA4 config tag, pageview events

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

  1. Enable Preview in GTM
  2. Navigate to your Concrete CMS site
  3. Click Data Layer tab in Tag Assistant
  4. Inspect each data layer push
  5. 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

For general data layer concepts, see Data Layer Guide.