mlstephane / laravel-analytics
Privacy-first analytics package for Laravel: track visitors and user actions without cookies, with a built-in dashboard, pageviews, sessions, bounce rate and visit duration.
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0|^13.0
- matomo/device-detector: ^6.4
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Privacy-first analytics and observability for Laravel: track visitors and user actions — pageviews, sessions, referrers, environments, bounce rate and visit duration — and read everything on a built-in dashboard. No cookies, no personal data, no IP addresses stored. Inspired by Umami.
Features
- Built-in dashboard — key metrics, time-series chart, top pages, sources, browsers, OS, devices, countries and events; server-rendered, no external assets (see Dashboard).
- One-line tracking —
@analyticsBlade directive,window.analytics.track()from a ~2 kB vanilla JS tracker,Analytics::track()/Analytics::pageview()from PHP, or declarativedata-analyticsattributes. - Privacy-first — no cookies, no fingerprinting, no stored IP; visitors are identified by a client-side uuid kept in
localStorage; Do-Not-Track is honored by default. - Sessions — landing page, referrer domain, UTM parameters, bounce rate and visit duration, with a 30-minute inactivity window.
- Secure collection endpoint — POST-only, domain allow-list, per-IP rate limit, bot detection and strictly validated payloads.
- Pluggable geolocation — country / region / city through a
LocationResolvercontract (the IP is never stored). - Data retention —
php artisan analytics:prunedeletes events older thanprune.days(default 365).
Requirements
- PHP 8.2+
- Laravel 11, 12 or 13
Installation
You can install the package via composer:
composer require mltstephane/laravel-analytics
The service provider and the Analytics facade are auto-discovered. Migrations are loaded automatically by the package — just run:
php artisan migrate
Publishing the config or the views is optional:
php artisan vendor:publish --provider="MltStephane\LaravelAnalytics\AnalyticsServiceProvider" --tag="laravel-config" php artisan vendor:publish --provider="MltStephane\LaravelAnalytics\AnalyticsServiceProvider" --tag="laravel-views"
Usage
Quick start
Add the tracker to your layout <head>:
@analytics
That's it: pageviews are sent automatically. Open the dashboard at /analytics and watch the data arrive.
- Automatic pageviews cover the initial load and SPA navigation (
pushState,popstate,hashchange). - Disable them on a page with
data-auto-track="false"on the directive output. - The browser's Do-Not-Track flag is honored by default (
tracker.respect_do_not_track).
Tracking events from the browser
The tracker exposes a small global API:
// Custom event window.analytics.track('signup', { plan: 'pro' }); // Manual pageview window.analytics.pageview('/blog/my-post', 'My post'); // Attach a stable id to the visitor (max 64 chars) window.analytics.identify('user-42');
You can also track clicks declaratively with data attributes:
<button data-analytics="signup" data-analytics-plan="pro">Sign up</button>
Any extra data-analytics-* attribute is sent as event data.
Tracking events from PHP
Use the Analytics facade:
use MltStephane\LaravelAnalytics\Facades\Analytics; Analytics::track('purchase', ['qty' => 2, 'total' => 99.99]); Analytics::pageview('/blog/my-post');
Server-side events are attached to a single shared visitor (config server.visitor_uuid).
Tracking pageviews with a middleware
Register the alias and apply it to the routes you want tracked server-side:
// routes/web.php Route::middleware('analytics.track-pageview')->get('/blog/{post}', [PostController::class, 'show']);
Event data limits
Event data is normalized server-side (Umami-like rules):
| Data type | Limit |
|---|---|
| Properties | max 50 per event |
| Strings | max 500 chars |
| Arrays | converted to string, max 500 chars |
| Numbers | rounded to 4 decimals |
| Event name | max 50 chars |
Dashboard
The dashboard lives at /analytics (configurable prefix and middleware — default ['web', 'auth']) with periods 24h, 7d, 30d, 90d:
- Key metrics — unique visitors, pageviews, sessions, pageviews per visitor, bounce rate and average visit duration, each compared to the previous period of the same length.
- Time-series chart — pageviews and unique visitors per interval, server-rendered SVG with an accessible table and spoken summary; no chart dependency.
- Content & acquisition — top pages and sources (pageviews, unique visitors, pages-per-visitor) and top countries (unique visitors).
- Audience — unique visitors by device, browser and OS.
- Events — top custom events and the 20 most recent events, with type badge and details.
Periods without data are hidden from the period switcher, and clear empty states guide you from the first visit onward.
Geolocation (custom driver)
Country / region / city are resolved through a pluggable resolver. Implement the contract and point the config to your class:
use MltStephane\LaravelAnalytics\Contracts\LocationResolver; class IpApiResolver implements LocationResolver { public function resolve(string $ip): ?array { // return ['country' => 'FR', 'region' => 'Île-de-France', 'city' => 'Paris'] or null } }
// config/analytics.php 'geolocation' => [ 'driver' => \App\Support\IpApiResolver::class, ],
The IP is only used for the lookup and is never stored.
Data retention
php artisan analytics:prune
Deletes events older than prune.days (default 365, in chunks), then orphaned sessions and visitors.
Configuration
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master switch — no route is registered when false |
collect.uri |
analytics/collect |
POST endpoint used by the tracker |
collect.domains |
[] |
Allowed referrer/origin domains. Empty = host of app.url, * = any domain |
collect.ignore_bots |
true |
Skip bot user agents (device-detector) |
collect.ignore_paths |
[] |
Regex list (preg_match) of paths that are not tracked |
collect.ignore_ips |
[] |
IPs that are never tracked (private/monitoring) |
collect.rate_limit |
60 |
Max requests/minute/IP to the collection endpoint |
collect.session_timeout |
30 |
Minutes of inactivity before a visitor starts a new session |
dashboard.enabled |
true |
Enable the dashboard routes |
dashboard.prefix |
analytics |
Dashboard URL prefix |
dashboard.middleware |
['web', 'auth'] |
Dashboard middleware stack |
tracker.auto_track |
true |
Automatic pageviews from the JS tracker |
tracker.script_path |
js/tracker.js |
URL path serving the tracker script. Use a neutral filename: blockers (Brave, uBlock) block any URL ending in analytics.js |
tracker.respect_do_not_track |
true |
Honor the browser Do-Not-Track flag |
geolocation.driver |
null |
Class implementing LocationResolver |
prune.days |
365 |
Event retention in days |
server.visitor_uuid |
server |
Visitor uuid used by facade/middleware events |
Security model
- The collection endpoint is not CSRF-protected by design: it is guarded by a domain allow-list (Origin/Referer) and a per-IP rate limit, and it only stores normalized, bounded data.
- All inputs are strictly validated (422 on failure). No user string is ever interpolated into SQL — all queries are parameterized or use internal constants.
- The
Analyticsmanager never throws toward the calling request: failures are logged andnullis returned.
Testing
composer test
composer fix
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security related issues, please report them privately instead of opening a public issue.
Credits
License
The MIT License (MIT). Please see License File for more information.