kadiaak/event-tracker

First-party, server-side business event tracking for Laravel, with visitor attribution.

Maintainers

Package info

github.com/kadiaak/event-tracker

pkg:composer/kadiaak/event-tracker

Transparency log

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-11 14:50 UTC

This package is auto-updated.

Last update: 2026-08-11 14:50:44 UTC


README

run-tests

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
google 288
direct 173
twitter 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 (default true) 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 the user_id column type in both migrations. Set it before migrating.
  • tables renames both tables; the models read the config at runtime.
  • collect.geo is the master switch for location; geo.headers and geo.database decide 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 to EncryptCookies::$except and document why.
  • The cookie is secure when the request is HTTPS, so local HTTP development still works.
  • Bots still get a visitor row — that is what the is_bot column is for — but ignore_bots (default true) keeps their business events out. Bot rows with no events are cleaned up by event-tracker:prune. Bot detection needs matomo/device-detector; without it, the option has no effect.
  • UTM parameters are stored with the utm_ prefix stripped, so ?utm_source=newsletter lands 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: true is 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.