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:
| Call | Refused when | Matomo would record |
|---|---|---|
event() | the category or the action is empty | a page view |
contentImpression() | the name is empty or 0 | a page view |
contentInteraction() | the name is empty or 0 | a page view |
contentInteraction() | the interaction is empty or only whitespace | an impression |
outlink(), download() | the URL is empty or 0 | a page view |
goal() | the id is 0 | an ecommerce cart update |
goal() | the id is below 0 | a page view |
ecommerceOrder() | the order id is empty or 0 | a 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:
| Setting | Default | Effect |
|---|---|---|
middleware.only_get | true | Only GET requests are tracked. |
middleware.only_successful | true | Only 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_html | true | Only 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_livewire | true | Livewire update requests are skipped — they are not navigations. |
middleware.skip_partials | true | A 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_prefetch | true | A request the browser announced as speculative is skipped — see Prefetched and prerendered pages, which also covers the visit that follows. |
middleware.strip_query | false | Drop the query string from the tracked URL entirely, campaign parameters such as utm_campaign included. |
middleware.performance | false | Stamp 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_headeris 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 poisoncipand slip pastexcept_ips. The safer configuration is to leave itnulland configure Laravel's ownTrustProxiesmiddleware, 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
],