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.

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

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:

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/invoiceand/orders/9921/invoiceare 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:

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_idattributes 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_identityreplaces 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.
- Packagist: packagist.org/packages/divoto/cairn
- Source: github.com/divoto/cairn
- Full technical write-up: Cairn in our portfolio
Paige Newsom
Author at IfHighLow