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.

From a Livewire component​

A component's action runs in a request to Livewire's endpoint, /livewire/update, while it belongs to the page the component lives on. A hit the action tracks is filed under that page, which the browser names in the Referer, or without one the page Livewire mounted the component on, and the tracking gate judges it by that page too. The Referer of such a request is the page itself, so the hit carries no referrer.

A hit needs its required field​

Matomo files a hit under the action whose required field it carries, and records a hit that has none as a page view. The tracker therefore refuses a hit Matomo would record as something else, and sends nothing for it:

CallRefused whenMatomo would record
event()the category or the action is emptya page view
contentImpression()the name is empty or 0a page view
contentInteraction()the name is empty or 0a page view
contentInteraction()the interaction is empty or only whitespacean impression
outlink(), download()the URL is empty or 0a page view
goal()the id is 0an ecommerce cart update
goal()the id is below 0a page view
ecommerceOrder()the order id is empty or 0a cart update

The usual way in is a value from your data: an event category read from a record that has none, or a goal id from configuration that is not set. matomo.js refuses the same calls in the browser.

A site search whose keyword is empty, only whitespace or 0 is a page view to Matomo as well, and it is skipped without a report. Its keyword is what a visitor typed, so an empty one is input rather than a mistake in your code, as searchFromRequest() already treats a request without a keyword.

A refused hit raises an InvalidArgumentException that names the reason. With resilience.never_throw on, the default, the tracker reports it the way it reports a failed delivery and the call returns as usual; with it off, the exception reaches your code. Matomo::fake() throws it either way, so a test fails at the call that would have been dropped. A hit wrapped in custom parameters is judged by the parameters it sends.

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.

Leave it off the pages that render @matomoScript: matomo.js records a page view of its own on every page it is on, so the two together count each view twice.

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.only_htmltrueOnly pages are tracked: an HTML response, or an Inertia visit, whose page arrives as JSON. JSON for a fetch(), a file, an image, a feed and a download sent as an attachment are not. A response that declares no type, such as a 304, is still counted.
middleware.skip_livewiretrueLivewire update requests are skipped — they are not navigations.
middleware.skip_partialstrueA request for part of a page is skipped: an htmx swap (HX-Request without HX-Boosted), a Turbo Frame, an XHR, and an Inertia partial reload (X-Inertia-Partial-Component), which a poll, a deferred prop or a reload with only or except sends. Inertia visits, PJAX and Turbo Drive navigations are counted.
middleware.skip_prefetchtrueA request the browser announced as speculative is skipped — see Prefetched and prerendered pages, which also covers the visit that follows.
middleware.strip_queryfalseDrop the query string from the tracked URL entirely, campaign parameters such as utm_campaign included.
middleware.performancefalseStamp the server generation time onto the page view.

A fetch() that loads an HTML fragment carries nothing that tells it from a navigation, so it is counted. Send it with X-Requested-With: XMLHttpRequest, as axios does, and skip_partials skips it. Track a download with Matomo::download().

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.

A 304 Not Modified carries no body. Its title is the one the middleware read before the response became a 304, which works when matomo.track sits inside cache.headers, that is, when it is listed after it:

Route::middleware(['cache.headers:public;etag', 'matomo.track'])->group(function () {
// ...
});

Listed the other way round, or registered on the web group through middleware.auto, a revalidated page is named by its route or 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. It takes the campaign parameters with them, utm_*, mtm_* and pk_*, which Matomo reads from the landing page to attribute a visit, so campaigns stop showing for server-side page views. 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.

In jobs and commands​

A queued job, a scheduled task or an Artisan command has no visitor. Laravel binds a request for them anyway, built from app.url, and Symfony fills it with Symfony as the user agent and 127.0.0.1 as the address. None of that describes a visitor, so the package sends such a hit without a visitor id, user agent, language or address, and except_ips does not match its address. Matomo then recognizes the visit by what it sees of the tracking request itself: your server's address and the user agent of its HTTP client. Hits from jobs and commands that follow each other within Matomo's visit length, half an hour by default, therefore join one visit from your server, located where your server is.

When the hit belongs to a person, pass what you know about them. The visitor id the package computed while they were on the site is the one to store and hand back:

use MatomoAnalytics\Contracts\VisitorIdResolver;
use MatomoAnalytics\Facades\Matomo;
use MatomoAnalytics\Tracking\CustomParameters;
use MatomoAnalytics\Tracking\Event;

// During the request:
$order->matomo_visitor_id = app(VisitorIdResolver::class)->resolve($request);

// Later, in the job:
Matomo::track(
CustomParameters::for(new Event('Payments', 'captured'))
->param('_id', $order->matomo_visitor_id)
->param('uid', (string) $order->user_id),
);

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 can be a chain, and the package reads it from the right. Each proxy appends the address it received the request from to whatever X-Forwarded-For the request already carried, so the entries on the left are whatever the client chose to send. Behind one proxy the client is the last entry; behind a CDN in front of a load balancer that both append, set ip_header_trusted_hops to 2. A single-value header such as CF-Connecting-IP needs neither. The standard Forwarded header is read the same way, by the for parameter of each hop, quoted or not. A port, a bracket pair around an IPv6 address and a zone id are stripped, since each is written next to an address without being part of one. A header that names no address counts as absent, and Laravel's request IP is used.

'anonymize_ip' => true,
'ip_header' => env('MATOMO_IP_HEADER'), // e.g. CF-Connecting-IP behind Cloudflare
'ip_header_trusted_hops' => filter_var(env('MATOMO_IP_HEADER_TRUSTED_HOPS', 1), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) ?? 1,

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. An IPv4 address keeps its first three octets and goes out as a /24, so 192.0.2.123 is sent as 192.0.2.0. An IPv6 address keeps its first 48 bits and the rest is zeroed, so 2001:db8:85a3::8a2e:370:7334 is sent as 2001:db8:85a3::. An IPv4 address written in IPv6 notation, ::ffff:192.0.2.123, is truncated as the IPv4 address it is. The lengths are fixed: the setting is on or off. Matomo can also anonymize server-side; doing it here means the full address never leaves your application. A value that is not an address cannot be truncated, so it is not sent at all. Turn it off only if you have a basis for storing complete addresses.

Device and operating system​

Matomo reads the browser, the operating system and the device from the user agent and from the client hints. Chrome freezes its user agent: Windows 11 reports itself as Windows NT 10.0, and every Android device as Android 10; K. The real platform version and the device model arrive only as Sec-CH-UA-* headers, which a server-side hit passes on to Matomo as uadata, together with X-Requested-With, which names the app around an Android web view.

A browser sends only part of the client hints on its own, and that part does not change what Matomo reports. middleware.client_hints makes the page view middleware ask for the rest with Accept-CH on HTML responses. A browser then sends them from the next request on, so the first page a visitor opens is still read from the frozen user agent. It is off by default: these hints say more about a device, and the package collects nothing of the kind unasked.

'middleware' => [
'client_hints' => true, // ask for Sec-CH-UA-Platform-Version, -Model and -Full-Version-List
],