Service Updates

Introducing Cairn: Privacy-First, Self-Hosted Analytics for Laravel

PN
Paige Newsom
7 min read
2 views

We have released Cairn, an open-source Laravel package for cookieless, self-hosted analytics. Two commands to install, a built-in dashboard, no IP addresses stored anywhere, and no cookie banner in the default configuration.

We have released Cairn, an open-source Laravel package that gives any application privacy-first, self-hosted web analytics. It is on Packagist as divoto/cairn, the source is on GitHub, and it is MIT licensed.

Every visitor adds a stone. Nobody leaves a name.

The whole install is two commands:

composer require divoto/cairn
php artisan migrate

Then visit /cairn.

Why we built it

Most Laravel projects end up with the same analytics setup: a Google Analytics tag, a cookie-consent banner nobody enjoys writing, and a dashboard that lives on somebody else's servers. It works, but it costs you three things — a third-party request on every page load, a consent flow to build and maintain, and a copy of your visitors' behaviour held by a company you do not control.

The self-hosted alternatives solve the third problem and keep the first two. Matomo is a separate application with its own database to run and upgrade. The lightweight JavaScript-based tools still put a tag on your page, and still see only what a browser can see.

We wanted something else: analytics that live inside the Laravel application, that store nothing on the visitor's device and no IP address anywhere, and that can answer the question a browser tag never can — which route matters, not which URL was requested.

What it looks like

The dashboard is server-rendered Blade. There is no build step in the entire package — no npm, no Vite, no Node. The CSS is about 8 KB, hand-written and inlined, and the page is fully readable with JavaScript disabled: filters are links, the chart is inline SVG computed on the server, and every chart has a real table underneath it.

Cairn dashboard in light mode: visitors, pageviews, sessions and bounce rate with a 30-day trend chart, live visitors, top routes and referrers

Dark mode follows the system, with a manual toggle in the header:

The same Cairn dashboard rendered in dark mode

Fifteen widgets ship by default — overview, live visitors, top routes, referrers, channels, countries, devices, browsers, operating systems, campaigns, sources, mediums, events, conversions and an activity feed. Each is a class you can reorder, remove, or extend:

Cairn breakdown widgets: channels, countries, devices, browsers, operating systems and campaigns

The one design decision everything follows from

Cairn identifies a visitor with a salted HMAC-SHA256 hash. The salt is 32 random bytes, it lives only in the cache, and it 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 itself.

That single decision cuts both ways, and it is worth being blunt about the cost:

  • You lose returning visitors. Somebody who visits on three days counts as three visitors. There is no way to know otherwise.
  • You lose multi-day journeys and multi-touch attribution. There is no earlier touch to attribute to.
  • Unique counts inflate over long ranges. A month is the sum of its days, and the dashboard marks those numbers with a ~ rather than pretending.

And what you get in return:

  • No cookie banner in the default configuration. Nothing is stored on the visitor's device.
  • No IP address in your database, logs or backups. It exists in memory for one lookup, then it is gone — and an architecture test asserts that no column in any Cairn table is named or typed to hold one.
  • A breach of your analytics table leaks counts, not people.
  • Route names instead of URLs. /orders/8814/invoice and /orders/9921/invoice are one page, not two.
  • Nothing blocks the response. Recording happens in terminate(), after the page has been sent.

If returning-visitor counts are essential to your work, Cairn is the wrong tool and you should use something that sets a cookie and asks for consent. It will do that job better. We would rather say that in the README than have somebody find out three months in.

Things a browser tag cannot do

Running inside the application means Eloquent models can be first-class analytics subjects:

use Divoto\Cairn\Concerns\HasAnalytics;

class Article extends Model
{
    use HasAnalytics;
}

$article->trackView();
$article->trackEvent('shared', ['network' => 'mastodon']);

Events and conversions are one call each:

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

And every surface — the dashboard, the optional JSON API, the Pulse cards, CSV export — reads through one report builder, 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. Ask for a combination that was never rolled up and Cairn throws an exception naming it, rather than quietly turning your dashboard into a table scan.

Redis is optional, and always will be

Cairn ships two driver families. The database driver buffers entries in memory and flushes them after the response, counts uniques exactly with one row per visitor per day, and tracks presence in a table with a five-minute window. The redis driver drains a list with cairn:work, counts with HyperLogLog, and uses a self-expiring sorted set.

Both are covered by a single shared test suite — the same assertions run against each, because a behavioural difference between the two is a bug, not a footnote. Cairn runs perfectly well on shared hosting with MySQL and nothing else.

Start with the doctor

Every installation stores and exposes slightly different things depending on how it is configured, so Cairn ships a command that tells you exactly what yours is doing:

Terminal output of php artisan cairn:doctor showing table sizes and configuration observations

It reports observations, not errors — several may be entirely deliberate. What it will not do is tell you that your deployment complies with GDPR or anything else. Compliance is a property of how software is deployed, configured and operated, not of a library, and nothing in the package is legal advice.

Alongside it are cairn:forget, which erases a subject and rebuilds the aggregates their rows contributed to, and cairn:export, which produces a subject access request as JSON. Retention is enforced by cairn:prune, and cairn:rollup rebuilds a window rather than incrementing it — which makes it idempotent and, more usefully, a repair path when something has drifted. Both are scheduled automatically and skipped if you have already scheduled them yourself.

Two settings that change the deal

Two configuration options change what Cairn stores. Both are off by default, and both carry a long explanation in config/cairn.php rather than a one-line comment:

  • privacy.track_user_id attributes entries to the signed-in user. That makes the data personal data in kind, not just in degree, and brings it inside whatever obligations already apply to your user records.
  • privacy.durable_identity replaces the rotating hash with a first-party cookie. It buys you accurate returning-visitor counts and reverses the central design decision — a durable identifier on a visitor's device is exactly the thing consent regimes are written about.

cairn:doctor reports on both.

Where it is now

Cairn is at v0.1.0. It is pre-release by design: the version number is a promise about stability, and we are not making that promise until it has run in production somewhere for a meaningful period. While it is below 1.0.0, minor releases may contain breaking changes.

Under the hood it runs Laravel Pint, PHPStan level 9 via Larastan with an empty baseline, Rector, and Pest against Orchestra Testbench, with architecture tests enforcing the privacy invariants themselves. Continuous integration covers PHP 8.2 to 8.4, Laravel 12 and 13, and a database matrix of SQLite, MySQL 8, MariaDB 11 and PostgreSQL 16.

If you try it, we would genuinely like to hear where it falls short — particularly the trade-offs above. Issues and pull requests are welcome, with one caveat stated up front in CONTRIBUTING.md: the privacy invariants are not negotiable, and a contribution that weakens one will be declined however well written it is.

Share this article

PN

Paige Newsom

Author at IfHighLow

Related Articles

Callie and Her Creative Stream

New Story: Callie and Her Creative Stream

In a sparkling stream, live a young caddisfly larva named Callie. She is a little artist with a soft, curled body tucked in a tube house made of pebbles, sand, and shells. Callie loved her colorful ho...

NN
Nora Newsworthy
Ferdy's Big Responsibilities

New Story: Ferdy's Big Responsibilities

In a lush, green forest, Ferdy the Darwin's Frog lived happily. He was small and leaf-green with a pointy snout like a leaf tip, slender striped legs, and bright watchful eyes. Ferdy had a special pou...

PN
Paige Newsom
The Night-Blooming Wonder

New Story: The Night-Blooming Wonder

In the heart of the lush, green forest, Pia the Potoo sat quietly on a branch. Her gray-brown feathers made her look just like the tree. With her golden eyes wide open, she watched the world around he...

FF
Finn Feedman

Stay Updated

Get the latest insights, tutorials, and industry news delivered to your inbox.

We respect your privacy. Unsubscribe at any time.