The tracking gate
Every hit — from the facade, from the middleware, from the Web Vitals route — passes through one gate. There is no second place where tracking is suppressed, which means one config block answers "who gets tracked?" completely.
'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. |
| 6 | bot | bots.track is false and the User-Agent is detected as a bot. |
| 7 | authenticated | tracking.track_authenticated is false and someone is logged in. |
| 8 | ability | The authenticated user passes any of tracking.except_abilities. |
| 9 | ip | The client IP matches tracking.except_ips. |
| 10 | route | The request path matches any of tracking.except_routes. |
| 11 | 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/*'],
These are Laravel request patterns, matched against the path. The shipped defaults already exclude the usual dashboards, the health endpoint and Livewire's update route — the things that would otherwise show up as your most-visited pages.
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.