Skip to main content

Server-side tracking

Server-side tracking speaks Matomo's Tracking API directly from PHP. It sees things the JavaScript tracker never will — a webhook, a queued job, an API-only client, a visitor with JavaScript disabled — and it cannot be blocked by an ad blocker.

Hits are gathered during the request and delivered out of band, so a call to the facade costs you the time it takes to build an array. Nothing is sent on the request path unless you explicitly choose sync mode.

The facade

use MatomoAnalytics\Facades\Matomo;

Matomo::pageView('Pricing');
Matomo::event('Subscription', 'created', 'pro', 49.00);
Matomo::siteSearch('invoices', category: 'docs', count: 7);
Matomo::goal(3, revenue: 49.00);
Matomo::download('https://example.com/whitepaper.pdf');
Matomo::outlink('https://partner.example.com');
Matomo::ping();

Every tracking method returns the tracker, so calls chain — flush() is the exception and returns nothing, being the end of a chain rather than a link in one. Each tracking call builds a typed hit object and hands it to the transmission layer; nothing about the call site changes when you later switch from queue to batch.

The remaining hit types have their own pages: ecommerce, site search, and content impressions and interactions. Matomo::track() takes a hit object directly, which is how you attach custom dimensions or raw Tracking-API parameters.

For the complete method list, see the Tracker contract.

Automatic page views

Attach the middleware and page views are recorded with no per-route code:

// routes/web.php
Route::middleware('matomo.track')->group(function () {
// ...
});

Or register it on the whole web group by setting middleware.auto to true.

What it records is deliberately narrow, because a page view should mean a person looked at a page:

SettingDefaultEffect
middleware.only_gettrueOnly GET requests are tracked.
middleware.only_successfultrueOnly delivered pages are tracked: 2xx and 304. A redirect is not one -- the page it lands on is tracked on its own request.
middleware.skip_livewiretrueLivewire update requests are skipped — they are not navigations.
middleware.strip_queryfalseDrop the query string from the tracked URL entirely.
middleware.performancefalseStamp the server generation time onto the page view.

The page title is resolved from the response, so a tracked page view carries the same title a visitor saw in their browser tab. It is looked for in the first 64 KB — the spec puts <title> in <head> — and a response without one falls back to the route name and then to the path.

The hit is queued after the response has been sent, from the middleware's terminate(). The gate, the payload build and the buffer write are all off the critical path, and in sync mode so is the request to Matomo — which is the mode where it mattered: 24.08ms of request time against 0.096ms in queue, measured against an instance answering in 20ms. The one number that has to be read earlier, the server generation time behind middleware.performance, is taken before the response leaves.

middleware.strip_query is the blunt way to keep query strings out of your analytics. The precise way is URL redaction, which is on by default and strips secrets and PII while keeping the parameters that carry meaning.

middleware.performance is covered in page performance.

The middleware still runs through the tracking gate, so bots, opted-out visitors and excluded routes are filtered before a hit is built.

Site search and AI chatbot middleware

Two more middleware aliases exist, each documented on its own page:

  • matomo.search — track a search route's query automatically. See site search.
  • matomo.chatbots — record AI-assistant page fetches as bot telemetry. See AI assistants.

How a visitor is identified

By default a visitor gets a cookieless identifier: a salted hash, derived per request and rotated daily, that yields the 16-character hex visitor id Matomo expects. No cookie is set, and nothing survives the rotation window, which is what makes the default posture consent-free.

'visitor' => [
'rotate' => 'daily', // daily|weekly|never
'user_id' => null, // 'auth' to attach the authenticated user id, or null
],

rotate trades identification accuracy against retention. daily means a returning visitor tomorrow is a new visitor; weekly widens the window; never keeps one stable identifier for a browser. Pick the shortest window that still answers your questions.

user_id => 'auth' attaches the authenticated user's id as the Matomo User ID, so a logged-in person's sessions join up across devices regardless of the rotation window. It is off by default: linking every hit to a known person is a deliberate step with a lawful basis behind it, not a starting point.

Which IP is used

With a token configured, the package forwards the client IP as cip. It comes from ip_header when you set one, and from Laravel's request IP otherwise. The gate's except_ips check resolves the address the same way, so an exclusion list and the IP that reaches Matomo always agree.

A forwarding header is a chainX-Forwarded-For carries client, proxy1, proxy2 — and the package reads the client from the front of it. A port, a bracket pair around an IPv6 address and a zone id are stripped for the same reason: each is written next to an address without being part of one. If the header holds no address at all, its value is passed on untouched rather than cut into something that looks like one.

'anonymize_ip' => true,
'ip_header' => env('MATOMO_IP_HEADER'), // e.g. CF-Connecting-IP behind Cloudflare

ip_header is trusted without verification. Set it only when your origin is reachable exclusively through that proxy. If the origin can be reached directly, a client can send the header themselves and both poison cip and slip past except_ips. The safer configuration is to leave it null and configure Laravel's own TrustProxies middleware, which the package's IP resolution then follows.

anonymize_ip truncates the address before it is sent, and is on by default. Matomo can also anonymize server-side; doing it here means the full address never leaves your application. Turn it off only if you have a basis for storing complete addresses.