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
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
- Dashboard with visitors, pageviews, pages/visit, bounce rate, average visit duration, a time-series chart (24h/7d/30d/90d), top pages, sources, browsers, OS, devices, countries and events — aggregated SQL, server-rendered SVG chart, no external assets.
- Clean DX facade:
Analytics::track()/Analytics::pageview()from PHP, a one-line Blade directive, and a lightweight vanilla JS tracker (~2 Ko gzipped,window.analytics.track(), SPA-aware auto pageviews,data-analyticsattributes). - Privacy-first: visitor identified by a client-side uuid kept in
localStorage, no cookies, no fingerprinting, no IP stored, optional Do-Not-Track support. - Sessions with landing page, referrer domain, UTM parameters, bounce flag and duration (30 min inactivity window).
- Secure public collection endpoint: POST-only, domain allow-list, per-IP rate limiting, bot detection (device-detector), strictly validated and normalized payloads.
- Pluggable geolocation through a
LocationResolvercontract (country / region / city). - Data retention command:
php artisan analytics:prune.
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.
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.
Pageviews are tracked automatically on load and on SPA navigation (pushState, popstate, hashchange). Set data-auto-track="false" on the directive output to disable automatic pageviews, and the browser's Do-Not-Track flag is honored by default (tracker.respect_do_not_track).
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:
- Stat cards: unique visitors, pageviews, pages/visit, bounce rate, average visit duration.
- Time series chart (hourly buckets on 24h, daily otherwise): pageviews + unique visitors per bucket.
- Top pages (url, pageviews, visitors), top sources (referrer domain or
(direct)). - Environments: browsers, operating systems, device types (desktop / mobile / tablet).
- Top countries (unique visitors) and top custom events (occurrences).
- Last 20 events with type badge, detail and visitor browser.
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.