TYPO3 GTM Data Layer Implementation | OpsBlu Docs

TYPO3 GTM Data Layer Implementation

Build data layers in TYPO3 using TypoScript, Fluid templates, and PHP DataProcessors for GTM tracking

Three methods for building TYPO3-specific data layers for Google Tag Manager: TypoScript inline JS with insertData, Fluid template partials, and PHP DataProcessorInterface implementations. Includes page metadata, user state, ecommerce events, form tracking, and Core Web Vitals.

Understanding the Data Layer

The data layer is a JavaScript object that stores information about your page, user, and interactions, making it available to GTM tags.

Basic Data Layer Structure

window.dataLayer = window.dataLayer || [];
dataLayer.push({
    'event': 'pageview',
    'page': {
        'type': 'product',
        'category': 'electronics',
        'title': 'Product Name'
    },
    'user': {
        'id': '12345',
        'type': 'logged_in'
    }
});

Method 1: TypoScript Data Layer

Basic Page Data Layer

page.jsInline {
    100 = TEXT
    100.value (
        window.dataLayer = window.dataLayer || [];
        dataLayer.push({
            'pageType': '{page:backend_layout}',
            'pageId': {page:uid},
            'pageTitle': '{page:title}',
            'siteLanguage': '{site:language.twoLetterIsoCode}',
            'siteName': '{site:identifier}'
        });
    )
    100.insertData = 1
}

Advanced Page Information

page.jsInline {
    100 = COA
    100 {
        # Initialize dataLayer
        10 = TEXT
        10.value = window.dataLayer = window.dataLayer || [];

        # Push page data
        20 = TEXT
        20.stdWrap {
            dataWrap (
                dataLayer.push({
                    'event': 'typo3.pageview',
                    'page': {
                        'id': {page:uid},
                        'pid': {page:pid},
                        'type': '{page:backend_layout}',
                        'title': '{page:title}',
                        'navigation_title': '{page:nav_title}',
                        'author': '{page:author}',
                        'created': '{page:crdate}',
                        'modified': '{page:tstamp}',
                        'language': '{site:language.twoLetterIsoCode}',
                        'template': '{page:backend_layout}'
                    },
                    'site': {
                        'identifier': '{site:identifier}',
                        'base': '{site:base}',
                        'rootPageId': '{site:rootPageId}'
                    }
                });
            )
            insertData = 1
        }
    }
}

User Information Data Layer

# Frontend User Data
[frontend.user.isLoggedIn == true]
page.jsInline.110 = TEXT
page.jsInline.110.stdWrap {
    dataWrap (
        dataLayer.push({
            'user': {
                'id': '{TSFE:fe_user|user|uid}',
                'username': '{TSFE:fe_user|user|username}',
                'usergroup': '{TSFE:fe_user|user|usergroup}',
                'loggedIn': true,
                'registeredSince': '{TSFE:fe_user|user|crdate}'
            }
        });
    )
    insertData = 1
}
[END]

# Guest User
[frontend.user.isLoggedIn == false]
page.jsInline.110 = TEXT
page.jsInline.110.value (
    dataLayer.push({
        'user': {
            'loggedIn': false,
            'type': 'guest'
        }
    });
)
[END]

Content Type Detection

# Detect page type and push relevant data
[page["doktype"] == 1]
    # Standard page
    page.jsInline.120 = TEXT
    page.jsInline.120.value (
        dataLayer.push({
            'content': {
                'type': 'standard_page',
                'category': 'content'
            }
        });
    )
[END]

[page["doktype"] == 254]
    # Folder/SysFolder
    page.jsInline.120 = TEXT
    page.jsInline.120.value (
        dataLayer.push({
            'content': {
                'type': 'folder',
                'category': 'system'
            }
        });
    )
[END]

Method 2: Fluid Template Data Layer

Base Layout with Data Layer

<!-- Resources/Private/Layouts/Page.html -->
<!DOCTYPE html>
<html lang="{language}">
<head>
    <meta charset="utf-8">
    <title>{page.title}</title>

    <!-- Initialize Data Layer -->
    <script>
        window.dataLayer = window.dataLayer || [];
    </script>

    <f:render partial="Analytics/DataLayer" arguments="{_all}" />
</head>
<body>
    <f:render section="Main" />
</body>
</html>

Data Layer Partial

Create Resources/Private/Partials/Analytics/DataLayer.html:

<script>
dataLayer.push({
    'event': 'typo3.pageload',
    'page': {
        'id': '{data.uid}',
        'title': '{data.title -> f:format.htmlentities()}',
        'type': '{data.backend_layout}',
        'language': '{language.twoLetterIsoCode}'
    },
    <f:if condition="{user}">
        'user': {
            'id': '{user.uid}',
            'loggedIn': true,
            'userType': '<f:for each="{user.usergroups}" as="group" iteration="iterator">{group.title}<f:if condition="{iterator.isLast}"><f:else>, </f:else></f:if></f:for>'
        },
    </f:if>
    'site': {
        'identifier': '{site.identifier}',
        'rootPage': '{site.rootPageId}'
    }
});
</script>

News Extension Data Layer

<!-- Resources/Private/Templates/News/Detail.html -->
<f:if condition="{newsItem}">
    <script>
        dataLayer.push({
            'event': 'news.view',
            'content': {
                'type': 'news_article',
                'id': '{newsItem.uid}',
                'title': '{newsItem.title -> f:format.htmlentities()}',
                'author': '{newsItem.author -> f:format.htmlentities()}',
                'publishDate': '{newsItem.datetime -> f:format.date(format: "Y-m-d")}',
                'categories': [
                    <f:for each="{newsItem.categories}" as="category" iteration="iterator">
                        '{category.title -> f:format.htmlentities()}'<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
                    </f:for>
                ],
                'tags': [
                    <f:for each="{newsItem.tags}" as="tag" iteration="iterator">
                        '{tag.title -> f:format.htmlentities()}'<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
                    </f:for>
                ]
            }
        });
    </script>
</f:if>

Product Data Layer (E-commerce)

<!-- Product Detail Template -->
<f:if condition="{product}">
    <script>
        dataLayer.push({
            'event': 'product.view',
            'ecommerce': {
                'currencyCode': 'EUR',
                'detail': {
                    'products': [{
                        'id': '{product.uid}',
                        'name': '{product.title -> f:format.htmlentities()}',
                        'price': {product.price},
                        'brand': '{product.brand -> f:format.htmlentities()}',
                        'category': '{product.category.title -> f:format.htmlentities()}',
                        'variant': '{product.variant}',
                        'quantity': 1,
                        'dimension1': '{product.manufacturer}',
                        'dimension2': '{product.color}',
                        'metric1': {product.rating}
                    }]
                }
            }
        });
    </script>
</f:if>

Method 3: PHP-Based Data Layer

Custom DataProcessor

Create Classes/DataProcessing/DataLayerProcessor.php:

<?php
declare(strict_types=1);

namespace Vendor\Extension\DataProcessing;

use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;

class DataLayerProcessor implements DataProcessorInterface
{
    public function process(
        ContentObjectRenderer $cObj,
        array $contentObjectConfiguration,
        array $processorConfiguration,
        array $processedData
    ): array {
        $dataLayer = [
            'page' => [
                'id' => $GLOBALS['TSFE']->id,
                'title' => $GLOBALS['TSFE']->page['title'],
                'type' => $GLOBALS['TSFE']->page['doktype'],
            ],
            'user' => $this->getUserData(),
            'content' => $this->getContentData($cObj),
        ];

        $processedData['dataLayer'] = $dataLayer;

        return $processedData;
    }

    protected function getUserData(): array
    {
        $userData = ['loggedIn' => false];

        if ($GLOBALS['TSFE']->fe_user->user) {
            $userData = [
                'loggedIn' => true,
                'id' => $GLOBALS['TSFE']->fe_user->user['uid'],
                'username' => $GLOBALS['TSFE']->fe_user->user['username'],
                'usergroups' => $GLOBALS['TSFE']->fe_user->user['usergroup'],
            ];
        }

        return $userData;
    }

    protected function getContentData(ContentObjectRenderer $cObj): array
    {
        return [
            'colPos' => $cObj->data['colPos'] ?? 0,
            'CType' => $cObj->data['CType'] ?? '',
            'uid' => $cObj->data['uid'] ?? 0,
        ];
    }
}

TypoScript Configuration for DataProcessor

page {
    10 = FLUIDTEMPLATE
    10 {
        templateRootPaths {
            0 = EXT:your_sitepackage/Resources/Private/Templates/
        }

        dataProcessing {
            100 = Vendor\Extension\DataProcessing\DataLayerProcessor
        }
    }
}

Use in Fluid Template

<script>
dataLayer.push({
    'page': {
        'id': {dataLayer.page.id},
        'title': '{dataLayer.page.title -> f:format.htmlentities()}',
        'type': {dataLayer.page.type}
    },
    'user': {
        'loggedIn': <f:if condition="{dataLayer.user.loggedIn}" then="true" else="false" />,
        <f:if condition="{dataLayer.user.id}">
            'id': '{dataLayer.user.id}',
            'username': '{dataLayer.user.username -> f:format.htmlentities()}'
        </f:if>
    }
});
</script>

E-commerce Data Layer

Product List (Category Page)

<script>
dataLayer.push({
    'event': 'productList.view',
    'ecommerce': {
        'currencyCode': 'EUR',
        'impressions': [
            <f:for each="{products}" as="product" iteration="iterator">
            {
                'id': '{product.uid}',
                'name': '{product.title -> f:format.htmlentities()}',
                'price': {product.price},
                'category': '{product.category.title -> f:format.htmlentities()}',
                'list': 'Product Listing',
                'position': {iterator.cycle}
            }<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
            </f:for>
        ]
    }
});
</script>

Add to Cart Event

<f:form action="addToCart" controller="Cart">
    <f:form.hidden name="product" value="{product.uid}" />
    <f:form.submit value="Add to Cart" class="btn-add-to-cart"
                   data-product-id="{product.uid}"
                   data-product-name="{product.title}"
                   data-product-price="{product.price}" />
</f:form>

<script>
document.querySelector('.btn-add-to-cart').addEventListener('click', function(e) {
    dataLayer.push({
        'event': 'addToCart',
        'ecommerce': {
            'currencyCode': 'EUR',
            'add': {
                'products': [{
                    'id': this.getAttribute('data-product-id'),
                    'name': this.getAttribute('data-product-name'),
                    'price': this.getAttribute('data-product-price'),
                    'quantity': 1
                }]
            }
        }
    });
});
</script>

Checkout Process

<!-- Checkout Step 1: Review Cart -->
<script>
dataLayer.push({
    'event': 'checkout',
    'ecommerce': {
        'currencyCode': 'EUR',
        'checkout': {
            'actionField': {'step': 1, 'option': 'Review Cart'},
            'products': [
                <f:for each="{cart.items}" as="item" iteration="iterator">
                {
                    'id': '{item.product.uid}',
                    'name': '{item.product.title -> f:format.htmlentities()}',
                    'price': {item.product.price},
                    'quantity': {item.quantity}
                }<f:if condition="{iterator.isLast}"><f:else>,</f:else></f:if>
                </f:for>
            ]
        }
    }
});
</script>

Purchase Confirmation

<?php
// In your checkout controller
public function confirmationAction()
{
    $order = $this->session->get('completed_order');

    if ($order) {
        $products = [];
        foreach ($order->getItems() as $item) {
            $products[] = [
                'id' => $item->getProduct()->getUid(),
                'name' => $item->getProduct()->getTitle(),
                'price' => $item->getProduct()->getPrice(),
                'quantity' => $item->getQuantity(),
                'category' => $item->getProduct()->getCategory()->getTitle(),
            ];
        }

        $purchaseData = [
            'event' => 'purchase',
            'ecommerce' => [
                'currencyCode' => 'EUR',
                'purchase' => [
                    'actionField' => [
                        'id' => $order->getOrderNumber(),
                        'affiliation' => 'TYPO3 Shop',
                        'revenue' => $order->getTotal(),
                        'tax' => $order->getTax(),
                        'shipping' => $order->getShippingCost(),
                        'coupon' => $order->getCouponCode() ?: '',
                    ],
                    'products' => $products,
                ],
            ],
        ];

        $GLOBALS['TSFE']->additionalFooterData['gtm_purchase'] = sprintf(
            '<script>dataLayer.push(%s);</script>',
            json_encode($purchaseData)
        );
    }
}

Form Tracking with Data Layer

Form Framework

page.jsFooterInline.600 = TEXT
page.jsFooterInline.600.value (
// Track form interactions
document.addEventListener('DOMContentLoaded', function() {
    const forms = document.querySelectorAll('[data-type="finisher"]');

    forms.forEach(function(form) {
        const formId = form.getAttribute('id');
        const formName = form.querySelector('legend')?.textContent || 'Unknown Form';

        // Track form start (first field focus)
        const firstField = form.querySelector('input, textarea, select');
        if (firstField) {
            firstField.addEventListener('focus', function() {
                dataLayer.push({
                    'event': 'form.start',
                    'form': {
                        'id': formId,
                        'name': formName,
                        'type': 'typo3_form'
                    }
                });
            }, {once: true});
        }

        // Track form submission
        form.addEventListener('submit', function(e) {
            dataLayer.push({
                'event': 'form.submit',
                'form': {
                    'id': formId,
                    'name': formName,
                    'type': 'typo3_form'
                }
            });
        });
    });
});
)

Powermail Data Layer

page.jsFooterInline.601 = TEXT
page.jsFooterInline.601.value (
// Powermail tracking
jQuery(document).on('powermail:submit:success', function(e, data) {
    dataLayer.push({
        'event': 'form.submit.success',
        'form': {
            'id': data.formUid,
            'type': 'powermail',
            'status': 'success'
        }
    });
});

jQuery(document).on('powermail:submit:error', function(e, data) {
    dataLayer.push({
        'event': 'form.submit.error',
        'form': {
            'id': data.formUid,
            'type': 'powermail',
            'status': 'error'
        }
    });
});
)

Search Data Layer

# indexed_search integration
[request.getQueryParams()['tx_indexedsearch_pi2']['search']['sword'] != '']
page.jsInline.130 = TEXT
page.jsInline.130.stdWrap {
    dataWrap (
        dataLayer.push({
            'event': 'search',
            'search': {
                'term': '{request.getQueryParams()['tx_indexedsearch_pi2']['search']['sword']}',
                'type': 'site_search',
                'results': document.querySelectorAll('.tx-indexedsearch-res').length || 0
            }
        });
    )
    insertData = 1
}
[END]
page.jsFooterInline.602 = TEXT
page.jsFooterInline.602.value (
// Track navigation clicks
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('nav a, .menu a').forEach(function(link) {
        link.addEventListener('click', function(e) {
            dataLayer.push({
                'event': 'navigation.click',
                'navigation': {
                    'text': this.textContent.trim(),
                    'url': this.href,
                    'type': this.closest('nav')?.className || 'unknown'
                }
            });
        });
    });
});
)

Custom Dimensions and Metrics

Page-Level Custom Dimensions

page.jsInline.140 = TEXT
page.jsInline.140.stdWrap {
    dataWrap (
        dataLayer.push({
            'customDimensions': {
                'dimension1': '{page:author}',
                'dimension2': '{page:backend_layout}',
                'dimension3': '{site:identifier}',
                'dimension4': '{site:language.title}'
            },
            'customMetrics': {
                'metric1': {page:uid},
                'metric2': {page:hits}
            }
        });
    )
    insertData = 1
}

Error Tracking

page.jsFooterInline.603 = TEXT
page.jsFooterInline.603.value (
// Track JavaScript errors
window.addEventListener('error', function(e) {
    dataLayer.push({
        'event': 'javascript.error',
        'error': {
            'message': e.message,
            'filename': e.filename,
            'line': e.lineno,
            'column': e.colno
        }
    });
});

// Track AJAX errors
document.addEventListener('ajaxError', function(e) {
    dataLayer.push({
        'event': 'ajax.error',
        'error': {
            'url': e.detail.url,
            'status': e.detail.status
        }
    });
});
)

Performance Metrics

page.jsFooterInline.604 = TEXT
page.jsFooterInline.604.value (
// Track Core Web Vitals
window.addEventListener('load', function() {
    if ('PerformanceObserver' in window) {
        // LCP
        new PerformanceObserver((list) => {
            const entries = list.getEntries();
            const lastEntry = entries[entries.length - 1];

            dataLayer.push({
                'event': 'web-vitals',
                'metric': {
                    'name': 'LCP',
                    'value': Math.round(lastEntry.renderTime || lastEntry.loadTime),
                    'rating': lastEntry.renderTime < 2500 ? 'good' : 'needs-improvement'
                }
            });
        }).observe({type: 'largest-contentful-paint', buffered: true});

        // FID
        new PerformanceObserver((list) => {
            list.getEntries().forEach((entry) => {
                dataLayer.push({
                    'event': 'web-vitals',
                    'metric': {
                        'name': 'FID',
                        'value': Math.round(entry.processingStart - entry.startTime),
                        'rating': entry.processingStart - entry.startTime < 100 ? 'good' : 'needs-improvement'
                    }
                });
            });
        }).observe({type: 'first-input', buffered: true});

        // CLS
        let clsValue = 0;
        new PerformanceObserver((list) => {
            list.getEntries().forEach((entry) => {
                if (!entry.hadRecentInput) {
                    clsValue += entry.value;
                }
            });

            dataLayer.push({
                'event': 'web-vitals',
                'metric': {
                    'name': 'CLS',
                    'value': clsValue,
                    'rating': clsValue < 0.1 ? 'good' : 'needs-improvement'
                }
            });
        }).observe({type: 'layout-shift', buffered: true});
    }
});
)

Debugging Data Layer

Console Logger

page.jsFooterInline.999 = TEXT
page.jsFooterInline.999.value (
    // Log all dataLayer pushes (development only)
    (function() {
        const originalPush = window.dataLayer.push;
        window.dataLayer.push = function() {
            console.group('dataLayer.push');
            console.log(arguments[0]);
            console.groupEnd();
            return originalPush.apply(this, arguments);
        };
    })();
)

GTM Preview Mode

  1. In GTM, click Preview
  2. Enter TYPO3 site URL
  3. Check Variables tab to see data layer values
  4. Verify all variables populate correctly

Best Practices

  1. Initialize Early: Place dataLayer initialization before GTM script
  2. Use Events: Push events to dataLayer for user interactions
  3. Consistent Naming: Use camelCase or snake_case consistently
  4. Avoid PII: Never include email, phone, or personal data unhashed
  5. Document Structure: Maintain documentation of data layer structure
  6. Test Thoroughly: Use GTM Preview and console logging

Next Steps