Skip to main content

Matomo Tracking in a Sulu Headless setup

· 3 min read
Stephan Hochdörfer

In our Sulu Headless project we have a requirement to integrate Matomo as a user tracking system.

Luckily, Sulu already comes with an integrated analytics feature and Matomo support out-of-the-box. However, we had to customize it a bit for 2 reasons: Sulu automatically injects the Javascript code to load the tracking functionality. For server-side rendered sites, that's nice but in our Headless set-up, we needed more control over the tracking and wanted to load the Javascript tracking script ourselves. Additionally, loading the tracking script automatically is a problem in regard to GDPR as users need to give consent first before they get tracked.

First, we added empty template files for the Matomo tracking to overwrite the default templates that are shipped with Sulu. For that, 2 empty files body-open.html.twig and head-close.html.twig have to be created in the templates/bundles/SuluWebsiteBundle/Analytics/matomo directory.

Since Sulu does not come with a Twig function to load the analytics information, we created our own Twig helper function by copying some of the internal Sulu code and adapting it. The resulting Twig extension looks like this:


namespace App\Twig;

use Sulu\Bundle\WebsiteBundle\Entity\AnalyticsRepositoryInterface;
use Sulu\Component\Webspace\Analyzer\RequestAnalyzerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class AnalyticsTwigExtension extends AbstractExtension
public function __construct(
private readonly RequestAnalyzerInterface $requestAnalyzer,
private readonly AnalyticsRepositoryInterface $analyticsRepository,
) {

public function getFunctions()
return [
new TwigFunction('analytics_load', [$this, 'load']),

* @return array<string, string>|null
public function load(string $type = ''): ?array
$dataList = null;
$kernelEnvironment = 'prod';
$portalUrl = $this->requestAnalyzer->getAttribute('urlExpression');
if (null === $portalUrl) {
return $dataList;

$analyticObjects = $this->analyticsRepository->findByUrl(

foreach ($analyticObjects as $analytics) {
if ($analytics->getType() === $type) {
$dataList = $analytics->getContent();

return $dataList;

Now, in our Twig page template, we can call the function like this: analytics_load('matomo') to get the analytics configuration for Matomo.

To expose the analytics configuration to our React frontend, we extended the default page template and introduced a window.SULU_ANALYTICS global variable:

{% block content %}
{# ... #}

{# define container element for rendering single page application #}
<div id="sulu-headless-container"></div>

{# initialize application with json data of current page to prevent second request on first load #}
<script>window.SULU_ANALYTICS = {{ analytics_load('matomo')|json_encode|raw }};</script>
<script>window.SULU_HEADLESS_VIEW_DATA = {{ headless|json_encode|raw }};</script>
<script>window.SULU_HEADLESS_API_ENDPOINT = '{{ sulu_content_path('/api') }}';</script>

{# start single page application by including built javascript code #}
<script src="/build/headless/js/index.js"></script>
{% endblock %}

In the React frontend, we use Klaro! as a privacy and security tool. In the Klaro configuration, a callback function is defined which is executed when the user gives or revokes consent. When that happens, we access the window.SULU_ANALYTICS global variable and initially load the Matomo Javascript. In the logic that handles the page change event, we fire a Matomo request to track the new page load.