An open-source Laravel package that gives any application cookieless pageview, session and event tracking plus a built-in dashboard - no third-party service, no IP addresses, no cookie banner in the default configuration. Published on Packagist as divoto/cairn.
Project Overview
Cairn is a privacy-first, self-hosted web analytics package for Laravel, published on Packagist as divoto/cairn and developed in the open on GitHub. It gives any Laravel application cookieless pageview, session, event and conversion tracking plus a complete built-in dashboard — without sending a single byte to a third party, and without requiring a cookie-consent banner in its default configuration.
Every visitor adds a stone. Nobody leaves a name.
Because Cairn runs inside the application rather than in the browser, it reports on things an external tag physically cannot see: route names instead of URLs, Eloquent models as first-class subjects, server response times, and authenticated users when the deployer explicitly opts in. And because recording happens after the response has been sent, it costs the visitor nothing.
The Dashboard
The dashboard is server-rendered Blade at /cairn. There is no build step, no npm, no Vite and no Node anywhere in the package — the CSS is roughly 8 KB, hand-written and inlined. It is fully readable with JavaScript disabled: every filter is a link, the chart is inline SVG computed on the server, and every chart has a real data table underneath it for screen readers. Dark mode follows the system, with a manual toggle.


Fifteen widgets ship by default — Overview, Live Visitors, Top Routes, Referrers, Channels, Countries, Devices, Browsers, Operating Systems, Campaigns, Sources, Mediums, Events, Conversions and the Activity Feed. Each one is a class; adding your own means subclassing Widget (or DimensionWidget for a ranked table) and adding it to a config array.

The Honest Trade-Off
Cairn identifies visitors with a salted HMAC-SHA256 hash derived from a 32-byte salt that lives only in the cache and is regenerated from new random bytes every 24 hours. The old salt is destroyed, so yesterday's hashes cannot be recomputed by anyone — including Cairn. Every other design decision follows from that one, in both directions.
| What you give up | What you get |
|---|---|
| Returning visitors. Somebody who visits on three days counts as three visitors. | No cookie banner in the default configuration. Nothing is stored on the visitor's device. |
| Multi-day journeys. No "read the blog on Monday, bought on Friday". | No IP address in your database, logs or backups. It exists in memory for one lookup, then it is gone. |
| Multi-touch attribution. Last click only, because there is no earlier touch to attribute to. | A breach of your analytics table leaks counts, not people. |
| Unique counts inflated over long ranges. A month is the sum of its days. | Route names, not URLs. /orders/8814/invoice and /orders/9921/invoice are one page. |
| Cross-device anything. | Eloquent models as subjects. $article->trackView(). |
If returning-visitor counts are essential to your work, Cairn is the wrong tool — and its own README says so.
Installation
Two commands, and that is the entire install. Package discovery registers the service provider, the middleware and the scheduled maintenance automatically.
composer require divoto/cairn
php artisan migrate
Then visit /cairn. The dashboard is guarded by a viewCairn gate that — exactly like Telescope and Pulse — denies everybody outside the local environment until you define it:
use Illuminate\Support\Facades\Gate;
Gate::define('viewCairn', fn ($user) => $user?->isAdmin() ?? false);
Requirements
- PHP 8.2, 8.3 or 8.4
- Laravel 12 or 13
- MySQL 8+, MariaDB 10.6+, PostgreSQL 13+ or SQLite
- Redis is optional and always will be. Every Redis-backed capability has a database-backed driver of equal correctness, tested against the same suite.
Recording Things Yourself
Pageviews are recorded automatically by a TrackPageView middleware that runs from terminate(), so nothing blocks the response. Everything else is an explicit call:
use Divoto\Cairn\Facades\Cairn;
Cairn::event('signed_up', ['plan' => 'pro']);
Cairn::conversion('purchase', 49.99);
Cairn::ignore('admin/*'); // for the rest of this request
Any Eloquent model becomes a measurable subject with one trait:
use Divoto\Cairn\Concerns\HasAnalytics;
class Article extends Model
{
use HasAnalytics;
}
$article->trackView();
$article->trackEvent('shared', ['network' => 'mastodon']);
The Report Builder
Every surface — the dashboard, the JSON API, the Pulse cards, CSV export — goes through one query layer, so they cannot disagree with each other:
use Divoto\Cairn\Enums\{Comparison, Dimension, Metric};
Cairn::report()
->lastDays(30)
->metrics(Metric::Visitors, Metric::Pageviews, Metric::BounceRate)
->groupBy(Dimension::Route)
->compare(Comparison::PreviousPeriod)
->orderByDesc(Metric::Pageviews)
->limit(20)
->get();
Derived metrics are recomputed from their stored components at the level they are displayed at. A week's bounce rate is that week's bounces over that week's sessions — never the average of seven daily rates, which is a different and wrong number. Asking for a combination that was never rolled up throws an exception naming it, rather than silently falling back to scanning raw entries.
Configuration Options
Every option in the published config file is commented, and the two that change what Cairn stores carry a much longer explanation of the consequences.
php artisan vendor:publish --tag=cairn-config
| Option | Default | What it controls |
|---|---|---|
enabled |
true |
Master switch. When false, no-op drivers are bound, no middleware is registered and reports return empty results rather than errors. |
driver |
database |
Backing store for ingest buffering, unique counting and presence. database or redis. |
connection / table_prefix |
null / cairn_ |
Point analytics writes at their own connection, and namespace the tables so the package can share a schema with the host app. |
cache_store |
null |
Where the rotating visitor salt lives. A memory-backed store forgets it properly; a file or database store writes it to disk. |
domain |
app host | Salt scoping, so two sites sharing one installation cannot correlate the same person across them. |
ingest.buffer / ingest.lottery |
500 / [2, 100] |
In-memory entry buffer, and the per-request chance of carrying maintenance work on hosts with no cron at all. |
privacy.respect_dnt / respect_gpc |
true |
Honour Do Not Track and Global Privacy Control. Evaluated before bot detection, ignore rules and sampling. |
privacy.track_user_id |
false ⚠ |
Attributes entries to the signed-in user. Turning it on makes the data personal data, in kind rather than degree. |
privacy.durable_identity |
false ⚠ |
Replaces the rotating hash with a first-party cookie. Buys returning visitors; reverses the central design decision. |
privacy.salt_rotation_hours |
24 |
Clamped at 24 hours: configuration may tighten the window, never loosen it. |
privacy.geo_resolver / geo_precision |
NullGeoResolver / country |
Country reporting is off by default. Lookups run against a local MaxMind database, never a third-party request per pageview. |
privacy.consent_resolver |
GrantingConsentResolver |
A hook for deployments with obligations Cairn cannot know about. It never bypasses DNT, GPC or the per-visitor opt-out. |
retention |
30 / 30 / null |
Days to keep raw entries and sessions; aggregates are counts rather than records of people, so they are kept forever by default. |
recorders |
PageViews, ClientMetrics, Conversions | Per-recorder enable flag, sample rate, and an ignore list matched with Str::is(). Sampled numbers are scaled back up and marked approximate. |
dashboard |
blade, path cairn |
Driver (blade, livewire, inertia, none), URL path, middleware stack, and the ordered widget list. |
api |
disabled | JSON report endpoint. It can read everything the dashboard can, so it stays off until you decide who may call it. |
tenancy |
disabled | A resolver whose tenant is applied automatically on read and write — never a caller's responsibility to remember. |
pulse |
true |
Registers Live Visitors and Top Routes cards when laravel/pulse is installed, reading Cairn's own storage. |
Publish tags
cairn-config, cairn-migrations, cairn-views, cairn-assets, cairn-privacy (a privacy-notice template and opt-out controller stub) and cairn-inertia.
Drivers
Both driver families are covered by one shared test suite — the same assertions run identically against each, because a behavioural difference between them is a bug rather than a footnote.
| Capability | database (default) |
redis |
|---|---|---|
| Ingest | In-memory buffer, flushed after the response | List drained by cairn:work |
| Unique counting | Exact — one row per visitor per day | HyperLogLog, ~0.81% error |
| Presence | Table with a 5-minute window | Sorted set, self-expiring |
Commands
| Command | What it does |
|---|---|
cairn:rollup | Recompute aggregates for a window. It rebuilds rather than increments, which makes it both idempotent and the authoritative repair path. |
cairn:prune | Enforce retention. Drops whole partitions where the engine supports it. |
cairn:work | Drain the Redis ingest queue (redis driver only). |
cairn:partition | Convert the raw tables to monthly range partitions (MySQL and MariaDB). |
cairn:doctor | Report what this installation stores and exposes — without asserting any legal conclusion. Start here. |
cairn:geoip | Download the MaxMind GeoLite2 database, verify it against the published checksum, and install it — replacing a working database only once a new one is known good. |
cairn:forget | Erase a visitor or user and rebuild the aggregates their rows contributed to. |
cairn:export | Produce a subject access request as JSON. |
Rollup and prune are scheduled automatically, and skipped if the application has already scheduled them itself.

Compared With Matomo and GA4
The honest version: Matomo and GA4 will tell you more about individual people than Cairn can. That is the difference, and it is deliberate.
| Cairn | Matomo (self-hosted) | GA4 | |
|---|---|---|---|
| Where data lives | Your database | Your server | |
| Cookies by default | None | Yes | Yes |
| IP stored | Never | Optional, on by default | Yes |
| Returning visitors | Not possible | Yes | Yes |
| Route names | Yes | No | No |
| Eloquent models | Yes | No | No |
| Install | composer require | Separate application | JS tag |
| Runtime cost | One buffered insert after the response | Separate app + database | Third-party request per page |
What Cairn Deliberately Does Not Do
Not "not yet" — these are decisions:
- Follow anyone across days. Not through fingerprint stitching, fallback identifiers, or "probably the same visitor" heuristics.
- Store an IP address in any table, log, cache entry, exception message or queue payload. An architecture test asserts that no column in any Cairn table is named or typed to hold one.
- Funnels, cohorts, heatmaps, session replay, A/B testing or attribution beyond last click — most of which need the cross-day identity that does not exist here.
- Behavioural bot detection, which means profiling visitors.
- Request high-entropy client hints. Cairn reads the low-entropy ones the browser volunteers and asks for nothing more.
- Scan raw entries from the dashboard. Ever.
- Tell you whether you are compliant with anything. Compliance is a property of how software is deployed and operated, not of a library.
Technical Highlights
- Five portable tables across MySQL, MariaDB, PostgreSQL and SQLite, with optional monthly partitioning and composite time keys prepared from the first migration so partitioning never means rebuilding a huge table later.
- A privacy gate that evaluates DNT, GPC, the per-visitor opt-out and the consent resolver before bot detection, ignore rules and sampling — so no configuration can record against a visitor's expressed wish. Prefetches are declined.
- Rollups that rebuild rather than increment, making recomputation both idempotent and a repair path for drift.
- Optional JavaScript beacon for the handful of measurements a server cannot make — time on page, scroll depth, viewport bucket, Core Web Vitals — with widgets that show an explanatory empty state rather than a misleading zero when it is off.
- Adapters, not dependencies: Livewire, Inertia and Laravel Pulse integrations register only when those packages are installed, and every optional-package reference is confined to a single namespace.
- Data-subject tooling built in: erase, export, and an installation doctor, plus a publishable privacy-notice template and opt-out controller stub.
- Quality gates: Laravel Pint, PHPStan level 9 via Larastan with an empty baseline, Rector, Pest with Orchestra Testbench, and architecture tests enforcing strict types, no debugging helpers, no
env()outside the config file and no facades outside the package's own facade namespace. - CI matrix across PHP 8.2–8.4 and Laravel 12–13, plus a database matrix covering SQLite, MySQL 8, MariaDB 11 and PostgreSQL 16.
Status and Links
Cairn is at v0.1.0, released on 3 August 2026 under the MIT licence. It is pre-release by design: the version number is a promise about stability, and that promise will not be made until the package has run in production somewhere for a meaningful period. While the version is below 1.0.0, minor releases may contain breaking changes.
- Packagist: packagist.org/packages/divoto/cairn
- Source: github.com/divoto/cairn
- Licence: MIT
Technologies Used
Project Details
- Client
- IfHighLow (Open Source)
- Category
- API / Backend
- Completed
- August 2026
- Views
- 2