Bolt CMS Analytics Implementation | OpsBlu Docs

Bolt CMS Analytics Implementation

Install tracking scripts and data layers on Bolt CMS using Twig templates, Symfony extensions, and content type configuration.

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/.