Web Vitals
Core Web Vitals are field measurements a browser can only take while a real visitor uses the page. Opt in and the package beacons them to a server-side route, which records each as a Matomo event through the normal tracking path.
'web_vitals' => [
'enabled' => false,
'path' => 'matomo-analytics/web-vitals',
'category' => 'Web Vitals',
'metrics' => ['LCP', 'CLS', 'INP', 'FCP', 'TTFB'],
'throttle' => '60,1', // route throttle "requests,minutes"; null to disable
'middleware' => [], // extra middleware for the ingest route; see below
'library' => null, // optional script URL; null = your app provides it
],
Then drop the directive into your layout:
@matomoWebVitals
Distinct from page performance, which is Matomo's own page-timing report and fills itself. Core Web Vitals are Google's metrics and arrive here as Matomo events, so they show up in the Behavior reports rather than in Performance.
You provide the library
The snippet expects Google's
web-vitals library on
window.webVitals. Bundle it with your own assets, or point web_vitals.library
at a self-hosted copy and the package renders the script tag for you.
No third-party CDN is ever loaded by default. That is a privacy decision, not an oversight: an analytics integration that silently pulls a script from someone else's origin undoes much of the reason to run Matomo. If the library is not present, the snippet is a clean no-op — nothing errors and nothing is sent.
The directive accepts a Content-Security-Policy nonce, like @matomoScript:
@matomoWebVitals($cspNonce)
One measurement per page load
Both tags the directive emits are marked data-navigate-once, so a client-side
navigation does not re-run them. That is what the metrics are defined against: a
Core Web Vital describes a page load, and the library reports the navigation type
itself, so a soft navigation is already visible in the data without being measured
as a second load.
Re-running the snippet would not produce a second, cleaner measurement — it would register another set of observers beside the first, so every metric would be reported once per navigation the visitor had made. Nothing fails in that state and the numbers stay plausible; they simply scale with how deeply a session browsed.
What travels, and how
For each metric the browser reports, the snippet sends one navigator.sendBeacon
call carrying a small JSON body: the metric name, its value, the rating the library
assigned, the navigation type, and the page's own URL. sendBeacon is what makes
this reliable — it survives the page being closed, which is exactly when the final
LCP and CLS values become known.
The URL is what attributes the measurement to a page. Without it the event would be filed under the ingest endpoint, and you would learn the metric and the rating and never which page was slow. It is accepted only for this application's own origin: it is unauthenticated input on a public endpoint, and a form-encoded cross-origin POST needs no preflight, so any page anywhere can make its visitors beacon this route. A URL from elsewhere is dropped rather than refused — the measurement is still real, it simply loses its page.
The server side records each sample as a Matomo event: the configured category, the metric name as the action, the rating as the event name, and the measurement as the numeric value. So a report grouped by event action gives you the distribution per metric, and grouping by name splits it into good, needs improvement and poor.
Recording goes through the same tracking gate as
every other hit — a bot, an opted-out visitor or an excluded route produces no
event. The excluded-route half works because of that URL: except_routes is matched
against the page the measurement was taken on. Before, the gate saw only the beacon's
own path, which no exclusion list ever names.
⚠️ tracking.track_authenticated and tracking.except_abilities still see a guest
here. The route is in no middleware group and starts no session, because the browser
beacons it with sendBeacon and that carries no CSRF token. Set
web_vitals.middleware to ['web'] if you need those two rules to apply, and exempt
the one route from CSRF on your side.
The rate limit is keyed on the visitor, not on the proxy. web_vitals.throttle is
enforced through a named limiter keyed on the address this package resolves — the same
ip_header that feeds cip and except_ips. Laravel's own throttle keys on the
request IP, which behind a CDN is the proxy for every visitor alike, so a single bucket
would have held all of them.
The route
The ingest route is always registered, whether or not the feature is enabled.
Toggling web_vitals.enabled is therefore a pure configuration change: it never
requires a route-cache rebuild. When the feature is off, the controller answers
404.
Two defenses bound what the route accepts:
throttleapplies Laravel's rate limiter to the route —60,1means sixty requests per minute per client. Set it tonullto remove the limiter, but only if something else in front is already bounding it.- The metric allowlist. A beacon whose metric is not in
web_vitals.metricsis rejected without being tracked, as is a non-numeric value. The route is public by necessity — the browser has to reach it — so it accepts only the shape it documents.
Change path if the default collides with a route of your own. Change metrics to
narrow what you store; removing a metric from the list stops it being accepted, not
merely stops it being sent.