Client integration scenarios
Setups beyond a same-origin Blade app: calling the endpoint without the JS beacon, full-page caches, API-only backends, mobile apps, identifiers from other systems, and frontends on another origin. Basics of the beacon: JS beacon.
Skipping the JS beacon
Section titled “Skipping the JS beacon”visits.js is a thin wrapper around POST /visits/collect. Any HTTP client — fetch, axios, a mobile HTTP client, curl — can call the endpoint directly. Do three things yourself:
- Keep the visitor id. Store
visitor_idfrom the response ({"visitor_id": "..."}) and send it back as theX-Visitor-Idheader (or avisitor_idbody field) on every later call. Without it, every call without a cookie creates a new visitor. - Send the CSRF header (
X-CSRF-TOKEN) while the route runs under thewebgroup — not needed oncecollect.middlewareno longer includesweb, or the route is excluded from CSRF (below). - Send the expected body, validated by
CollectController:
| Field | Rules |
|---|---|
type | page_view (default) or action |
name | string up to 255 characters; required when type is action |
url | string up to 2048 characters; stored as the event URL and path. Always send it: when omitted, the collect URL itself is stored and route_name becomes visits.collect |
referrer | string up to 2048 characters; the page the user came from (document.referrer). When url is sent, the collect request’s own Referer header is ignored — it is your page, not the source |
meta | object |
Responses: 200 {"visitor_id": "..."}, 403 {"visitor_id": null} when collect.allowed_origins rejects the origin, 422 for invalid fields, 429 from the throttle, 200 {"visitor_id": null} when visits.enabled is off.
UTM parameters and click IDs are read from the query of url, and from the collect request’s own query string (POST /visits/collect?utm_source=newsletter); when both have a parameter, url wins.
What the beacon adds on top: the VisitsQueue buffer, the automatic page view on load, and swallowing failed requests so tracking never breaks the page.
Full-page HTTP caching
Section titled “Full-page HTTP caching”If pages are served from an nginx page cache (fastcgi_cache, proxy_cache), most visits never reach PHP.
TrackVisit runs only on a cache miss. A cache hit is served by nginx; Laravel never runs. Heavily cached pages are undercounted.
A cached Set-Cookie merges visitors. TrackVisit sets the visitor cookie on the miss response. If nginx caches that response with its Set-Cookie header (the default unless told otherwise), the visitor id of whoever filled the cache is served to everyone who gets the cached page, until it expires — unrelated people become one visitor. Strip it:
fastcgi_ignore_headers Set-Cookie;# or behind proxy_pass:proxy_ignore_headers Set-Cookie;Use the beacon for cached pages. visits.js runs in the browser whether the HTML came from the cache or not, and POST /visits/collect is never cached (page caches only cache GET/HEAD by default). The beacon sends its localStorage id as X-Visitor-Id, which beats any cookie, so even a stale cached cookie doesn’t corrupt tracking on this path. Call Visits.trackPageView() or set autoTrackPageView: true, and exclude these routes from TrackVisit (exclude_paths, or auto_track = false).
A cached CSRF token breaks the beacon silently. The csrf-token meta tag is per session. Baked into a cached page, it doesn’t match most visitors’ sessions, the collect request gets 419, and the beacon swallows the error. The endpoint doesn’t need CSRF protection — identity comes from the visitor id, not the session — so exclude it:
->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'visits/collect', ]);})API-only backends, decoupled SPAs and mobile apps
Section titled “API-only backends, decoupled SPAs and mobile apps”POST /visits/collect runs under web by default: session and CSRF, so a token-authenticated API, an SPA on another origin or a native app without a session gets 419. The identity flow already works without cookies (X-Visitor-Id in, visitor_id out), so swap the middleware:
'collect' => [ 'middleware' => ['api'],],A pure API backend has no page views for TrackVisit — page views and screens come from the client through POST /visits/collect, conversions from Visits::track() in your API controllers. Without the web group no cookie is written to the response (no AddQueuedCookiesToResponse): the client keeps the id itself.
First call from a mobile app, no id yet:
POST /visits/collectAccept: application/jsonContent-Type: application/json
{"type": "page_view", "url": "app://home"}{"visitor_id": "7d0c3b1e-4f7e-4a8e-9a43-2a4f5b8c1d20"}Store the id (Keychain, EncryptedSharedPreferences, localStorage for a web SPA) and send it on every later call:
POST /visits/collectAccept: application/jsonContent-Type: application/jsonX-Visitor-Id: 7d0c3b1e-4f7e-4a8e-9a43-2a4f5b8c1d20
{"type": "action", "name": "order.placed", "meta": {"amount": 42}}To connect server-side Visits::track() calls of logged-in users to the same visitor, use HasVisits on the user model — see Logged-in users on Bearer-token APIs. Or have the client send its X-Visitor-Id header to your API too: the header wins over everything else in Visits::track() as well.
Mixed web and API app
Section titled “Mixed web and API app”When one backend serves both a Blade web app and an SPA or mobile API, switching collect.middleware to ['api'] would drop the cookie for the web side. Keep ['web'] and exclude the route from CSRF instead (same snippet as for cached pages above). The web beacon still gets its cookie through the web group; mobile and SPA clients rely on X-Visitor-Id.
Handing off an identifier from another system
Section titled “Handing off an identifier from another system”When another system assigns a visitor id first — a separate landing page, a legacy tracker’s anon_id, a mobile SDK’s device id — pass it to the package so both stay one visitor from the first hit.
The simplest way is a query parameter on the link to the main site. visitor_id is read with $request->input(), which includes the query string:
https://landing.example.com → https://example.com/?visitor_id=7d0c3b1e-4f7e-4a8e-9a43-2a4f5b8c1d20TrackVisit picks it up on the first hit and the cookie carries it on; the parameter is needed only on that one link.
The id must match visits.visitor_id.format_regex — a UUID by default — or it is ignored and a new id is generated. A UUID passes as is. For any other format, change the regex or the token resolver:
'visitor_id' => [ 'format_regex' => '/^[A-Za-z0-9_-]{20,64}$/',],Keep a bound on length and characters — the value ends up in a 64-character column, a cookie and URLs.
Sharing the cookie across subdomains. If the landing page and the main site share a registrable domain, the landing page can set the package’s cookie itself (visits_visitor_id, Domain=.example.com). Laravel’s EncryptCookies discards any cookie it cannot decrypt, so a cookie written by another system is ignored unless its name is excluded from encryption:
->withMiddleware(function (Middleware $middleware) { $middleware->encryptCookies(except: ['visits_visitor_id']);})With that exception the package’s own cookie is stored unencrypted too. The cookie set by the package has no Domain attribute — it stays on the host that set it.
Either way the id is not authenticated: fine for attribution continuity, not a security boundary.
Frontend on another origin
Section titled “Frontend on another origin”Two things beyond the API-only setup, or the beacon talks to the wrong host or gets blocked:
-
endpointmust be absolute. The default/visits/collectis relative to the page:<script>window.VisitsConfig = { endpoint: 'https://api.example.com/visits/collect' };</script> -
CORS on the API — the app’s own
config/cors.php, not avisits.*key.Content-Type: application/jsonmakes it a non-simple request with a preflightOPTIONS:config/cors.php 'paths' => ['api/*', 'visits/collect'],'allowed_origins' => ['https://app.example.com'],'allowed_headers' => ['Content-Type', 'Accept', 'X-Visitor-Id', 'X-CSRF-TOKEN'],X-CSRF-TOKENis needed only if the SPA page has acsrf-tokenmeta tag — the beacon sends the header whenever it finds one.
A cookie set by the API is useless to a page on another origin, and visits.js fetches with credentials: 'same-origin', so it doesn’t try — identity rests on localStorage and X-Visitor-Id.
Separately, visits.collect.allowed_origins (default null) lets the server reject requests whose Origin (or Referer scheme and host) is not listed:
'collect' => [ 'middleware' => ['api'], 'allowed_origins' => ['https://app.example.com'],],This is not CORS — CORS is what a browser permits, this is what the server accepts — and both headers are trivially forged by non-browser clients. It filters casual misuse, such as a copy of the beacon left on an old domain. A request with neither header is rejected once the list is set.