SPA and soft navigation
A single-page navigation never reloads the document, so the normal trackPageView
fires once and every subsequent screen is invisible. Turn on spa.enabled and the
tracker records a virtual page view on each client-side navigation instead.
'spa' => [
'enabled' => env('MATOMO_SPA', false),
'adapters' => ['livewire', 'inertia'], // livewire | inertia | generic
'performance' => true,
],
It is off by default: an application that does full page loads would double-count if it were on, and the package cannot detect which kind you are.
The adapters
Enable the ones your application actually uses. Each listens for one specific event rather than guessing.
| Adapter | Covers | Listens for |
|---|---|---|
livewire | Livewire's wire:navigate — including apps built with WireKit, which requires Livewire 4 | livewire:navigated |
inertia | Inertia.js, both the Vue and the React adapter | inertia:navigate |
generic | Any client-side router | History pushState plus popstate |
generic is the fallback: it wraps history.pushState and listens for popstate,
so it catches both programmatic navigation and the browser's back button. Use it
only when neither framework event applies — a wrapped pushState is a broader
intervention than an event listener.
An adapter records only when the URL changes
A framework navigation event is not by itself proof that a navigation happened, so every adapter compares the URL first and does nothing when it is unchanged.
That guard is not defensive tidiness. Livewire's navigation plugin ends by firing
its navigated event once unconditionally, which reaches the page as
livewire:navigated — so the event arrives on every hard load of a Livewire
application, at the URL the tracker has just recorded a page view for, whether or
not the application uses wire:navigate anywhere. Without the guard, enabling the
livewire adapter counted every full page load twice.
It also makes the adapters safe to combine: enabling livewire and generic
together no longer records one navigation twice, because whichever runs second
sees a URL that already matches.
The one deliberate exception is the manual helper below, which is never guarded.
The tracker script runs once, not once per hop
Livewire's navigation plugin re-executes every <script> inside the <body> it swaps
in. The tracking snippet is marked data-navigate-once, so it is bootstrapped a single
time per real page load and the adapter registered on that load keeps serving every
soft navigation afterwards — document survives the swap, which is why the listener
does.
Without the marker each hop would run the whole snippet again: another matomo.js
insert, another set of _paq configuration commands, and one more adapter listener
beside the ones already there, none of which are ever removed.
The marker is not a guess about your application. Livewire compares the tag's own hash, and everything the snippet emits comes from configuration — so two pages of one application produce the identical tag. Where they do not, the hash differs, the tag runs again, and the new configuration takes effect.
What a virtual page view sends
For each soft navigation the tracker sets the referrer to the previous in-app URL, then the current URL and the current document title, re-applies any configured custom dimensions so action-scoped dimensions attach to this view too, tracks the page view, re-scans for content impressions if that is on, and re-enables link tracking for the newly rendered DOM.
The referrer chain is what makes the flow reports work: without it, every virtual page view would look like a direct entry. The chain starts at the page the browser loaded normally, so the very first soft navigation carries that URL as its referrer rather than nothing.
Manual and custom triggers
A helper is always exposed when spa.enabled is true, whichever adapters you
picked:
window.matomoTrackPageView();
Call it after any navigation the adapters do not see — a tab switch you want counted as a screen, a modal route, a router you have not enabled an adapter for.
The helper is deliberately not subject to the URL-change rule above: every one of those cases is a screen the URL does not express, so guarding it would make it useless. Calling it twice at the same URL therefore records two page views, which is what you asked for.
Performance timings on soft navigations
A soft navigation produces no new browser Navigation Timing, so Matomo records no page-performance data for it and those columns stay empty. If your application measures the timings itself, expose them before the navigation and the tracker forwards them once:
window.__matomoPerf = { net: 12, srv: 40, tfr: 8, dm1: 60, dm2: 30, onl: 15 }; // ms
The keys are network, server, transfer, DOM-processing, DOM-completion and on-load
time. The tracker clears the object after forwarding it, so a stale measurement is
never attributed to the next navigation, and it never re-emits the hard-load
timings. Requires Matomo 4.5+. Controlled by spa.performance (on by default, and a
harmless no-op until your application sets the object).
See page performance for the hard-load side of the same report.
Tag Manager
This applies to the direct matomo.js tracker only. If you load a
Tag Manager container instead,
Tag Manager handles soft navigation with its own history-change trigger and the
spa block does nothing.