Looking to hire Laravel developers? Try LaraJobs

laravel-stats maintained by n2ns

Description
N2N Stats: cookieless, multi-site web analytics for Laravel. Only daily aggregates are stored.
Author
Last update
2026/11/11 12:21 (dev-main)
License
Downloads
0

Comments
comments powered by Disqus

N2N Stats

Cookieless, multi-site web analytics for Laravel. One Laravel app collects page views, sources, outbound clicks, downloads, custom events and AI fetches for any number of websites, and stores only daily aggregates in your own database.

n2ns/laravel-stats is made by Datafrog LLC as part of N2NS Lab.

Why

  • No cookies, no browser storage. The tracking script never reads or writes cookies, localStorage or sessionStorage, so there is nothing to ask consent for in a cookie banner.
  • Aggregates only. There is no table of individual page views. Every report is a daily counter (page views, visits, visitors, bounces, …) per site, page, source, country, device and so on.
  • No personal data kept. IP addresses and User-Agent strings are used for a moment to compute a hash and are never stored. The hash uses a random salt that changes every day and is deleted within 48 hours after the day ends, so a visitor cannot be followed across days or across sites.
  • Multi-site. One installation serves many websites with one line of script each; they do not have to be Laravel apps.
  • Self-hosted, no third parties. Data stays on your server. Country comes from your CDN's request header; no IP address is ever sent to a geolocation service.

What is and isn't collected

Collected (as daily counts) Not collected
Page path (without query string or fragment), page title, page type and content group IP addresses
Referrer host, UTM parameters, source type Full User-Agent strings
Country (from the CF-IPCountry header) Cookies or any browser storage
Device class, browser, operating system, browser language, site language version Individual page views or sessions
Outbound link, download and event targets (without query string) Identifiers that last longer than a day
Custom event names and labels; moves between pages within a visit Anything about a visitor across days or sites
AI fetcher requests (server side)

See PRIVACY.md for the full list and a paragraph you can adapt for your privacy policy.

Requirements

  • PHP ^8.3
  • Laravel 12 or 13
  • MySQL, PostgreSQL or SQLite

Installation

composer require n2ns/laravel-stats
php artisan stats:install

stats:install publishes config/stats.php, runs the migrations (pass --force in production) and prints the next steps below.

Host setup

The app that stores the data runs in host mode (the default, STATS_MODE=host).

1. Choose where the collector lives

The tracking script, the collector and the read API can run on their own domain, for example stats.example.com, while the dashboard stays on your app's normal domain:

STATS_COLLECTOR_DOMAIN=stats.example.com

Leave it empty to serve them on the app's own domain under STATS_COLLECTOR_PATH (empty by default, so the script is at /js/stats.js).

For a separate stats subdomain:

  • DNS: add a record for stats.example.com pointing to the server of your app.
  • Web server: point the subdomain at the same Laravel app, e.g. an Nginx server block with server_name stats.example.com; and the same root and PHP handling as your app, plus a TLS certificate for the name.
  • Country data: the country is read from the CF-IPCountry request header. Put the domain behind Cloudflare with the proxy enabled (or another CDN that sends this header); without it every visit is counted with the country unknown.
  • Real client IP: behind a proxy or CDN, configure Laravel's trusted proxies so that $request->ip() is the visitor's address. The address is only used for the daily hash and rate limits, but if all requests appear to come from the proxy, all visitors collapse into one.

2. Allow access to the dashboard

The dashboard is guarded by the viewStats gate, which denies everyone until you define it, for example in AppServiceProvider::boot():

use Illuminate\Support\Facades\Gate;

Gate::define('viewStats', fn ($user) => $user->is_admin);

3. Run the scheduler

The package schedules stats:maintain every five minutes. It ends visits that have been idle for 30 minutes, deletes expired salts and per-day deduplication data, and removes aggregates older than retention_days. Make sure Laravel's scheduler runs:

* * * * * cd /path-to-your-app && php artisan schedule:run >> /dev/null 2>&1

Adding sites

php artisan stats:site create --name="Example" --domain=example.com --domain=*.example.org --timezone=Europe/Berlin

The command prints the site's public id and its snippet. With collector_domain set, the snippet points to https:// on that domain; otherwise its URL is built from APP_URL, so set APP_URL to the app's public URL first. Add the snippet to the <head> of every page:

<script defer data-site="01JC7…" src="https://stats.example.com/js/stats.js"></script>
  • Domains are the hostnames allowed to send data for the site. example.com also accepts www.example.com; *.example.com accepts example.com and every subdomain. Events from other hosts, from localhost and from IP addresses are dropped.
  • Testing locally: the script sends nothing on localhost or an IP address. Open the page under a hostname that resolves to your machine, such as 127.0.0.1.nip.io, and add that hostname to the site's domains.
  • Timezone decides where a day starts and ends for the site's daily aggregates (default UTC).
  • Site languages (--locale=en --locale=zh-cn, optional): the URL path prefixes of the site's language versions. Each page view is counted under its language version (the first path segment when it is one of them, otherwise --default-locale), so you see which language versions are read.
  • Excluded paths (--exclude-path=/admin*, repeatable): paths that are never counted, for every way of sending (script and server); * matches any characters.
  • Public counts (--public-counts, off by default): lets anyone read the all-time view and event counts of the site's pages without a token, see Public counts.

Other actions:

php artisan stats:site list
php artisan stats:site update --site=<public id> --name="New name" --domain=example.com --timezone=UTC
php artisan stats:site delete --site=<public id>          # asks first; --force skips the question
php artisan stats:site key --site=<public id>             # server key for client mode, see below

update replaces the domain, site language and excluded path lists when any --domain, --locale or --exclude-path is given. delete removes the site and all of its statistics. Sites can also be added and edited on the dashboard.

The tracking script

The script is a single dependency-free file of about 6 KB.

Automatic:

  • A page view when the page loads, and another one each time the path changes through history.pushState, history.replaceState or popstate, so single-page apps are counted without extra code.
  • An outbound event when a link to another host is clicked (left or middle button).
  • A download event when a link to a file with one of the configured download_extensions (pdf, zip, dmg, exe, apk, …) is clicked.
  • Outbound and download events carry the link and, when the link or one of its parents has a data-stats-label attribute, that label, e.g. the project a card belongs to: <div data-stats-label="pairdock"><a href="https://github.com/…">…</a></div>.

Page details make reports easier to read. Each page view sends the page title and, when present, these meta tags. After a single-page-app navigation the page view is sent once the router has had a chance to render the new page, so the new title and meta tags are the ones sent:

<meta name="n2n-stats:type" content="article">   <!-- page type: [a-z0-9_-], up to 40 characters -->
<meta name="n2n-stats:group" content="post-42">  <!-- shared by the language versions of one piece of content -->
<meta name="n2n-stats:skip">                      <!-- e.g. on the 404 page: nothing is sent from this page -->

Custom events, either from JavaScript:

window.n2nStats('signup', { label: 'pro-plan', target: '/checkout' });

or without JavaScript, by marking an element; a click sends the event, and when the element is or is inside a link, the link (internal links too) is sent as its target:

<a href="/checkout?plan=pro" data-stats-event="cta" data-stats-label="pro-plan">Buy</a>

Event names match [a-z0-9_.-]{1,64}; labels are at most 120 characters; targets are stored without query string.

Exclusions: nothing is sent when navigator.webdriver is true (automated browsers), on file:// pages, or on localhost, 127.0.0.1 and [::1]. A data-exclude-paths attribute on the script tag excludes paths: a comma-separated list where * matches any characters, including / (for example data-exclude-paths="/admin*,/preview/*"). Downloads are detected by file extension; data-download-extensions="pdf,zip" on the script tag replaces the built-in list. Requests whose User-Agent is empty or matches the bot_user_agents list are dropped by the collector.

Data is sent with navigator.sendBeacon (or fetch with keepalive and no credentials) as a text/plain request, so browsers do not send a CORS preflight. The collector always answers 204 No Content, also when it drops an event; only malformed requests get 400.

Counting on the server

Three things can be counted by your Laravel app itself instead of the browser:

  • AI fetches. AI crawlers and assistants (GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, …) do not run JavaScript. The TrackAiFetches middleware records each matching GET request answered with 2xx or 3xx as an ai_fetch event with the fetcher's name and the requested path. Fetchers listed in ai_user_fetchers (ChatGPT-User, Claude-User, Perplexity-User) are shown as user-triggered, the others as crawlers.

  • Page views, optionally. The TrackPageviews middleware counts GET requests answered with 2xx and an HTML body (not prefetches, bots or the package's own pages). Visitors without JavaScript are counted, and 404 pages or made-up paths are not. Describe a page from your controller or view:

    use N2ns\Stats\Stats;
    
    Stats::page(title: $post->title, type: 'article', group: 'post-'.$post->id);
    

    A site counted this way must not also send page views from the script, or every view is counted twice. To keep the script's outbound, download and custom events, load it with data-pageviews="off":

    <script defer data-site="01JC7…" data-pageviews="off" src="https://stats.example.com/js/stats.js"></script>
    
  • Events from the server, e.g. when a controller redirects to a checkout:

    Stats::event('checkout_start', label: 'pro', target: $checkoutUrl);
    

    The event is counted after the response, for the visitor of the current request, and is credited to that visitor's visit like events from the script.

  • On the host app, the middleware and Stats::event() write to the database directly, through the same checks as the collector (site domains, rate limits, bot filter, excluded paths). Set STATS_HOST_SITE to the host's own site public id, so the site is not taken from the request's Host header. AI fetches are limited to rate_limits.server_events_per_minute per site and minute.

  • On another Laravel site, install the same package in client mode. It creates no tables and only sends these events to the host (for page views and events together with the visitor's IP address and User-Agent, which the host needs for the daily visitor hash and does not store). Create a server key on the host:

    php artisan stats:site key --site=<public id>
    

    The key is shown once, together with the lines for the client's .env:

    STATS_MODE=client
    STATS_CLIENT_ENDPOINT=https://stats.example.com
    STATS_CLIENT_SITE=<public id>
    STATS_CLIENT_KEY=<key>
    

    Running key again replaces the key; the old one stops working.

Register the middleware, for example in bootstrap/app.php:

use N2ns\Stats\Http\Middleware\TrackAiFetches;
use N2ns\Stats\Http\Middleware\TrackPageviews;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [TrackAiFetches::class, TrackPageviews::class]); // TrackPageviews only if wanted
})

In client mode everything is sent after the response has been delivered, with a 2-second timeout; failures are logged and never affect the page.

Dashboard

Open /stats (the dashboard_path) on your app's domain. It is plain server-rendered Blade with inline SVG charts: no front-end build and no CDN. The interface is in English and Simplified Chinese and follows your app's locale (zh_CN shows Chinese).

  • Site switcher and date ranges (today, yesterday, 7, 15, 30 and 90 days, this month, last month, custom). Changes are computed over complete days: a range that includes today compares its days up to yesterday with the same number of days before (today alone compares yesterday with the day before); the page shows both compared ranges.
  • Overview: visitors, visits, page views, pages viewed, views per visit, bounce rate, average visit duration, events, outbound clicks, visitors online now (visits with activity in the last 5 minutes) and a daily chart of visitors, visits, page views, events and outbound clicks.
  • Pages: pages with title and type, entry pages with the page views, outbound clicks and events of the visits that started there, exit pages, page types, content groups, and moves from page to page.
  • Sources: source types and sources (with page views, outbound clicks and events), UTM campaigns, and landing pages by source, by campaign and by search engine.
  • Visitors: countries, devices, browsers, operating systems, browser languages and site languages, with visits, visitors and page views.
  • Events: custom events with label and target, outbound links and downloads with label, and every event with the page it happened on.
  • AI: visits and visitors referred by AI assistants and AI fetches with a daily chart, AI referrals, the pages AI assistants send visitors to, AI fetchers (crawler or user-triggered, with the number of pages fetched) and the pages they fetched.
  • Every table shows the top 50 rows with each row's share, can be sorted by any column and exported as CSV (up to 500 rows); source types, countries, devices, site languages and page types also have a donut chart.
  • Site management: add sites, edit domains, timezone, site languages, excluded paths and public counts, show the embed snippet, reset the server key.

Filament

If your app uses Filament, register the plugin in your panel:

use N2ns\Stats\Filament\StatsPlugin;

$panel->plugin(StatsPlugin::make()->navigationGroup('Analytics')->navigationLabel('Statistics'));

It adds a page with the full dashboard inside your panel, and widgets with each site's visitors and page views for today and the last 7 days (with the change over complete days) and the top 5 sources of the last 7 days. Both are visible to users allowed by the viewStats gate.

Read API

A read-only JSON API is served on the collector domain. Create a token:

php artisan stats:token create --name="Reporting" --site=<public id>   # repeat --site, or omit it for all sites
php artisan stats:token list
php artisan stats:token revoke --id=<token id>

The token is shown only once; only its SHA-256 hash is stored. Send it as a bearer token:

Endpoint Returns
GET /api/v1/sites The sites the token can read
GET /api/v1/sites/{public_id}/summary Totals for the range, and comparison: the complete-day current and previous ranges with their totals
GET /api/v1/sites/{public_id}/timeseries Daily visitors, visits, page views, events and outbound clicks
GET /api/v1/sites/{public_id}/ai AI visits, visitors, page views and fetches, with a daily series
GET /api/v1/sites/{public_id}/breakdown/{report}?limit=&offset=&min_visitors= Rows of one report
GET /api/v1/sites/{public_id}/snapshot?limit=&min_visitors= Summary, timeseries, AI and the top rows of every report in one call

report is one of pages, entries, exits, page_types, content, transitions, source_types, sources, campaigns, landing_pages, campaign_pages, search_pages, countries, devices, browsers, os, languages, locales, outbound, downloads, events, event_pages, ai_referrals, ai_pages, ai_fetchers, ai_fetched_pages. Breakdown rows are paged with limit (1–500, default 50) and offset; has_more says whether more rows follow. min_visitors leaves out rows with fewer visitors. The snapshot's limit is 1–100 (default 10).

Every per-site endpoint takes the range as from and to (YYYY-MM-DD in the site's timezone; at most 366 days; default the last 30 days) or as period, one of today, yesterday, 7d, 15d, 30d, 90d, month, last_month (the same ranges as the dashboard). An unknown period, or a period together with from or to, returns 422. Responses include partial: true when the range includes the site's today, whose numbers are still growing. Requests are limited to 60 per minute per token.

curl -H "Authorization: Bearer $STATS_TOKEN" \
  "https://stats.example.com/api/v1/sites/01JC7…/summary?from=2026-11-01&to=2026-11-30"

curl -H "Authorization: Bearer $STATS_TOKEN" \
  "https://stats.example.com/api/v1/sites/01JC7…/breakdown/pages?from=2026-11-01&to=2026-11-30&limit=10"

Public counts

To show view or like counts on your own pages, turn on public counts for the site (stats:site update --site=<public id> --public-counts, or on the dashboard). Then anyone can read all-time numbers without a token:

curl "https://stats.example.com/api/public/sites/01JC7…/counts?path[]=/blog/hello&event[]=like"
{"site":"01JC7…","pages":{"/blog/hello":{"pageviews":1234,"last_date":"2026-11-10"}},"events":{"like":{"post-42":56}}}

Up to 50 paths and 20 custom event names per request; event counts are grouped by label. Responses allow any origin (Access-Control-Allow-Origin: *), are cacheable for 60 seconds and are limited to rate_limits.public_per_minute requests per IP and minute. Sites without public counts answer 404.

Configuration

Publish the file with php artisan vendor:publish --tag=stats-config. Main options:

Key Env Default Meaning
mode STATS_MODE host host stores data and serves the collector, dashboard and API; client only sends server-side events (AI fetches, page views, events) to a host
table_prefix STATS_TABLE_PREFIX stats_ Prefix of all tables
collector_domain STATS_COLLECTOR_DOMAIN none Domain for the script, collector and API; none = the current domain
collector_path STATS_COLLECTOR_PATH empty Path prefix of those routes
dashboard_path STATS_DASHBOARD_PATH stats Dashboard route prefix
dashboard_middleware ['web'] Middleware of the dashboard routes (the viewStats gate is always applied)
host_site STATS_HOST_SITE none Host mode: public id of the site the middleware and Stats::event() count for; none = found by the Host header
visit_timeout_minutes 30 Idle time that ends a visit
retention_days STATS_RETENTION_DAYS none Days to keep daily aggregates; none = forever
rate_limits Collector limits per site (3000/min), per IP (120/min, IPv6 per /64) and per visitor (60/min); AI fetches per site (600/min), API per token (60/min), public counts per IP (120/min)
max_new_rows_per_day 5000 New daily rows per site, day and table; further values are counted under (other)
bot_user_agents User-Agent substrings that are dropped as bots
ai_fetchers User-Agent substrings counted as AI fetchers
ai_user_fetchers AI fetchers that fetch on a person's request (the others are crawlers)
search_hosts, social_hosts, ai_hosts Referrer hosts that classify a source as search, social or AI
click_ids Click-id query parameters (gclid, fbclid, …) and the source they imply
download_extensions File extensions counted as downloads
client.endpoint, client.site, client.key STATS_CLIENT_* Client mode: host URL, site public id and server key

Data model

All tables use the table_prefix. Daily tables are bucketed by the site's timezone.

Table Contents Kept
stats_sites Name, public id, allowed domains, timezone, site languages, excluded paths, public counts, hash of the server key Until deleted
stats_api_tokens Name, hash of the token, allowed sites, last use Until revoked
stats_daily_totals Page views, visitors, visits, bounces, visit duration, events, outbound clicks per day retention_days
stats_daily_pages Per path: page views, visitors, entries, exits, the page views, outbound clicks and events of visits that entered there; title, page type, content group retention_days
stats_daily_sources Per source (type, host or UTM source, medium, campaign, content, term): visits, visitors, page views, bounces, outbound clicks, events retention_days
stats_daily_entries Per entry page and source: visits, visitors retention_days
stats_daily_transitions Per move from one page to another: count, visitors retention_days
stats_daily_dimensions Per country, device, browser, OS, browser language, site language: visits, visitors, page views retention_days
stats_daily_events Outbound clicks, downloads, custom events and AI fetches per name, label, target and page: count, visitors retention_days
stats_salts The random salt of each day Two days
stats_uniques Visitor hashes already counted per day and report row Two days
stats_visits Visits in progress (hash, start, last activity, page views, entry and exit page, source, device class, browser, OS, country, language) Until 30 minutes idle

How the metrics are defined

  • Page view: one per page load or single-page-app path change reported by the script, or per successful HTML response counted by the TrackPageviews middleware.
  • Pages viewed: the number of different paths with at least one page view in the range.
  • Visitor: a unique sha256(daily salt + site + IP + User-Agent) within one day. The salt changes daily, so the same person is a new visitor every day. Visitors over a range of days are the sum of the daily unique visitors, not a count of distinct people.
  • Visit: page views and events from the same visitor with less than 30 minutes between them. After 30 minutes of inactivity the next page view starts a new visit. When the daily salt changes, the visitor hash changes too, so the next page view also starts a new visit.
  • Source: decided when a visit starts, from UTM parameters, the referrer and click ids (in that order: UTM, AI, search, social, click id, other referrer, direct). Referrals from the site itself count as direct.
  • Bounce: a visit that ended with exactly one page view. Bounce rate = bounces / visits.
  • Events: outbound clicks, downloads and custom events (AI fetches are counted separately). An event is credited to the source and the entry page of the visit it happens in.
  • Page move: two consecutive page views of one visit on different paths.
  • Visit duration: the time between the first and the last activity of a visit; the total of ended visits is stored, and the average is derived from it. A single page view without further events lasts 0 seconds.
  • Bounces, durations, exit pages, page views per country, device, browser, OS and browser language, and the page views after an entry page are counted when a visit ends (30 minutes after its last activity), so the most recent figures fill in with a delay.
  • Comparisons use complete days only (see Dashboard).

Limitations

  • The numbers are for reference only. Anyone can send events from an allowed domain; domain checks, rate limits, bot filters and field validation reduce noise but are no guarantee.
  • No identification across days. Returning visitors, multi-day unique visitors and cross-day funnels are deliberately impossible.
  • Pages with Referrer-Policy: no-referrer or same-origin send beacons without an origin; they are accepted when the page URL belongs to the site, so the origin check only applies when a browser sends one.
  • Values beyond max_new_rows_per_day for a site and day are grouped under (other).
  • No conversions or revenue yet. Goals, funnels and revenue tracking are not part of this version.
  • With the script alone, visitors without JavaScript are not counted, and a page that loads the script is counted whatever its HTTP status unless it carries <meta name="n2n-stats:skip">. Use the TrackPageviews middleware for server-side page views. Crawlers other than AI fetchers are never counted.

Contributing and security

See CONTRIBUTING.md. Please report vulnerabilities privately as described in SECURITY.md.

License

Apache-2.0. Copyright 2026 Datafrog LLC.