Search by

fojlerabbirabib / laravel-spa-analytics

FojleRabbiRabib

Self-hosted, first-party analytics for Laravel — traffic, real-time visitors, funnels, goals and campaign tracking, with data that never leaves your own infrastructure.

Package info

github.com/FojleRabbiRabib/laravel-spa-analytics

pkg:composer/fojlerabbirabib/laravel-spa-analytics

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-30 06:05 UTC

This package is auto-updated.

Last update: 2026-09-30 07:10:17 UTC


README

Tests Code Style Static Analysis Latest Version on Packagist License: MIT

A self-hosted, first-party analytics package for Laravel. The goal is feature parity with mainstream analytics tools (GA4, Plausible, Fathom) — real-time visitors, sessions, funnels, goals, campaign tracking, accurate new-vs-returning visitor detection — with one constraint: data never leaves your own infrastructure. No third-party SaaS, no external API calls, no data sharing.

Status: pre-release (0.x). 0.1.0 ships first-party visitor identity, page view and session tracking, and opt-in visitor re-linking. Custom events, goals, device and geo enrichment, rollups and the query API are planned. Config keys and table layouts may change before 1.0; see the Changelog.

Requirements

  • PHP 8.3+
  • Laravel 13.x

Installation

composer require fojlerabbirabib/laravel-spa-analytics

Run the install command. It publishes the config file, the migrations and the browser collector, then offers to run the migrations:

php artisan spa-analytics:install

The command is hidden from php artisan list (a package-tools default) but works as shown. To do the steps yourself:

php artisan vendor:publish --tag="spa-analytics-config"
php artisan vendor:publish --tag="spa-analytics-migrations"
php artisan vendor:publish --tag="spa-analytics-assets"
php artisan migrate

php artisan about shows the tracking state, write mode, session timeout and re-linking under "SPA Analytics".

After updating the package, re-publish the collector with php artisan vendor:publish --tag="spa-analytics-assets" --force. An old published client.js talking to a newer server fails silently.

Add the collector to your root Blade layout, for example just before </body>:

@spaAnalytics

The directive renders one <script> tag (with your CSP nonce when the app uses one). It renders nothing when SPA_ANALYTICS_ENABLED=false.

Configuration

All keys live in config/spa-analytics.php.

Key Default Purpose
enabled true Master switch (SPA_ANALYTICS_ENABLED)
retention_days null Raw event retention; null keeps everything
identity.register_middleware true Append the identity middleware to the web group
identity.cookie_name spa_analytics_vid Visitor cookie name
identity.cookie_lifetime_days 365 Cookie lifetime, refreshed on every response
identity.tls_fingerprint_header null Request header your proxy or CDN forwards the JA4 hash in; null disables the TLS signal
identity.nonce_ttl_seconds 60 How long a handshake nonce stays valid
identity.route_prefix spa-analytics URL prefix of the handshake and identify endpoints
identity.rate_limit_per_minute 30 Per-visitor (cookie) limit on both endpoints
identity.rate_limit_per_ip_per_minute 600 Per-address ceiling on both endpoints; high so visitors behind one shared address (carrier NAT, offices) do not block each other, and it stops clients that rotate cookies
identity.relink false Adopt a previous visitor id when a first-time fingerprint matches exactly one known visitor (see Re-linking)
sessions.timeout_minutes 30 Inactivity gap after which the next page view starts a new session
tracking.register_middleware true Append the page view capture middleware to the web group
tracking.write_mode defer defer (after the response is sent), queue (queued job) or sync
tracking.connection / tracking.queue null Queue connection and queue name used by queue mode
tracking.excluded_paths up, spa-analytics/*, reset-password/*, password/reset/* request()->is() patterns that are never recorded; add any other URL that carries a secret
tracking.bot_patterns see file Case-insensitive user agent substrings that flag a request as a bot
tracking.search_hosts / tracking.social_hosts see file Case-insensitive host substrings used to classify referrers

If you set identity.register_middleware to false, attach the spa-analytics.identity middleware alias to the routes that need a visitor identity yourself.

Visitor identity

  • Cookie: a first-party visitor cookie (random UUID, encrypted, HttpOnly, SameSite=Lax, 365 days) is set on the first visit and stays authoritative whenever it is present.
  • Fingerprint: a small first-party script collects device signals and sends them to the server, where they are hashed into tiers. A stable hash covers signals that browser updates do not change (screen size regardless of rotation, color depth, timezone, hardware, platform, languages, touch support). Separate hashes cover canvas rendering, audio rendering, WebGL strings and, when your proxy forwards it, the TLS JA4 fingerprint. Two fingerprints match when the stable hashes are equal and at least one of the other hashes is equal, so a browser major update usually keeps a visitor recognisable. The client IP address is not an input.
  • Transport: the browser first requests a short-lived, single-use nonce and a per-session key, then sends the signals as a compressed, AES-GCM encrypted binary body. This makes casual inspection and tampering harder; the real controls are the signed nonce, strict server-side validation and rate limiting. It is not a secret from a determined attacker.
  • Limits: fingerprinting is probabilistic. Two identical devices can share a stable hash, and a visitor whose cookie is cleared and whose volatile hashes all changed will look new.
  • Storage: each identified visitor's hashes are kept in analytics_visitor_fingerprints (one row per visitor, with first and last seen). Events and sessions reference the visitor id only.
  • Hook: the identify endpoint dispatches a VisitorIdentified event carrying the resolved identity and fingerprint, so your own code can react to it.

Re-linking returning visitors

Off by default. With identity.relink set to true, a visitor who arrives without their cookie is recognised when their first fingerprint matches exactly one known visitor: the new id's events and sessions move to the old id, the cookie is re-issued with the old id, and VisitorRelinked (new and old id) is dispatched before VisitorIdentified. The identify response then reports source: "relinked". Zero matches, several matches or more than 50 candidates never re-link, so the package does not guess.

Turn it on only if you accept the trade-off: two people with identical devices (for example two of the same phone model with the same browser) share a stable hash and often identical rendering hashes, so the second person can be merged into the first when their cookie is missing. Late queued writes for the abandoned id are routed to the adopted id through analytics_visitor_links.

Things to know before enabling it:

  • The visitor id is not a credential. Device signals come from the client and the transport is not a secret, so anyone who reproduces a device's signals can be handed that visitor's id. Never tie anything private to it.
  • Sessions are merged by the timeout rule. A moved session that sits within sessions.timeout_minutes of the adopted visitor's sessions is folded into the earliest one (page views summed, earliest entry and attribution kept, latest exit used, events repointed). Sessions further apart stay separate.
  • VisitorRelinked fires once per pair. A second tab or a retry that identifies with the abandoned id gets the adopted id back and the cookie re-issued, but nothing is moved or dispatched again.

Page view tracking

The capture middleware joins the web group after the identity middleware and records one row per page view in analytics_events.

Request Recorded
GET HTML document, any status Yes
Inertia visit Yes
Inertia partial reload, prefetch, redirect, non-GET, JSON or asset, excluded path No
Bot user agent Yes, with is_bot = true (filter with AnalyticsEvent::notBots())
Request without a resolved visitor identity No

Each row stores the visitor id, path (never the query string), response status, referrer host and type (direct, search, social, referral; same-site referrers count as direct), the five UTM values, first Accept-Language tag, IP address, user agent (truncated to 512 characters) and time.

  • Write modes: defer (default) writes after the response is sent and still records 4xx and 5xx responses; queue dispatches a WriteEvent job (a failed job keeps its payload, including the IP, in failed_jobs); sync writes inline. A failed write never breaks the request; only the exception class and code are logged.
  • 404 limit: URLs that match no route and implicit-binding 404s throw before web middleware runs, so only 404s and errors from matched routes are recorded. To capture unmatched URLs, register a fallback route. In routes/web.php it already runs in the web group:
Route::fallback(fn () => abort(404));

Elsewhere, add the group yourself: Route::fallback(...)->middleware('web'). Requests for images, scripts and other non-document resources (by Accept and Sec-Fetch-Dest) are never recorded, so missing assets and /favicon.ico do not count as page views.

  • Manual wiring: if you set tracking.register_middleware to false, attach the spa-analytics.capture alias to your routes after spa-analytics.identity; without an identity nothing is recorded.

Sessions

Every recorded page view is attached to a session in analytics_sessions; no session cookie is used. A session continues while the visitor's next page view arrives within sessions.timeout_minutes of their last one, otherwise a new one starts.

Field Meaning
started_at, last_seen_at First and latest page view; duration is their difference
entry_path, exit_path First and latest path
page_views Count; a bounce is a session with one page view (derive it at query time)
referrer_host, referrer_type, utm_* Taken from the session's first page view only
is_new_visitor The visitor had no earlier session
is_bot From the first page view

Session writes take a per-visitor cache lock (Cache::lock). In production use a cache store shared by all app servers, such as redis, database or memcached. file is only safe on a single server, and array keeps locks in one process's memory, so it protects nothing and is meant for tests.

Filter with the scopes on the models:

use FojleRabbiRabib\LaravelSpaAnalytics\Models\AnalyticsSession;

AnalyticsSession::query()->notBots()->between($from, $to)->get(); // between() uses started_at

Extending

Two seams are contracts you can replace. Bind your own implementation in your app's AppServiceProvider::register() (not boot()); the package's defaults are bound in the register phase and yours wins.

Contract Default Purpose
FojleRabbiRabib\LaravelSpaAnalytics\Contracts\BotDetector PatternBotDetector (user agent substrings from config) Decide whether a request is a bot
FojleRabbiRabib\LaravelSpaAnalytics\Contracts\EventStore DatabaseEventStore (session plus event rows, under a lock) Persist a page view
use FojleRabbiRabib\LaravelSpaAnalytics\Contracts\BotDetector;

public function register(): void
{
    $this->app->bind(BotDetector::class, MyDeviceDetectorBotDetector::class);
}

A custom EventStore may throw; the writer logs the exception class and code and never lets the failure reach the visitor.

Only these two seams are swappable. Re-linking, the session model scopes and the visitor link lookup read and write the package's own tables directly, so a store that writes elsewhere gets no rows in analytics_sessions and re-linking finds nothing to move.

Planned capabilities

  • Traffic: page views, unique visitors, sessions, new vs. returning visitors, bounce rate, average session duration, entry/exit pages.
  • Real-time: current active visitor count and their current page.
  • Sources: referrer classification, full UTM campaign tracking.
  • Audience: device, OS, browser, viewport, language, country/region.
  • Behavior: outbound link clicks, file downloads, 404s, scroll/ engagement depth, custom events, conversion goals, multi-step funnels.
  • Client-side tracker: a first-class JS client for SPA page-view transitions, outbound clicks, scroll depth, and custom analytics.track() events — runs alongside server-side middleware capture, not instead of it.

Data retention

Raw events are kept in full by default — no forced pruning. Daily/hourly rollup tables exist for fast dashboard queries, but they supplement raw data rather than replacing it. A retention window is configurable via config/spa-analytics.php if disk usage ever becomes a concern.

Testing

composer test
npm test

The identify request format is pinned by a frozen envelope, tests/Fixtures/identify-envelope.json: the TypeScript test must reproduce its bytes and the PHP test must decode it, so a change on either side fails a suite. Regenerate it only together with a new envelope version.

By default the suite runs on in-memory sqlite and the array cache. The GitHub Actions workflow also runs it on MySQL 8 and PostgreSQL 16, each with the database and redis cache stores, and adds a concurrency test that forks several processes writing page views for one visitor and asserts a single session with an exact page view count. That mode is switched on only by SPA_ANALYTICS_TEST_* variables set in CI; it refuses non-local hosts, only connects to a database named spa_analytics_test, and never runs locally unless you set those variables yourself.

Code style & static analysis

composer format
composer analyse
npm run lint
npm run types

Building the collector

The browser collector is written in TypeScript under resources/js and shipped prebuilt as resources/dist/client.js. After changing it, run npm run build and commit the result; applications that install the package need no JS build step.

Changelog

See CHANGELOG.md for a history of changes.

License

The MIT License (MIT). See LICENSE.md for details.