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 or 'off' 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 small JSON body: the metric
name, its value, the rating the library assigned, the navigation type, and the page's own
URL. It goes with navigator.sendBeacon, and with fetch and keepalive where a browser
refuses the beacon, as Chromium has for a JSON body. Both survive 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, and a URL from elsewhere is dropped rather than refused: the measurement is still real, it simply loses its page.
The request itself has to come from one of your pages. A form-encoded
cross-origin POST needs no preflight, so any page anywhere can make its visitors beacon
this route, and that page writes the URL as well. So the endpoint checks what the
sending page cannot write: the request's Origin header must be your application's
origin, or, where a browser sends no Origin, Sec-Fetch-Site must be same-origin.
Anything else is refused with 403 and records nothing.
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. The read-only session pair
from the hit beacon in web_vitals.middleware applies
those two rules without writing the session; ['web'] applies them too, writes the
session, and needs a CSRF exemption for the one route 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. An IPv6 visitor is counted per /64: a connection is given at
least that range and every device in it picks its own address, so a count per full address
would start over each time the address changes. An IPv4 address counts on its own.
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, and a single number is that many a minute. Set it tonullor'off'to remove the limiter, but only if something else in front is already bounding it. Any other value that is not a throttle, a blank.envline included, keeps the shipped60,1.- 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.