Search by

akibeo / kirby-umami

wdebusschere

Umami analytics for Kirby CMS: tracker script with CSP nonce, server-side events and a Panel stats view.

Package info

github.com/wdebusschere/kirby-umami

Type:kirby-plugin

pkg:composer/akibeo/kirby-umami

Fund package maintenance!

e-xperience.pt

Statistics

Installs: 83

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

10.0.7 2026-10-26 15:29 UTC

This package is auto-updated.

Last update: 2026-10-26 15:30:39 UTC


README

Tests Kirby 4/5 License MIT

Umami analytics for Kirby. Umami is cookieless and stores no personal data, so it needs no cookie banner and no consent gating.

  • Tracker script — one snippet prints the <script> tag with every tracker option as config.
  • CSP nonce — picks up cspNonce() from akibeo/kirby-csp automatically.
  • Off while developing — nothing is tracked while Kirby's debug option is on.
  • Server-side events — umami()->track('contact-form') from PHP, for things that never reach the browser tracker (form endpoints, redirects).
  • Panel view — an "Analytics" entry with visitors, visits, pageviews, bounce rate and visit time for the last 24h / 7 / 30 / 90 days, the events, goals and breakdown tables of the Umami dashboard, a link to the Umami dashboard and, optionally, the embedded public share report. Follows the Panel's light and dark theme.
  • Cloud or self-hosted — works with Umami Cloud (API key) and self-hosted instances (API key or username/password).

Installation

Composer

composer require akibeo/kirby-umami

Download / Git submodule

Copy this repository into site/plugins/kirby-umami/:

git submodule add https://github.com/wdebusschere/kirby-umami.git site/plugins/kirby-umami

No build step is required — Kirby autoloads plugins from site/plugins/. The plugin registers itself as akibeo/umami and reads its options from the akibeo.umami namespace.

Configuration

Add to site/config/config.php. Only enabled and websiteId are required; the rest are shown with their defaults.

return [
    'akibeo.umami' => [
        'enabled' => true,
        'websiteId' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
        'src' => 'https://stats.example.com/script.js', // default: https://cloud.umami.is/script.js

        // Tracker options, see https://umami.is/docs/tracker-configuration
        'hostUrl' => null,         // origin of the Umami instance; defaults to the origin of `src`
        'autoTrack' => true,       // false: only events you send with umami.track() in JS
        'domains' => [],           // only track on these hostnames
        'tag' => null,             // tag every view, to filter in the dashboard
        'excludeSearch' => false,  // drop ?query from tracked URLs
        'excludeHash' => false,    // drop #hash from tracked URLs
        'excludeBots' => true,     // don't track automated/headless browsers (see note)
        'utmFromClickIds' => true, // file auto-tagged ad clicks under utm_source/utm_medium (see note)
        'beforeSend' => null,      // name of your own (type, payload) => payload JS function

        'trackInDebug' => false,   // also track while option('debug') is true
        'trackPanelUsers' => true, // false: skip logged-in Panel users (see note)
        'nonce' => null,           // CSP nonce; defaults to cspNonce() when akibeo/kirby-csp is installed

        // Umami API, for the Panel view and umami()->stats()
        'apiUrl' => null,          // defaults to https://api.umami.is/v1 (cloud) or {hostUrl}/api
        'apiKey' => null,          // API key (Umami Cloud, or Settings > API keys on self-hosted)
        'username' => null,        // or the login of a self-hosted install
        'password' => null,
        'timeout' => 5,            // seconds, for every request to Umami

        // Panel
        'panel' => true,           // false: hide the "Analytics" menu entry
        'shareUrl' => null,        // public share URL, embedded in the Panel view

        'cache' => true,           // cache for API tokens and stats
    ],
];

Keep secrets (apiKey, password) in a host config (config.<host>.php) that is not committed.

Per-environment overrides

config.localhost.php and friends can override any key:

'akibeo.umami' => [
    'trackInDebug' => true, // test the tracker locally
],

Content-Security-Policy

With akibeo/kirby-csp the script tag and the inline before-send helper (excludeBots, utmFromClickIds) carry the per-request nonce, so script-src needs nothing extra. The tracker posts to /api/send on the Umami host, so add that host to connect-src:

'akibeo.csp' => [
    'directives' => [
        'connect-src' => "'self' https://stats.example.com",
    ],
],

Without kirby-csp, set 'nonce' => fn () => myNonce() (a callable is resolved per request) or leave it null.

Headless-browser bots

Scrapers that drive a real browser run the tracker like any visitor and can inflate the numbers several times over. With excludeBots on (the default) the tag carries a small inline helper, registered as Umami's data-before-send, that cancels every send when the browser reports itself as automated: navigator.webdriver is true (Puppeteer, Playwright, Selenium) or the user agent contains HeadlessChrome. No real visitor matches either, so there are no false positives, but stealth setups hide both and still get through. This only stops new hits; Umami cannot remove what it has already recorded.

Ad clicks without UTM parameters

Umami files a visit under a campaign only by its utm_* parameters. Google Ads auto-tagging adds gclid, gbraid and gad_* instead, so those visits show up as "Paid Ads" in the channels but never in the UTM report. With utmFromClickIds on (the default) the tag carries a small inline helper, registered as Umami's data-before-send, that adds utm_source, utm_medium=cpc and, for Google, utm_campaign from gad_campaignid to the URL Umami receives whenever it sees a paid click ID and no utm_source:

Click ID utm_source
gclid, gbraid, wbraid, gad_source google
msclkid bing
ttclid tiktok
li_fat_id linkedin
twclid twitter

Manual UTM tags always win, the browser URL is not changed and the click ID still reaches Umami. The helper carries the same nonce as the tracker. With excludeSearch on, the tracker strips the query before the helper runs, so there is nothing to pick up. Set beforeSend to the name of your own global function to run it after the helper (or instead of it, when both excludeBots and utmFromClickIds are off).

trackPanelUsers and the pages cache

When the pages cache is on, the first visitor decides what gets cached. A logged-in Panel user hitting an uncached page would cache it without the tracker. Leave trackPanelUsers on when the pages cache is on, or exclude your own visits in Umami instead (localStorage.setItem('umami.disabled', 1) in the browser console).

Usage

Tracker script

In a plain PHP template:

<?php snippet('umami') ?>

In a Blade layout:

@snippet('umami')

Or build the tag yourself:

<?= umami()->scriptTag() ?>
<?= $site->umami()->scriptTag() ?>

The tag is empty when tracking is off, so it can always be printed.

Server-side events

umami()->track('contact-form', ['subject' => $subject]);
umami()->track('download', ['file' => $file->filename()], $page->url());

The event joins the visitor's session in Umami: the visitor's IP and User-Agent are forwarded. Returns false when tracking is off or Umami could not be reached; it never throws.

Stats from PHP

$stats = umami()->stats('30d'); // 24h, 7d, 30d, 90d
// ['pageviews' => 1234, 'visitors' => 567, 'visits' => 600,
//  'bounces' => 210, 'totaltime' => 41000, 'prev' => [...], ...]

$pages = umami()->metrics('path', '7d', 10);
// ['type' => 'path', 'total' => 900, 'rows' => [['x' => '/', 'y' => 400], …]]
// Types: path (url on Umami < 3), entry, exit, title, hostname, query,
// referrer, channel, browser, os, device, screen, language, country,
// region, city, event, tag — see Umami::METRIC_TYPES.

$series = umami()->series('7d');
// ['unit' => 'day', 'timezone' => 'Europe/Brussels',
//  'buckets' => [['key' => '2026-10-01', 'label' => '1 Oct', 'pageviews' => 120, 'sessions' => 80], …]]

$online = umami()->active(); // visitors in the last five minutes

$goals = umami()->goals('7d');
// ['goals' => [['id' => '…', 'name' => 'Contact form sent', 'description' => '',
//   'type' => 'path', 'value' => '/contact/thanks',
//   'conversions' => 23, 'visitors' => 567, 'rate' => 4.1], …]]
// The goals saved in Umami (3.0 or newer), most conversions first; empty
// on older versions. A goal whose result failed to load has 'error' set.

Results are cached for 10 minutes (one minute for active()). All of them throw Akibeo\Umami\UmamiException when the API is unreachable or the credentials are wrong; $e->status() holds the HTTP status. Any other endpoint of the Umami API is reachable through umami()->api('websites/…').

Panel

"Analytics" in the Panel menu: the summary numbers with the change against the previous period, visitors online now, a pageviews chart, the events and the goals saved in Umami with their conversion rate, and the breakdown tables of the Umami dashboard (pages, referrers, browsers, countries, …) for the last 24 hours, 7, 30 or 90 days. The goals card only appears when the website has goals. Without API credentials it only links to the Umami dashboard (and shows the share report if shareUrl is set). To embed the share report, the Umami instance has to allow framing by the Panel origin: set ALLOWED_FRAME_URLS=https://www.example.com in the Umami environment.

API endpoints

All of them need a Panel session.

GET /api/plugin/umami/stats?range=7d returns the summary, the live count and the chart series:

{
  "status": "success",
  "range": "7d",
  "stats": { "pageviews": 1234, "visitors": 567, "visits": 600, "bounceRate": 35, "avgTime": "1m 8s", "avgSeconds": 68 },
  "prev": { "...": "same keys for the previous period" },
  "active": 12,
  "series": { "unit": "day", "timezone": "Europe/Brussels", "buckets": [{ "key": "2026-10-01", "label": "1 Oct", "pageviews": 120, "sessions": 80 }] }
}

GET /api/plugin/umami/metrics?type=country&range=7d&limit=50 returns one breakdown table:

{ "status": "success", "range": "7d", "type": "country", "total": 567, "rows": [{ "x": "BE", "y": 280 }] }

GET /api/plugin/umami/goals?range=7d returns the goals saved in Umami with their conversions in the period (an empty list when there are none):

{ "status": "success", "range": "7d", "goals": [{ "id": "…", "name": "Contact form sent", "description": "", "type": "path", "value": "/contact/thanks", "conversions": 23, "visitors": 567, "rate": 4.1 }] }

Development

composer install
composer test      # vendor/bin/phpunit

The logic lives in Akibeo\Umami\Umami (src/Umami.php); the test suite in tests/ runs it against a bare Kirby app, so no Umami instance is needed.

License

MIT — © E-xperience LAB

Credits

  • Wannes Debusschere