Analytics Architecture on Bolt CMS
Bolt CMS is built on Symfony and uses Twig as its templating engine. The template hierarchy lives in public/theme/[theme-name]/, with layout inheritance providing the injection points for analytics code.
The base template (typically _base.html.twig or partials/_master.html.twig) defines blocks that child templates extend:
{# _base.html.twig #}
<!DOCTYPE html>
<html lang="{{ config.get('general/locale')|split('_')[0] }}">
<head>
{% block headtags %}{% endblock %}
<meta charset="UTF-8">
<title>{% block title %}{{ record.title|default(config.get('general/sitename')) }}{% endblock %}</title>
{% block headincludes %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block extrajavascript %}{% endblock %}
</body>
</html>
Content types are defined in config/bolt/contenttypes.yaml. Each content type has fields that Twig templates access via the record variable. Bolt's config.yml (or config/bolt/config.yaml in Bolt 5) contains global settings accessible as config.get('key').
Installing Tracking Scripts
Method 1: Twig Block Injection
Override the headincludes and extrajavascript blocks in your base template:
{# _base.html.twig #}
<head>
{% block headincludes %}
<!-- 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>
{% endblock %}
</head>
<body>
<!-- GTM noscript -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
{% block body %}{% endblock %}
{% block extrajavascript %}
<!-- Additional analytics scripts load here -->
{% endblock %}
</body>
Child templates can append to the block without overriding:
{# record.html.twig #}
{% extends '_base.html.twig' %}
{% block extrajavascript %}
{{ parent() }}
<script>
// Page-specific tracking for this content type
dataLayer.push({'event': 'contentView', 'contentType': '{{ record.contenttype.slug }}'});
</script>
{% endblock %}
Method 2: Bolt Extension (Symfony Bundle)
For Bolt 5+, create a Symfony extension that injects scripts via Twig event listeners. Register a subscriber in src/EventSubscriber/AnalyticsSubscriber.php:
<?php
namespace App\EventSubscriber;
use Bolt\Event\Subscriber\TwigEventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class AnalyticsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'kernel.response' => 'onKernelResponse',
];
}
public function onKernelResponse($event): void
{
$response = $event->getResponse();
$content = $response->getContent();
$gtmScript = '<!-- GTM Head --><script>...</script>';
$content = str_replace('</head>', $gtmScript . '</head>', $content);
$response->setContent($content);
}
}
Method 3: Twig Include Partial
Create a reusable partial at public/theme/[theme]/partials/_analytics.html.twig:
{# partials/_analytics.html.twig #}
{% set gtm_id = config.get('general/custom/gtm_id', '') %}
{% if gtm_id %}
<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_id }}');</script>
{% endif %}
Include it in _base.html.twig:
<head>
{% include 'partials/_analytics.html.twig' %}
</head>
Store the GTM ID in config/bolt/config.yaml:
# config/bolt/config.yaml
custom:
gtm_id: 'GTM-XXXXXX'
Data Layer Implementation
Build the data layer before GTM loads using Bolt's Twig variables:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'siteName': {{ config.get('general/sitename')|json_encode|raw }},
'locale': '{{ config.get('general/locale') }}',
{% if record is defined %}
'contentType': '{{ record.contenttype.slug }}',
'contentId': {{ record.id }},
'pageTitle': {{ record.title|json_encode|raw }},
'pageSlug': '{{ record.slug }}',
'pageStatus': '{{ record.status }}',
'pageDate': '{{ record.createdAt|date('c') }}',
'pageModified': '{{ record.modifiedAt|date('c') }}',
{% if record.author is defined %}
'pageAuthor': {{ record.author.displayName|json_encode|raw }},
{% endif %}
{% if record.taxonomy is defined %}
'pageTaxonomy': {{ record.taxonomy|json_encode|raw }},
{% endif %}
{% else %}
'contentType': 'listing',
{% endif %}
});
</script>
For content types with custom fields, access them by field name. Given a contenttypes.yaml definition:
# config/bolt/contenttypes.yaml
products:
name: Products
singular_name: Product
fields:
title:
type: text
price:
type: number
sku:
type: text
category:
type: select
values: [electronics, clothing, accessories]
taxonomy: [ tags ]
The data layer in a product template:
{# product.html.twig #}
{% if record.contenttype.slug == 'products' %}
<script>
window.dataLayer.push({
'productSku': '{{ record.fieldValues.sku|default('') }}',
'productPrice': {{ record.fieldValues.price|default(0) }},
'productCategory': '{{ record.fieldValues.category|default('') }}'
});
</script>
{% endif %}
Common Issues
Block not rendering in child templates. Bolt's Twig templates use strict block inheritance. If you define {% block extrajavascript %} in a child template without calling {{ parent() }}, the parent block content is replaced entirely. Always use {{ parent() }} to append.
record is undefined on listing pages. The record variable is only available on single content pages. On listing pages, use records (plural) to iterate. Wrap data layer pushes with {% if record is defined %}.
Config values not loading. In Bolt 5, configuration files moved from app/config/ to config/bolt/. The access syntax changed from {{ config.general.sitename }} (Bolt 3) to {{ config.get('general/sitename') }} (Bolt 5).
Twig autoescape breaks inline JavaScript. Twig autoescapes output by default. When outputting values into JavaScript, use |json_encode|raw to produce valid JSON strings without HTML entity encoding.
Extension not loading. Bolt 5 uses Symfony's service container. Extensions must be registered as services in config/services.yaml and tagged appropriately. Check bin/console debug:event-dispatcher to verify your subscriber is registered.
Platform-Specific Considerations
Bolt's Symfony foundation means all HTTP responses pass through the Symfony kernel. Middleware-level analytics (response header injection, server-timing metrics) can be implemented via Symfony event subscribers listening to kernel.response.
Bolt supports SQLite, MySQL, and PostgreSQL. For server-side analytics querying content metadata, connect to whichever database backend is configured in config/bolt/doctrine.yaml.
Template caching is handled by Twig's compiled cache in var/cache/. After modifying analytics templates, clear the cache with bin/console cache:clear or delete the var/cache/ directory.
Bolt 5 includes a REST/JSON API for headless use. If using Bolt as a headless CMS with a separate frontend (React, Next.js), analytics implementation shifts entirely to the frontend application; Bolt only serves content data via API endpoints at /api/.