Skip to main content

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.

OrderReasonDenies when
1disabledenabled is false — the master switch.
2not_configuredhost or site_id is missing.
3environmenttracking.environments is set and the current environment is not in it.
4dntprivacy.honor_dnt is on and the request sends DNT: 1 or Sec-GPC: 1.
5opted_outThe first-party opt-out cookie is present.
6botbots.track is false and the User-Agent is detected as a bot.
7authenticatedtracking.track_authenticated is false and someone is logged in.
8abilityThe authenticated user passes any of tracking.except_abilities.
9ipThe client IP matches tracking.except_ips.
10routeThe request path matches any of tracking.except_routes.
11gateYour 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.