Four Cairn releases in a month: a stability promise at 1.0, dashboard filters that narrow the whole page in 1.1, Core Web Vitals, landing pages and top content in 1.2, and a Laravel 13 beacon fix plus an origin check in 1.2.1.
A month ago we introduced Cairn, our open-source, privacy-first analytics package for Laravel. Since then it has gone from a pre-release to 1.2.1, and this site has been running each release in production as it shipped. This post walks through the four releases in order and what each one means for anyone running the package. The full record is in the changelog on GitHub.
The short version:
- 1.0.0 made the version number a promise. No code changes, a fixed public API.
- 1.1.0 made the dashboard filters honest. Selecting a country now narrows every number on the page, or says why it cannot.
- 1.2.0 surfaced four things Cairn was already recording and never showed: languages, screen sizes, Core Web Vitals, landing and exit pages, and top content.
- 1.2.1 fixed the beacon on Laravel 13 and taught the collect endpoint to check where a submission came from.
Upgrading from any 1.x release is one Composer constraint change:
composer require divoto/cairn:^1.2
php artisan migrate
1.0.0 — the version number becomes a promise
When we introduced Cairn it was at 0.1.0, and we said we would not call it stable until it had run in production somewhere for a meaningful period. Three 0.x releases later it had, and 1.0.0 carries no code changes over 0.4.0. It is the commitment the pre-release line deferred, and nothing else.
From 1.0.0 onward the public API will not break within a major version. That covers the Cairn facade, the Divoto\Cairn\Contracts\* interfaces a host application binds to, the published config file, the cairn:* commands a scheduler runs, and the shape of the reporting payloads. Anything under Divoto\Cairn\Support, the storage schema and the Blade markup are internal and may still change in a minor release.
The pre-release warnings in the README, the installation guide and the security policy were retracted rather than left to contradict the tag. Upgrading from 0.4.0 is a Composer constraint change and nothing else.
1.1.0 — filters that narrow the whole page
This release is entirely on the read path. No data, schema or rollup changes, and no migration to run. Every fix is about the dashboard and the report builder telling the truth about what they can and cannot answer.
Selecting a country now narrows the numbers
Clicking a row in the Countries, Top routes or Channels panel added ?country=SG to the URL and raised a filter chip, but the headline totals and the chart went on showing site-wide figures. The filter reached only the panel it was clicked in, and the overview never passed it to its report at all.
It does now. The totals and the timeseries answer for the selected value. Metrics the rollup never measured at that grain, sessions and bounce rate, render as — rather than as a site-wide figure wearing the filter's label. Unique visitors are counted per route as traffic arrives, so a route filter reports them and a country filter withholds them. Panels grouped by another dimension still cannot be narrowed, since v1 rolls up one dimension at a time, and they now say so beneath their heading instead of leaving the chip to imply otherwise.
A filter without a grouping returned zero
This one was the wrong kind of bug: a plausible number that was not the answer.
Cairn::report()
->filter(Dimension::Country, 'DE')
->total(); // returned 0
The builder read the dimensionless "overall" rollup, filtered every row of it out in PHP and reported zero. It now reads the rollup for the filtered dimension and collapses it, and timeseries() applies filters it was previously ignoring outright. Two filters at once throw UnavailableDimensionException like every other unmaterialised combination, rather than silently honouring one.
One filter at a time, and nulls instead of zeros
Selecting a country while a route was selected produced a URL naming both, describing an intersection that was never rolled up. Dimension filters now replace each other, in the UI and in Filters::with(). A hand-written URL naming two is read as the first rather than half-honoured.
Alongside that, ReportRow::metric() returns null for metrics a narrowed report cannot measure, so toArray() and the CSV export drop those columns for a filtered report rather than carrying a zero that was never measured. Unfiltered reports are unchanged.
1.2.0 — four panels for data Cairn already had
Every panel in this release is a rollup on top of data that was being collected before it. Nothing new is asked of a visitor anywhere in it. That is the part we are most pleased with: the privacy footprint did not move, and the dashboard got considerably more useful.
Languages and screen sizes
Both columns have been written on every pageview since 1.0 and neither was ever reported. A language is shown as it was recorded, so pt-BR stays pt-BR. Screen sizes come from the optional beacon only, and the panel says so rather than showing a zero. Neither panel is in the default list, since fifteen panels was already a long page. The config file names both as opt-in.
Core Web Vitals
The beacon has measured LCP, INP and CLS since 1.0 and put all three in every payload. The collect endpoint validated four other fields and dropped these. They are now stored and reported per route, led by the share of page views that met Google's thresholds. An average hides its own tail, and one slow render in ten is exactly the experience worth knowing about.
Only Chromium has all three observers. A browser missing one contributes no sample for that vital rather than a misleading zero, which matters most for CLS, where zero is the best score rather than the absence of one.
Landing and exit pages, with a bounce rate per page
These are the first dimensions measured from sessions rather than entries, and therefore the first that can report a bounce rate per page. That is the question people actually ask of this data, and one no other panel could answer. Clicking a landing page narrows the headline sessions and bounce rate, which no filter could do before. Exit pages ships opt-in: every visit ends somewhere, and a page at the top of that table is not a fault by itself.
To make this work, a session's entry and exit URL now hold a collapsed path. Identifiers become {id}, so every order's invoice stops being its own landing page with one session against it, and the query string is dropped. Campaigns have columns of their own, and keeping them here would split one landing page across every campaign that pointed at it.
Top content
trackView() has written a subject type and id on every entry since 1.0 and nothing read them back. Models are now ranked by views and shown by name, and a model can name itself with analyticsLabel(). The HasAnalytics trait gains three read helpers, each reading the rollup rather than raw entries:
$article->views();
$article->analyticsEvents();
$article->analyticsConversions();
With that comes a new observation in cairn:doctor: it reports views recorded against your user model. Tracking views of content is what the subject dimension is for. Tracking views of people puts user ids into aggregate keys, which outlive raw retention and are not covered by cairn:forget.
A published config file now picks up new settings
Laravel merges top-level config keys only. A deployer who published config/cairn.php at 1.0 never saw a key added inside privacy or dashboard: their nested array won whole and the new setting read as null. Cairn now merges its defaults underneath a published file recursively, with one rule:
Named settings fill in. Lists stay exactly as written.
Removing a widget or an ignore pattern is still permanent, and an empty widgets array still means no panels. Turn a setting off by writing false rather than by deleting it. A deleted key is one you have expressed no opinion about, so it returns at its default.
Also in 1.2.0
- A model event no longer fails the host's request.
trackView(),trackEvent()andtrackConversion()run inside your own request, and with Cairn's storage unreachable they threw straight into the page. The trait now guards itself the same way the pageview middleware does: the failure is reported and the page is served. - Placing the beacon is documented. The installation guide now shows where the
@cairndirective goes, and the config comment says what theauto_injectswitch is: reserved, and not yet wired to anything. - Tested on PHP 8.5. The suite runs on PHP 8.2 through 8.5 against Laravel 12 and 13, at both the lowest and the highest dependency versions the package allows.
Upgrading to 1.2.0
Run the migration, which adds three nullable columns to the entries table. Then re-run the rollup over your retained raw entries, so the new panels have history rather than starting from the moment you upgraded:
php artisan migrate
php artisan cairn:rollup --from=2026-08-07 --to=2026-09-06 --period=all
--period=all rebuilds the hour, day and month buckets, which is what the dashboard's ranges read. Skip the step and the new panels are still correct, only empty until traffic accumulates. How far back it is worth going is bounded by your entries retention. Core Web Vitals are the exception either way: nothing before the upgrade stored them, so that panel fills from now on regardless.
Two more things to check. If you have published the config file with a widgets list, the three new default panels (Web vitals, Landing pages and Top content) will not appear until you add them. That list is yours and Cairn will not add to it. And if you ran config:cache before upgrading, run it again, since a cached configuration skips the merge entirely.
1.2.1 — the beacon on Laravel 13
A patch release for anyone running the beacon on Laravel 13, where it had been silently rejected since the framework renamed its CSRF middleware.
Fixed: a 419 on every measurement
Laravel 13 renamed its CSRF middleware to PreventRequestForgery and made ValidateCsrfToken a subclass of it. The collect route lifted the check by naming the subclass, which the router does not match against the parent. Every measurement the beacon sent on Laravel 13 was answered with "Page Expired", and no time on page, scroll depth, screen size or Core Web Vital was ever stored. The route now names both classes. Laravel 12 was unaffected.
Security: the collect endpoint checks where a submission came from
The collect endpoint runs without the CSRF check because sendBeacon() cannot carry a token. It now reads the two headers a browser sets and a page cannot forge. Sec-Fetch-Site must be same-origin when present, and otherwise the Origin host must be the site's own. A POST that a browser attributes to another site is dropped before anything else runs.
A request carrying neither header, which no browser sends on a POST, is left to the checks that already bound what a script can do: a matching recent pageview for the same visitor, one submission per pageview, and the per-visitor rate limit.
An empty beacon panel says which of two things it means
Core Web Vitals, time on page, scroll depth and screen sizes all showed "This needs the optional JavaScript beacon, which is not enabled" whenever they had no rows for the selected period. That included when the beacon was enabled and had simply not been reported to yet, or when the visitor's own browser was sending Do Not Track. The panel now says the beacon is enabled and no browser has reported in the period, and keeps the old wording for when it is actually off.
What has not changed
Every release above kept the invariants the package was built on. No IP address is stored anywhere. Nothing is written to a visitor's device in the default configuration. The visitor hash still rotates its salt every 24 hours and the old salt is still destroyed. The architecture tests that assert those things have run green on every tag, and the contribution policy is unchanged: a change that weakens one of them will be declined however well written it is.
This site runs 1.2.1 with the beacon enabled, so the Core Web Vitals, landing pages and top content panels described above are the ones we look at ourselves. If something in a release does not behave as this post describes, the issue tracker is the place to tell us.
- Changelog: github.com/divoto/cairn/blob/main/CHANGELOG.md
- Packagist: packagist.org/packages/divoto/cairn
- Launch post: Introducing Cairn
- Technical write-up: Cairn in our portfolio
Paige Newsom
Author at IfHighLow