Grav CMS Analytics: Twig Templates, Plugin Hooks, | OpsBlu Docs

Grav CMS Analytics: Twig Templates, Plugin Hooks,

Implement analytics on Grav CMS. Covers Twig template script injection, custom plugin event hooks, page metadata data layers, and tracking on a...

Analytics Architecture on Grav

Grav is a flat-file PHP CMS. There is no database. Content lives in Markdown files with YAML frontmatter, and templates use the Twig engine. This architecture affects analytics implementation in three ways.

First, all script injection happens through Twig templates, typically in your theme's base.html.twig. There is no admin setting to paste a global script unless a plugin provides one. Second, Grav's plugin system uses event hooks (onPageInitialized, onOutputGenerated, onAssetsInitialized) that let you inject tracking code programmatically from PHP. Third, page metadata stored in YAML frontmatter (taxonomy, template type, modified date) is directly accessible in Twig and can populate data layers without any database queries.

Because Grav caches aggressively, you need to confirm that dynamic data layer values are not cached with static HTML. Use Twig's {% do assets.addInlineJs() %} or the Assets API to inject scripts that respect Grav's cache-clearing lifecycle.


Installing Tracking Scripts

The primary injection point is your theme's base template. Open user/themes/yourtheme/templates/partials/base.html.twig and add scripts inside the <head> block.

GTM installation via Twig template:

{# user/themes/yourtheme/templates/partials/base.html.twig #}
<head>
  {% block head %}
    {{ parent() }}
    {% if config.plugins.analytics.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','{{ config.plugins.analytics.gtm_id }}');</script>
    {% endif %}
  {% endblock %}
</head>

The config.plugins.analytics.gtm_id value comes from user/plugins/analytics/analytics.yaml:

# user/plugins/analytics/analytics.yaml
enabled: true
gtm_id: "GTM-XXXXXXX"

For the GTM <noscript> fallback, add it immediately after <body>:

<body>
  {% if config.plugins.analytics.gtm_id %}
  <noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{ config.plugins.analytics.gtm_id }}"
  height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  {% endif %}
  {% block content %}{% endblock %}
</body>

Data Layer from Page Metadata

Grav pages store metadata in YAML frontmatter. Every field is accessible in Twig through the page object. Use this to build structured data layers without server-side logic.

Pushing page metadata to the data layer:

{# user/themes/yourtheme/templates/partials/analytics-datalayer.html.twig #}
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'page_title': '{{ page.title|e("js") }}',
    'content_type': '{{ page.template|e("js") }}',
    'taxonomy_category': '{{ page.taxonomy.category|join(",")|e("js") }}',
    'page_modified': '{{ page.modified|date("Y-m-d") }}',
    'page_route': '{{ page.route|e("js") }}'
  });
</script>

Include this partial before your GTM snippet so the data layer is populated when GTM initializes:

{% block head %}
  {% include 'partials/analytics-datalayer.html.twig' %}
  {# GTM snippet follows #}
{% endblock %}

For pages with custom frontmatter fields, access them through page.header:

{% if page.header.author %}
  dataLayer.push({ 'content_author': '{{ page.header.author|e("js") }}' });
{% endif %}

The |e("js") filter is critical. It escapes output for JavaScript string contexts, preventing XSS from user-controlled page titles.


Custom Analytics Plugin

For more control, build a Grav plugin that injects tracking code through event hooks. This approach centralizes analytics logic outside of templates.

Plugin file structure:

user/plugins/analytics/
  analytics.php
  analytics.yaml
  blueprints.yaml

Plugin implementation:

<?php
// user/plugins/analytics/analytics.php
namespace Grav\Plugin;

use Grav\Common\Plugin;

class AnalyticsPlugin extends Plugin
{
    public static function getSubscribedEvents()
    {
        return [
            'onPageInitialized' => ['onPageInitialized', 0],
            'onAssetsInitialized' => ['onAssetsInitialized', 0]
        ];
    }

    public function onPageInitialized()
    {
        $page = $this->grav['page'];
        $this->grav['assets']->addInlineJs(
            "window.dataLayer = window.dataLayer || [];
             dataLayer.push({
               'event': 'page_view',
               'page_route': '" . addslashes($page->route()) . "',
               'page_template': '" . $page->template() . "'
             });"
        );
    }

    public function onAssetsInitialized()
    {
        $gtmId = $this->config->get('plugins.analytics.gtm_id');
        if ($gtmId) {
            $this->grav['assets']->addJs(
                'https://www.googletagmanager.com/gtm.js?id=' . $gtmId,
                ['loading' => 'async', 'priority' => 100]
            );
        }
    }
}

The onAssetsInitialized hook fires before the assets pipeline renders, so your GTM script loads with the correct priority. The onPageInitialized hook fires after the page object is available, giving you access to route and template data.

The plugin's blueprints.yaml file defines admin panel fields for configuration:

# user/plugins/analytics/blueprints.yaml
name: Analytics
version: 1.0.0
description: Injects analytics tracking scripts and data layers
author:
  name: Your Name
form:
  fields:
    enabled:
      type: toggle
      label: Plugin Status
      default: true
    gtm_id:
      type: text
      label: GTM Container ID
      description: "Format: GTM-XXXXXXX"
      placeholder: "GTM-XXXXXXX"
    track_admin:
      type: toggle
      label: Track Admin Pages
      default: false

With this blueprint, site administrators can configure the GTM container ID through the Grav admin panel at Plugins > Analytics without editing YAML files directly.


Event Tracking with Twig Macros

Grav's Twig environment supports macros for reusable tracking patterns. Define a macro for consistent event tracking across templates.

Defining a tracking macro:

{# templates/macros/analytics.html.twig #}
{% macro trackEvent(eventName, eventCategory, eventLabel) %}
<script>
  dataLayer.push({
    'event': '{{ eventName|e("js") }}',
    'event_category': '{{ eventCategory|e("js") }}',
    'event_label': '{{ eventLabel|e("js") }}'
  });
</script>
{% endmacro %}

Using the macro in page templates:

{% import 'macros/analytics.html.twig' as analytics %}

{{ analytics.trackEvent('page_section_view', 'content', page.title) }}

This keeps event tracking calls consistent across templates and centralizes the data layer push format in one file.


Twig Caching and Dynamic Values

Grav's Twig cache can freeze data layer values into static HTML. If a page's data layer includes values that change between requests (session state, query parameters), you need to handle caching.

Disable Twig cache for a specific template block:

{% set cacheable = false %}
<script>
  dataLayer.push({
    'query_string': '{{ uri.query|e("js") }}',
    'referrer': document.referrer
  });
</script>

Alternatively, use client-side JavaScript to capture dynamic values instead of relying on Twig:

<script>
  dataLayer.push({
    'query_string': window.location.search,
    'referrer': document.referrer
  });
</script>

The second approach avoids cache interference entirely and is the recommended pattern for any value that varies per visit.


Common Errors

Symptom Cause Fix
GTM not loading on any page Missing {% block head %}{{ parent() }}{% endblock %} in child template Add {{ parent() }} call so the base template's head block renders
Data layer values show raw Twig syntax Twig processing disabled for the page or template Check twig_first: true in page frontmatter; verify the template file has .html.twig extension
Duplicate pageview events GTM snippet in both the plugin and the Twig template Remove one injection point; use either the plugin or the template, not both
Cached data layer shows stale values Grav's Twig cache serving old rendered HTML Move dynamic values to client-side JS; run bin/grav cache --clear after template changes
Plugin not loading Plugin class name does not match filename Class AnalyticsPlugin must be in analytics.php; filename must match the plugin folder name
Taxonomy values empty in data layer Page has no taxonomy assigned in frontmatter Add a conditional check: {% if page.taxonomy.category %} before pushing
Scripts load but events never fire Assets pipeline minification breaking inline JS Use addInlineJs() with group: 'bottom' to avoid pipeline interference
GTM container ID undefined YAML config not loaded or plugin disabled Verify user/plugins/analytics/analytics.yaml has enabled: true and the correct gtm_id value