Back to blog
#php#laravel#privacy#analytics#package#gdpr

Sillage: privacy-first event tracking for Laravel

A drop-in Laravel package for first-party, privacy-friendly event tracking — two tables, a two-cookie visitor model, IP masking on write, and native adapters for both Blade and Inertia. The Rails ecosystem has Ahoy; this is the equivalent ergonomics for Laravel, built on my own privacy terms — with a look at how it works under the hood.

7 min min read

If you've worked in Rails, you probably know Ahoy: a small, first-party analytics library that records visits and events straight into your own database, no third-party script required. I wanted the same ergonomics in Laravel, on my own privacy terms — and rather than bend an existing tool into shape, I built it.

The result is Sillage: first-party, privacy-friendly event tracking for Laravel 11/12. It's on Packagist as beubeucode/sillage, MIT-licensed. This post is a tour of what it does, and of how it works underneath.

The shape of it

Sillage is deliberately small. Two tables — visits and events — a two-cookie visitor model, and a single tracking endpoint. Nothing leaves your infrastructure: the data lands in your database, and you own it end to end.

For a Blade application, page-view tracking is automatic. A TrackVisit middleware is appended to the web group at install time, and every full page load records a visit plus a $view event — no analytics JavaScript required. For an Inertia app, the same happens on hard loads, while partial reloads are ignored so you don't double-count client-side navigation.

Installation is a single Composer command followed by an interactive installer:

composer require beubeucode/sillage
php artisan sillage:install

The installer publishes the config and migrations, walks you through the IP-masking choice, and offers to run the migrations. For CI, the same command takes non-interactive flags (--mask-strategy, --no-mask, --force).

The data model

The whole library rests on two tables, and the relationship between them is the first design decision worth explaining.

visits holds one row per visit: a visit_token and a visitor_token (both UUIDs), an optional user_id, the masked ip, a truncated user_agent, the referrer and landing_page, and a started_at timestamp. events holds one row per tracked action: a nullable visit_token, an optional user_id, the event name, a JSON properties bag, and a time.

Note what links the two: not a foreign key on visits.id, but the visit_token UUID. That indirection is deliberate. The token is what the browser carries in a cookie and what the current request exposes through the container (more on that below), so any event — including one fired from a place that never loaded a Visit model — can still be attributed to the right visit by copying a string. It also means anonymization can blank out user_id without severing the visit-to-event linkage, so your aggregate funnels survive an erasure request.

How a visit is stitched together

This is the heart of any Ahoy-style tracker, and where most of the interesting logic lives.

Two cookies, with deliberately different lifetimes:

  • sillage_visitor — long-lived (about two years by default). This is the returning-browser identity.
  • sillage_visit — short-lived (four hours by default). This defines the boundaries of a single visit.

On each request the middleware reads both cookies, generating a fresh UUID for whichever is missing. A new Visit row is created only when the visit cookie has lapsed — not on every request — which is what turns a stream of page loads into discrete, sessionized visits. Both cookies are re-queued as httpOnly, secure, SameSite=Lax; because they're httpOnly, client-side JavaScript can't read the tokens at all — they exist purely for the server to correlate requests.

The neat part is the hand-off. The middleware stashes the current visit token into the Laravel container:

app()->instance('sillage.visit_token', $visitToken);

so that anywhere downstream — a page view in the same request, a custom event from a controller, an event from deep inside a service — track() can pick up the right visit token without touching cookies again. If the binding isn't present (an event fired from a queue job or an Artisan command, outside any HTTP request), the token is simply null and the event is recorded unattached rather than failing.

Page views themselves are handled by the same middleware, but only on GET requests. Each one records a $view event carrying the full URL, the route name, and a type of full or spa — the latter inferred from the X-Inertia header. Inertia partial reloads carry an X-Inertia-Partial-Data header, and the middleware bails out early on those, so client-side partial navigation never inflates your view count.

Custom events

Beyond page views, you record events through a facade or a global helper:

use Beubeucode\Sillage\Facades\Sillage;
 
Sillage::track('checkout-completed', ['order_id' => 42]);
 
// or the global helper
sillage_track('checkout-completed', ['order_id' => 42]);

Event names are normalized to snake_case by collapsing dashes and underscores to spaces and snake-casing the result, so Checkout Completed, checkoutCompleted, and checkout-completed all resolve to the same checkout_completed. You don't have to police naming conventions across a team; the library does it for you. Each event also captures auth()->id() and the current time automatically.

Eloquent models can opt in to a Trackable trait that injects their key. It derives the property name from the table — singularized, suffixed with _id:

use Beubeucode\Sillage\Concerns\Trackable;
 
class Order extends Model
{
    use Trackable;
}
 
$order->track('checkout-completed'); // properties: { order_id: <key> }

On the client side, Blade gets a @sillage directive that renders a tiny window.sillage() client, and Inertia/React gets a useSillage hook. Both post to the same POST /sillage/events endpoint, which validates the payload and returns 204 No Content.

Privacy by default

This is the part I care about most, and where the design is opinionated rather than neutral.

Raw IP addresses are never stored, and only visits carry an IP at all — events have no IP column, so they're inherently less identifying. Masking happens on write, in the middleware, before the row is ever persisted; there's no later batch job leaving a window of exposure. Two strategies ship:

  • truncate (the default) zeroes the last IPv4 octet or the final IPv6 block, keeping coarse geolocation while dropping the host identity.
  • hash computes an HMAC-SHA256 of the IP, keyed with your application key, so the same address is stable across rows but irreversible without the key.

For data-retention and crypto-shredding workflows, an anonymize command nulls the identifying columns:

php artisan sillage:anonymize            # past the configured retention window
php artisan sillage:anonymize --user=42  # a single user, regardless of age

Without arguments it works on records older than a configurable retention window (a year by default), clearing ip, user_agent, and user_id on matching visits and user_id on matching events — enough to honour an erasure request without discarding your aggregate history.

The events endpoint is rate-limited (throttle:60,1) out of the box and can be disabled entirely through config if you'd rather wire your own route.

A few decisions worth calling out

Some choices in a small package tell you more about it than the feature list does.

The Inertia adapter posts with fetch, never router.post. Routing an analytics call through Inertia would register a visit and pollute the browser history — exactly the kind of side effect you don't want from instrumentation. Using fetch directly keeps tracking invisible to the navigation layer. It reads the CSRF token from the XSRF-TOKEN cookie and sends with keepalive: true, so events survive a page unload.

The global helper is prefixed sillage_track, not a bare track. A public package that claims a name as generic as track() in the global namespace is a collision waiting to happen. If you want the shorter form in your own application, the README shows the four-line alias to add yourself — an explicit opt-in rather than a surprise.

When tracking is disabled, track() is a silent no-op that still constructs and returns an unpersisted Event. Your calling code doesn't branch on the environment; it just works in local and test runs without writing rows.

Where it fits

Sillage is a personal project, MIT-licensed, and not yet running in production anywhere — I'm publishing it because it fills a niche I wanted filled: a genuine drop-in for first-party, privacy-conscious tracking in a Laravel app, without reaching for a hosted analytics product or a heavier self-hosted platform. If that's the shape of the gap in your stack, it might save you the afternoon I spent building it.

Everything is externalized in config/sillage.php — table names, cookie durations, the user model, masking strategy, routes — so you can bend it to an existing schema without touching the source.

The code is on GitHub and the package is on Packagist. Issues and feedback welcome.

Previous postReviving ransack-mongoid: Ruby 3, Mongoid 9, Ransack 4.4