kadiaak / event-tracker
First-party, server-side business event tracking for Laravel, with visitor attribution.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- geoip2/geoip2: ^3.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- matomo/device-detector: ^6.4
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
Suggests
- geoip2/geoip2: Resolves country, city and ASN from a local MaxMind database.
- matomo/device-detector: Parses user agents into device type, browser and OS, and detects bots.
This package is auto-updated.
Last update: 2026-08-11 14:50:44 UTC
README
First-party, server-side tracking of business events for Laravel.
Track::event('cgu.accepted', ['version' => '2.1']);
Every event is attached to a visitor (a first-party visitor_id cookie, one
year), and that visitor is attached retroactively to a user_id the moment the
person signs up or logs in. So you can answer questions like which acquisition
channel brings the users who actually accept the terms? — from your own
database, with no third party involved.
What this is not: a Google Analytics replacement. No automatic pageviews, no sessions, no dashboard. One event equals one explicit business action.
Requirements
- PHP 8.2 – 8.4
- Laravel 11 or 12
Install
composer require kadiaak/event-tracker
Publish and run the migrations:
php artisan vendor:publish --tag=event-tracker-migrations php artisan migrate
Add the middleware to your web group — it is opt-in and never registers
itself globally:
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \Kadiaak\EventTracker\Middleware\TrackVisitor::class, ]); })
Record your first event from anywhere:
use Kadiaak\EventTracker\Facades\Track; Track::event('cgu.accepted', ['version' => '2.1']);
That is the whole install. Everything below is optional.
Publish the config if you want to change any of it:
php artisan vendor:publish --tag=event-tracker-config
Optional dependency
Not required. Without it the package degrades quietly — no user-agent parsing, no bot detection, never an exception.
composer require matomo/device-detector # device type, browser, OS, bot detection
Geolocation
No network call is ever made, in any mode. Location comes from whichever of these is available, cheapest first.
1. Your CDN or load balancer (default, nothing to install). If anything fronts your app, it has already resolved the country and put it in a request header. The package reads it and is done — no file, no lookup, no latency. Supported out of the box:
| Edge | Headers read |
|---|---|
| Cloudflare | CF-IPCountry, CF-Region, CF-IPCity |
| AWS CloudFront | CloudFront-Viewer-Country, -Country-Region-Name, -City, -ASN |
| Vercel | X-Vercel-IP-Country, -Country-Region, -City |
| Google App Engine / Cloud LB | X-AppEngine-Country, -Region, -City |
| Fastly, Akamai, custom | X-Geo-Country, X-Geo-Region, X-Geo-City, X-Country-Code |
Some of these need enabling at the edge — Cloudflare's CF-IPCountry needs the
IP Geolocation toggle on, and city/region are Enterprise-only; CloudFront needs
the geo headers added to your origin request policy.
These are ordinary request headers, so a visitor could forge one if your origin is reachable directly. Behind a CDN the edge overwrites them. If your origin is exposed, restrict it to the CDN or turn this off:
'geo' => [ 'headers' => false, // or ['cf-ipcountry'] to trust exactly one ],
2. A local MaxMind database (optional, only if you want city/ASN without a CDN). Consulted only when the headers say nothing.
composer require geoip2/geoip2
EVENT_TRACKER_GEOIP_DATABASE=/var/www/storage/app/geoip/GeoLite2-City.mmdb
The .mmdb is a ~60 MB file you supply yourself; MaxMind refreshes it twice a
week and the GeoLite2 licence requires you to update it within 30 days. It is
not a drop-once-and-forget file, which is exactly why the header route is the
default.
3. Neither. country and geo stay NULL and nothing else changes. Device,
browser, OS, UTM, time zone and language do not depend on any of this. Note that
in local development geo is useless anyway — private IPs are in no database.
Browser context (optional)
Time zone, viewport, colour scheme and the rest can only come from the browser. Publish the collector and include it:
php artisan vendor:publish --tag=event-tracker-js
<meta name="csrf-token" content="{{ csrf_token() }}"> <script src="{{ asset('vendor/event-tracker/event-tracker.js') }}" defer></script>
It is 2 KB of dependency-free vanilla JS, no build step. It fires once per
visitor per 30 days (guarded by a vctx cookie), sends via navigator.sendBeacon
and then gets out of the way. Because sendBeacon cannot set headers, the CSRF
token travels in the body — hence the meta tag.
Tune it from the tag itself:
<script src="..." data-endpoint="/_track/context" data-days="30" data-fingerprint="0" defer></script>
When JavaScript is blocked, the middleware still advertises
Accept-CH: Sec-CH-UA-Platform, Sec-CH-UA-Mobile, Sec-CH-UA-Model, so modern
browsers send client hints on the next request as a fallback.
API
// Record an event Track::event('cta.clicked', ['placement' => 'hero']); // Attach the current visitor's anonymous events to an account Track::claim($user); // Authenticatable, or a raw id — returns rows updated // The current visitor row Track::visitor(); // ?TrackedVisitor // Stop tracking for the rest of this request (opt-out, impersonation, health checks) Track::disable(); // Record without an HTTP context — jobs, commands, webhooks Track::for($visitorId)->event('subscription.renewed', ['plan' => 'pro']); // Erase everything about a person (see GDPR below) Track::forget($user); // returns the number of events deleted
Querying
use Kadiaak\EventTracker\Models\TrackedEvent; TrackedEvent::named('cgu.accepted')->since(now()->subMonth())->count(); TrackedEvent::named(['checkout.started', 'checkout.completed'])->identified()->get(); TrackedEvent::anonymous()->count(); $event->visitor; // TrackedVisitor $event->user; // your User model $visitor->events; // everything this browser ever did
Which channel brings the users who accept the terms?
This is the query the package exists for. Attribution is captured once, on first contact, and never overwritten — so it survives the user coming back three weeks later through a direct visit.
-- MySQL / MariaDB SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(v.utm, '$.source')), 'direct') AS channel, COUNT(DISTINCT e.user_id) AS users FROM tracked_events e JOIN tracked_visitors v ON v.visitor_id = e.visitor_id WHERE e.name = 'cgu.accepted' AND e.user_id IS NOT NULL GROUP BY channel ORDER BY users DESC; -- PostgreSQL: swap the first line for -- COALESCE(v.utm ->> 'source', 'direct') AS channel
| channel | users |
|---|---|
| newsletter | 412 |
| 288 | |
| direct | 173 |
| 41 |
A funnel over the same tables:
$funnel = collect(['pricing.viewed', 'checkout.started', 'checkout.completed']) ->mapWithKeys(fn (string $step) => [ $step => TrackedEvent::named($step) ->since(now()->subDays(30)) ->distinct() ->count('visitor_id'), ]);
Because visitor dimensions live in real indexed columns, slicing is cheap:
TrackedEvent::named('cgu.accepted') ->join('tracked_visitors as v', 'v.visitor_id', '=', 'tracked_events.visitor_id') ->where('v.device_type', 'mobile') ->where('v.country', 'CH') ->count();
Front-end events
Two routes ship behind the configurable _track prefix. Both always return
204, including on rejection — a scanner learns nothing about what got through.
POST /_track/context— the browser context described above.POST /_track/event— events fired from the front end.
The event route is closed by default. Only names you list are accepted, and an empty list disables the route entirely:
'allowed_client_events' => [ 'cgu.accepted', 'checkout.*', // wildcards allowed ],
Both routes are rate limited (60/min per IP, configurable via routes.throttle)
and payloads are validated strictly: 3 levels of nesting maximum, 8 KB maximum,
no keys that would shadow an event column (name, user_id, visitor_id,
ip, url, created_at, id, _truncated).
Never whitelist an event that grants something. Anyone can post to these routes.
Retroactive identification
The package listens to Registered and Login and calls Track::claim() for
you (auto_claim, on by default). Listening to Login as well as Registered
is what lets an existing account absorb the anonymous activity of a new device.
Claiming updates tracked_events where visitor_id matches and user_id is
still null — events already attributed to someone else are left alone — and
sets user_id on the visitor row.
Retention
php artisan event-tracker:prune
Deletes events older than retention_days (365 by default, null disables it)
in chunks of 1000, then deletes visitors left with no events, no account, and no
activity inside the window. Schedule it:
// routes/console.php Schedule::command('event-tracker:prune')->daily();
On volume: one row per business event, plus one row per browser. The
visitor row is written on every request that passes through the middleware, so
the write volume tracks your pageviews even though the event volume does not.
tracked_events carries indexes on (name, created_at) and
(visitor_id, user_id) — the two queries that actually matter.
GDPR / ePrivacy
Not all browser signals are equal, and the package treats them differently.
Time zone, locale and user agent are, in most readings, ordinary technical data: you can collect them on a legitimate-interest basis without prior consent.
hardwareConcurrency, deviceMemory, colorDepth and devicePixelRatio
combined are something else. Together they constitute a terminal
fingerprint — a stable identifier for a device. Under ePrivacy, reading them
requires consent exactly as a third-party cookie would, even when you are
first-party and even when you never send the data anywhere.
That is why collect.fingerprint is false by default, and that default is not
up for negotiation. Turn it on only behind consent, and the server strips those
keys whatever the browser sends while it is off.
Do Not Track
respect_dnt is true by default. A request sending DNT: 1 gets no event, no
visitor row, and no cookie at all.
'respect_dnt' => true,
Wiring it to a consent platform
Load the collector only once the user has accepted, and turn fingerprinting on in the same breath:
<script> window.addEventListener('cmp:consent', function (e) { if (!e.detail.analytics) return; var s = document.createElement('script'); s.src = '{{ asset('vendor/event-tracker/event-tracker.js') }}'; s.dataset.fingerprint = e.detail.fingerprinting ? '1' : '0'; document.head.appendChild(s); }); </script>
For the server side, drop tracking for a request that has no consent:
if (! $request->user()?->hasAnalyticsConsent()) { Track::disable(); }
Right to erasure
Track::forget($user);
Deletes the user's events, the events of every visitor that user was ever linked to, and those visitor rows. It returns the number of events deleted.
forget() is the one method that lets exceptions through. Everything else
in the package swallows failures so tracking can never break a page; an erasure
that silently does nothing is a legal problem, not a rendering one, so it fails
loudly instead. Wire it to your account-deletion flow and check the result.
Configuration notes
A few decisions worth knowing about:
queue(defaulttrue) means after the response has been flushed, in the same PHP process. It does not need a queue worker, Redis or Horizon. Outside an HTTP request — console, queue jobs, webhooks — writes are always synchronous, because nothing would ever terminate.user.key_type(bigint|uuid|ulid) drives theuser_idcolumn type in both migrations. Set it before migrating.tablesrenames both tables; the models read the config at runtime.collect.geois the master switch for location;geo.headersandgeo.databasedecide where it comes from.- Cookie encryption. The visitor cookie is
httpOnly, so JavaScript cannot read it anyway and Laravel's default encryption is harmless — the middleware reads it back through$request->cookie(). If you need the raw value (sharing it across apps, reading it at the edge), add it toEncryptCookies::$exceptand document why. - The cookie is
securewhen the request is HTTPS, so local HTTP development still works. - Bots still get a visitor row — that is what the
is_botcolumn is for — butignore_bots(defaulttrue) keeps their business events out. Bot rows with no events are cleaned up byevent-tracker:prune. Bot detection needsmatomo/device-detector; without it, the option has no effect. - UTM parameters are stored with the
utm_prefix stripped, so?utm_source=newsletterlands as{"source": "newsletter"}. Click identifiers (gclid,fbclid,msclkid) keep their own names. - Truncation to column length is mb-safe and measured by display width, so it errs on the short side rather than overflowing a column.
- Payloads over 8 KB are cut down rather than rejected: keys that still fit
are kept, the rest are dropped, and
_truncated: trueis added. The front-end route is stricter and rejects an oversized payload outright.
Testing
composer test # Pest composer analyse # Larastan, level 6 composer format # Pint
Changelog
See CHANGELOG.md. Upgrading from 0.x? See UPGRADING.md — 1.0 is a full rewrite with a different API and schema.
License
The MIT License (MIT). See LICENSE.md.