Analytics Architecture on Fork CMS
Fork CMS is built on the Symfony framework and uses Twig for templating. Its modular architecture separates frontend rendering from backend administration. Analytics implementation touches several layers:
- Twig templates in themes render page output with access to page variables, module data, and global settings
- ThemesBundle provides the theming layer where layout templates define
<head>and<body>structure - Head/footer code injection via the CMS settings allows non-developers to paste tracking scripts without template changes
- Module system packages functionality into self-contained units with their own templates, actions, and widgets
- Kernel events (Symfony
kernel.response,kernel.request) allow programmatic script injection - Locale system provides multi-language content with language-specific routing
Fork CMS routes requests through a front controller to the appropriate module action, which loads data and renders a Twig template within the theme's layout.
Installing Tracking Scripts
Method 1: CMS Head/Footer Code Settings (Simplest)
Fork CMS provides built-in fields for injecting code into every page's <head> and before </body>:
- Go to Settings > General in the Fork CMS admin
- Paste your tracking script into the "Header HTML" or "Footer HTML" field
- Save
<!-- Header HTML field -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
</script>
This outputs inside the {{ siteHTMLHeader|raw }} and {{ siteHTMLFooter|raw }} Twig tags in the theme layout.
Method 2: Theme Layout Template (Recommended for Developers)
Add tracking code directly in your theme's layout template:
{# src/Frontend/Themes/MyTheme/Core/Layout/Templates/Default.html.twig #}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="utf-8">
<title>{{ page.title }} - {{ SITE_TITLE }}</title>
{{ siteHTMLHeader|raw }}
{# Analytics #}
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
</script>
{% block head_extra %}{% endblock %}
</head>
<body>
{% block content %}
{{ block('main') }}
{% endblock %}
{{ siteHTMLFooter|raw }}
</body>
</html>
Method 3: Kernel Event Subscriber
Inject scripts programmatically via a Symfony event subscriber in a custom module:
<?php
// src/Frontend/Modules/Analytics/EventSubscriber/TrackingSubscriber.php
namespace Frontend\Modules\Analytics\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class TrackingSubscriber implements EventSubscriberInterface
{
private string $propertyId;
public function __construct(string $propertyId)
{
$this->propertyId = $propertyId;
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'onKernelResponse',
];
}
public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) return;
$response = $event->getResponse();
$content = $response->getContent();
if (!str_contains($content, '</head>')) return;
$script = sprintf(
'<script async src="https://www.googletagmanager.com/gtag/js?id=%s"></script>',
htmlspecialchars($this->propertyId)
);
$response->setContent(
str_replace('</head>', $script . '</head>', $content)
);
}
}
Register it as a service:
# src/Frontend/Modules/Analytics/config/services.yaml
services:
Frontend\Modules\Analytics\EventSubscriber\TrackingSubscriber:
arguments:
$propertyId: '%analytics.property_id%'
tags:
- { name: kernel.event_subscriber }
Data Layer Implementation
Twig Template Data Layer
Build the data layer in your theme's layout template using Fork CMS's template variables:
{# In the layout template, inside <head> #}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'page_id': {{ page.id|default(0) }},
'page_title': {{ page.title|default('')|json_encode|raw }},
'page_language': {{ LANGUAGE|json_encode|raw }},
'site_title': {{ SITE_TITLE|json_encode|raw }},
'page_template': {{ page.template_path|default('')|json_encode|raw }},
{% if page.parent_id is defined and page.parent_id %}
'parent_id': {{ page.parent_id }},
{% endif %}
'is_action': {{ page.is_action|default(false) ? 'true' : 'false' }}
});
</script>
Module-Specific Data
Fork CMS modules (Blog, FAQ, etc.) pass data to their templates. Extend the data layer in module templates:
{# src/Frontend/Modules/Blog/Layout/Templates/Detail.html.twig #}
{% block head_extra %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'page_type': 'blog_post',
'article_id': {{ item.id }},
'article_title': {{ item.title|json_encode|raw }},
'article_category': {{ item.category_title|default('')|json_encode|raw }},
'author': {{ item.user_id|default('')|json_encode|raw }},
'publish_date': {{ item.publish_on|date('Y-m-d')|json_encode|raw }},
'comment_count': {{ item.comments_count|default(0) }},
{% if item.tags is defined %}
'tags': {{ item.tags|map(t => t.name)|json_encode|raw }}
{% endif %}
});
</script>
{% endblock %}
Widget Data Layer Contributions
Fork CMS widgets render in page positions. Widgets can push their own data:
{# Widget template #}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'widget_type': 'recent_posts',
'widget_items_count': {{ items|length }}
});
</script>
Locale-Aware Data Layer
Fork CMS serves content in multiple languages with language-prefixed URLs (/en/, /fr/). Track the active locale:
<script>
window.dataLayer.push({
'content_language': '{{ LANGUAGE }}',
'interface_language': '{{ INTERFACE_LANGUAGE }}',
'available_languages': {{ LANGUAGES|json_encode|raw }}
});
</script>
Common Issues
Draft and Revision Pages
Fork CMS supports page drafts and revisions. Draft pages are accessible to logged-in admins at preview URLs. Ensure tracking only fires on published pages:
{% if page.status == 'active' %}
{# Inject analytics #}
{% endif %}
Or check in the event subscriber:
// Skip non-200 responses and admin routes
if ($event->getResponse()->getStatusCode() !== 200) return;
if (str_starts_with($event->getRequest()->getPathInfo(), '/private/')) return;
Backend vs Frontend Routes
Fork CMS has separate frontend (/en/page) and backend (/private/en/...) route namespaces. Never inject analytics on backend routes. The kernel event subscriber should check the request path:
$path = $event->getRequest()->getPathInfo();
if (str_contains($path, '/private/')) return;
In Twig, backend templates use different layout files, so analytics code in frontend themes does not leak into the admin.
Module Action URLs
Fork CMS module actions generate URLs like /en/blog/detail/article-slug. The URL structure includes the module name and action. Your data layer should parse the module context:
{% if 'blog/detail' in app.request.pathInfo %}
{# Blog detail page #}
{% elif 'blog/category' in app.request.pathInfo %}
{# Blog category listing #}
{% endif %}
Twig Variable Availability
Not all variables are available in all templates. Module-specific variables (like item in blog detail) only exist in that module's templates. Attempting to access them in the layout template causes errors. Use {% if item is defined %} guards or push module data from child templates via the {% block %} mechanism.
SEO Module URL Overrides
Fork CMS's SEO module lets editors override page URLs and meta data. The data layer should use the actual rendered URL, not the default slug:
'canonical_url': '{{ page.full_url|default(app.request.uri) }}'
Platform-Specific Considerations
Theme Structure: Fork CMS themes live in src/Frontend/Themes/{ThemeName}/. The layout template at Core/Layout/Templates/Default.html.twig is the primary injection point. Module templates extend or include blocks from this layout. All analytics code in the layout automatically applies to every module page.
siteHTMLHeader / siteHTMLFooter: These Twig variables output whatever is configured in Settings > General. They are the lowest-effort way to inject tracking code but provide no conditional logic. For environment-specific tracking (staging vs production), use template-level conditionals or the kernel event subscriber approach.
Module Development: If building a custom Fork CMS module that needs analytics events (e.g., form submission tracking, search tracking), push data layer events from the module's action class:
// In a module action
$this->template->assign('searchQuery', $query);
$this->template->assign('resultCount', count($results));
Then in the module template:
<script>
window.dataLayer.push({
'event': 'site_search',
'search_query': {{ searchQuery|json_encode|raw }},
'search_results': {{ resultCount }}
});
</script>
Multisite Configuration: Fork CMS instances typically serve one site. For multi-domain setups, configure separate Fork CMS installations per domain, each with its own analytics property in Settings. There is no built-in multi-site from a single installation.
Asset Pipeline: Fork CMS does not include a JavaScript bundler. Tracking scripts in theme templates load as-is. For performance, place async/defer attributes on analytics scripts and position them after critical CSS in the <head>.