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 |
Related Guides
- Google Analytics Setup -- GA4 configuration tag and measurement ID placement
- GTM Setup -- Container installation and trigger configuration
- Data Layer Implementation -- Structured data layer variables and custom events
- Troubleshooting -- Performance and tracking issue resolution