The tracking gate
Every hit the server sends — from the facade, from the middleware, from the beacon
routes — passes through one gate, and so does the <noscript> pixel when a page is
rendered. There is no second place where that tracking is suppressed, which means one
config block answers "who gets tracked?" for everything that passes the server. The
JavaScript tracker sends from the browser, and which of these rules reach it is
described below.
'tracking' => [
'environments' => ['production'], // restrict to environments (null = all)
'track_authenticated' => true, // include logged-in users
'except_abilities' => ['admin'], // skip users passing a Gate ability
'except_ips' => ['10.0.0.0/8'], // skip IPs / CIDR ranges
'except_routes' => ['horizon*', 'up'],// skip route/path patterns
'gate' => null, // invokable class-string for full control
],
The checks, in order
The gate is a short-circuit: the first rule that denies wins, and each denial carries a reason. Order matters, because it decides which reason you see when several would apply.
| Order | Reason | Denies when |
|---|---|---|
| 1 | disabled | enabled is false — the master switch. |
| 2 | not_configured | host or site_id is missing. |
| 3 | environment | tracking.environments is set and the current environment is not in it. |
| 4 | dnt | privacy.honor_dnt is on and the request sends DNT: 1 or Sec-GPC: 1. |
| 5 | opted_out | The first-party opt-out cookie is present, or mtm_consent_removed, which Matomo's opt-out writes. |
| 6 | no_consent | privacy.consent is full and the request carries no mtm_consent, the consent matomo.js remembers. A job or a command is not asked. |
| 7 | bot | bots.track is false and the User-Agent is detected as a bot. |
| 8 | prefetch | tracking.skip_prefetch is on and the browser announced the request as speculative (Sec-Purpose or Purpose carrying prefetch). |
| 9 | authenticated | tracking.track_authenticated is false and someone is logged in. |
| 10 | ability | The authenticated user passes any of tracking.except_abilities. |
| 11 | ip | The client IP matches tracking.except_ips. |
| 12 | route | The request path matches any of tracking.except_routes. |
| 13 | gate | Your own tracking.gate callable returned false. |
Checks 1 and 2 come first because they are the cheapest and the most absolute — a package that is off, or has nowhere to send hits, has nothing to decide. The privacy signals come before everything about identity, so a visitor who asked not to be tracked is never evaluated against your business rules.
Environments
'environments' => ['production'], // null or [] = every environment
This is the right switch for narrowing where tracking happens. It is not a substitute
for MATOMO_ENABLED, which is off until you turn it on — this one decides where an
already-enabled package is active, and lives in the config file rather than in a
per-environment variable. A missing MATOMO_HOST keeps tracking inert regardless.
Excluding your own team
Two ways, and they answer different questions.
except_abilities runs Laravel's Gate for the authenticated user and excludes anyone
who passes any of the listed abilities. This is the precise way to keep staff out of
your analytics — it follows your existing authorization rules rather than duplicating
them:
'except_abilities' => ['view-admin-panel'],
except_ips excludes by address and accepts CIDR ranges, so 10.0.0.0/8 covers a
whole office network. It works for visitors who are not logged in, which is its
advantage over abilities — and its limitation is that addresses move.
The IP is resolved the same way the tracked cip is, so an exclusion list cannot
disagree with what reaches Matomo. If you run behind a proxy, read the
ip_header warning first.
Excluding routes
'except_routes' => ['horizon*', 'telescope*', 'nova*', 'up', 'health*', 'livewire/*', 'livewire-*/*'],
These are Laravel request patterns, matched against the path. The shipped defaults
already exclude the usual dashboards, the health endpoint and Livewire's endpoint,
livewire/* for Livewire 3 and livewire-*/* for the hashed prefix of Livewire 4 —
the things that would otherwise show up as your most-visited pages.
A hit that names its page in a url, such as a beacon or a hit you send with one, is
matched against that page rather than the route it arrived on. The home page is / in
every form, so ['/'] covers https://example.com/ and https://example.com, which is
how url('/') writes it.
A hit that a Livewire component action tracks, an event, a goal or an order, is judged by
the page the component lives on, not by Livewire's endpoint it arrived on: the browser names
that page in the Referer, and the hit is filed under it as well. Without a Referer from
this application, the page is the one Livewire mounted the component on, which Livewire's own
originalUrl() reads too. So livewire/* does not drop such a hit, while an exclusion such as
admin/* still covers the actions of an admin page. Only an update that names neither is
about the endpoint, and the defaults exclude it.
Add your own admin path here. This is a path check, not an authorization check: it excludes the URL for everyone, which is what you want for an operational endpoint and is not what you want for staff exclusion (use abilities for that).
Your own rule
For anything the config cannot express, tracking.gate takes a callable that receives
the request and the hit:
namespace App\Analytics;
use Illuminate\Http\Request;
use MatomoAnalytics\Tracking\Hit;
final class OnlyBillableTenants
{
public function __invoke(Request $request, Hit $hit): ?bool
{
return $request->user()?->tenant->tracks_analytics;
}
}
'tracking' => ['gate' => \App\Analytics\OnlyBillableTenants::class],
The return value is three-valued: false denies, and anything else — true or
null — leaves the decision as the earlier checks left it. That is why the signature
is nullable: "I have no opinion about this request" is a distinct answer from "track
it".
Use an invokable class-string rather than a closure so config:cache keeps
working. The class is resolved through the container, so it can take constructor
dependencies. See configuration.
Your gate runs last, so it cannot accidentally re-enable tracking for a bot or for
a visitor who sent DNT. That is deliberate: an application-level rule should not be
able to override a privacy signal.
Observing exclusions
Every denial fires a VisitorExcluded event carrying the reason string from the table
above. That is the way to answer "why is my traffic not showing up?" without adding
logging to the package — listen for it in a local environment and count the reasons.
See events and
troubleshooting.
What the gate does not cover
AI-chatbot telemetry bypasses the visitor gate by design — those
hits are bots on purpose, recorded as bot telemetry rather than as visits. They have
their own switch, ai_chatbots.track, which is off by default.
matomo.js sends from the browser, past the gate. @matomoScript renders nothing on a
page that tracking.environments or tracking.except_routes excludes, and a soft
navigation to an excluded route records no page view: those rules describe the page, so
they hold for every visitor, a cached copy of the page included. The privacy
signals reach matomo.js on their own: it honors Do-Not-Track through setDoNotTrack,
waits for consent under privacy.consent, and stops at Matomo's opt-out cookie.
The rules about the visitor (track_authenticated, except_abilities, except_ips, the
package's own opt-out cookie and your tracking.gate) are decided per hit, so they hold
for server-side hits and the <noscript> pixel, not for matomo.js. Rendering the tracker
by them would let one cached page decide for everybody. Keep it off those pages in the
layout, and exclude addresses in Matomo itself, which applies its excluded IPs to every
hit it receives:
@guest
@matomoScript
@endguest