Skip to content

Analytics · Open source

Laravel First-Party Analytics

Self-hosted web analytics in your Laravel app's own database: visits, sources and UTM campaigns, custom events, conversions and ordered funnels, with no cookies by default.

Laravel 12 and 13 PHP 8.3+ v0.1.0 Free · MIT
Laravel First-Party Analytics: Self-hosted web analytics in your Laravel app's own database: visits, sources and UTM campaigns, custom events, conversions and ordered funnels, with no cookies by default.

What it solves

A hosted analytics script sends every visitor's data to someone else's servers and usually needs a cookie banner.

The Laravel packages that keep data at home tend to log raw requests, or cover one piece (attribution, or request logs) without visits, sources and a dashboard.

A middleware on the web group records each successful HTML page as a pageview and groups pageviews into visits with their landing page, referrer, UTM tags, browser, OS and device. Everything goes into two tables in your own database, batched and written after the response, and a gated Blade dashboard reads it back. The package makes no network calls.

  • Pageviews and visits: a visit ends after 30 idle minutes or when the visitor arrives from a new campaign or another site
  • Sources: referrer host and URL without its query string, utm_source/medium/campaign/term/content, ?ref=, and channels such as Organic search, Social, Email and Paid
  • Custom events from PHP (Analytics::track) and from the browser (@firstPartyAnalytics, analytics.track and data-analytics-event clicks)
  • Conversion goals and ordered funnels defined in config, with step-by-step drop-off
  • No cookies by default: a visitor id that changes every day; optional first-party cookies to recognise returning visitors and link earlier visits at sign-in
  • Skips bots and empty user agents, Do-Not-Track and Global Privacy Control browsers, and visitors you choose to ignore
  • analytics:prune for retention (395 days by default) and a dashboard gated to the local environment until you define who may see it
The dark-mode Analytics dashboard for 17 to 23 Oct 2026, labelled first-party, no cookies, with range buttons from Today to 12 months and a custom date range: 41 unique visitors, 42 visits, 89 pageviews, 31% bounce rate, 1s average visit and 2 events; a visits-per-day bar chart at 2.1 pages per visit; top pages and entry pages under /shop; channels led by Direct, Social, Organic search and Email; referrers duckduckgo.com, laravel-news.com and reddit.com; UTM sources newsletter, twitter, google and reddit; and a countries panel reading no country header seen.
The dark-mode Analytics dashboard for 17 to 23 Oct 2026, labelled first-party, no cookies, with range buttons from Today to 12 months and a custom date range: 41 unique visitors, 42 visits, 89 pageviews, 31% bounce rate, 1s average visit and 2 events; a visits-per-day bar chart at 2.1 pages per visit; top pages and entry pages under /shop; channels led by Direct, Social, Organic search and Email; referrers duckduckgo.com, laravel-news.com and reddit.com; UTM sources newsletter, twitter, google and reddit; and a countries panel reading no country header seen.

Install

Requires PHP 8.3+ and Laravel 12 and 13.

composer require amjadiqbal/laravel-first-party-analytics

Usage

use AmjadIqbal\FirstPartyAnalytics\Facades\Analytics;

// Inside a request: joins the visitor's current visit.
Analytics::track('signup', ['plan' => 'pro']);

// From a job, a command or a webhook handler: stored for the user, without a visit.
Analytics::track('invoice_paid', ['amount' => 49], $user);

Setup

Install the package and run the migration, which adds two tables: analytics_visits and analytics_events (names and database connection are configurable). From then on the TrackVisits middleware, added to the web group automatically, records pages your web routes serve. Set auto_track to false to add the analytics.track middleware alias only to the routes you choose. The dashboard is at /analytics and is open in the local environment only until you define its gate.

  1. 1 composer require amjadiqbal/laravel-first-party-analytics
  2. 2 php artisan migrate
  3. 3 Optionally publish the config: php artisan vendor:publish --tag=first-party-analytics-config
  4. 4 Define the viewFirstPartyAnalytics gate, then open /analytics.

What is recorded

A pageview is a GET request answered with a 2xx HTML response. Not recorded: other methods, redirects and errors, JSON and other non-HTML responses, AJAX and Livewire requests, prefetches (Purpose, Sec-Purpose or X-Moz headers), the dashboard and event endpoint, and the paths in exclude_paths (admin*, livewire/*, telescope*, horizon*, pulse*, nova*, filament*, _debugbar/*, sanctum/*, broadcasting/*, up and more). A new visit stores the landing path without its query string, the referrer from another site without its query string and its host, the UTM tags (or ?ref= as the source), browser, OS and device type parsed from the user agent (the user agent itself is not stored), a two-letter country only if your CDN sends a header such as CF-IPCountry, the signed-in user's id, and the IP as the ip option says. Returning from hosts in ignore_referrers (Stripe Checkout, PayPal, Google and Apple sign-in by default) continues the visit.

Privacy options

With cookies off (the default) no cookie is set. The visitor id is an HMAC-SHA256 of the UTC date, IP, user agent and host, keyed with your APP_KEY, so the same person gets a new id every day and unique visitors are unique per day; the visit itself is kept together in the cache. Separately, the visits table has an ip column: the default ip mode, anonymize, stores the address with the last IPv4 octet or last 80 IPv6 bits zeroed; none stores no IP and full stores it as is. The IP is also used, unstored, as the rate-limit key for the browser event endpoint. Browsers sending DNT: 1 or Sec-GPC: 1 are not recorded at all (respect_dnt and respect_gpc). Whether you need consent depends on where you operate and the options you choose; the package gives you the settings, not legal advice.

// config/first-party-analytics.php
'cookies' => env('ANALYTICS_COOKIES', false),
'ip' => 'none',            // 'anonymize' (default), 'none' or 'full'
'respect_dnt' => true,
'respect_gpc' => true,

// Skip your own team, from a service provider:
Analytics::ignoreUsing(fn (Request $request) => $request->user()?->is_staff === true);

Cookie mode and signed-in users

With cookies on, the package sets an fpa_visitor cookie (365 days, visitor_days, renewed on each tracked request) and an fpa_visit cookie (visit_minutes, 30 by default). Both are first-party, HttpOnly and SameSite=Lax, and Secure on HTTPS requests. There is no built-in consent prompt: cookie mode is a config switch, so turning it on behind your own consent flow is up to you. When someone signs in (Login) or registers (Registered), or you call Analytics::identify($user), the current visit and its events are linked to them; any tracked request from a signed-in user links the visit too. In cookie mode the same visitor's earlier anonymous visits are linked as well, so a signup can be traced to the campaign that first brought them; without cookies only the current visit is linked.

Analytics::identify($user);

Events from the browser

Put @firstPartyAnalytics in your layout. It renders a small inline script (with Vite's CSP nonce when one is set) that defines fpa.track(), and analytics.track() when no other script already uses window.analytics, and sends a click on any element with data-analytics-event, with its other data-analytics-* attributes as properties. Events go by sendBeacon (fetch as a fallback) to POST /fpa/event (js.endpoint). The route skips CSRF, so instead it answers 403 unless the Origin header, or the Referer when there is no Origin, has your site's host; it is rate limited to 120 requests a minute per IP (js.rate_limit), and otherwise always answers 204. Names must be 1 to 100 characters, must not start with $ (reserved for pageviews) and, if you set js.allowed_events, must be in that list. For PHP and browser events alike, properties keep at most 25 keys (cut to 40 characters) with scalar values, strings cut to 255.

@firstPartyAnalytics

<a href="/pricing" data-analytics-event="cta_clicked" data-analytics-button="hero">See pricing</a>

<script>analytics.track('video_played', { video: 'intro' });</script>

Conversions and funnels

List goal event names in conversions: the dashboard shows how many visits reached each one, how many distinct users, and the rate against visits started in the range. A funnel is a list of ordered steps, each an event name or page:/path for a pageview (page:/blog/* matches a prefix). A visit counts for a step only after it completed the step before it, in the same visit; there is no separate time window beyond the visit and the selected date range. Each step shows its share of the first step and its drop from the previous one.

// config/first-party-analytics.php
'conversions' => ['signup', 'purchase'],

'funnels' => [
    'Checkout' => ['page:/pricing', 'checkout_started', 'purchase'],
    'Blog to signup' => ['page:/blog/*', 'signup'],
],

The dashboard and who can open it

The dashboard (dashboard.path, default /analytics) shows, for today, 7, 30 or 90 days, 12 months or a custom range of up to 400 days: unique visitors, visits, pageviews, bounce rate, average visit duration and events; visits per day; top and entry pages; channels, referrers and UTM source, medium and campaign; countries, devices, browsers and operating systems; custom events with their most common property values; conversions and funnels. Bounce rate counts visits with at most one pageview, and visit duration is the time between a visit's first and last recorded event. Every load runs live aggregate queries; nothing is cached. It is plain Blade with inline CSS and follows the system light or dark mode. Access goes through the viewFirstPartyAnalytics gate, checked after the dashboard middleware (web by default), so an empty middleware list does not open it.

use Illuminate\Support\Facades\Gate;

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

Writes and retention

Everything a request records (a new visit, the pageview, events, user links) is collected and written in one batch. write: after_response (default) writes after the response has been sent; queue dispatches one RecordAnalytics job per request on queue.connection and queue.queue; sync writes at once. Console commands and queue jobs write at once (or queue). A failed write is reported to your exception handler and never breaks the page. Old data is not removed on its own: schedule analytics:prune, which deletes visits and events older than retention_days (395 by default; null keeps everything) in chunks of 1,000 rows.

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('analytics:prune')->daily();

// php artisan analytics:prune --days=90
// php artisan analytics:prune --pretend   (count only)

Things to know

  • Queue write mode has not been tested on a real queue worker; the test suite only checks that the job is dispatched and applied.
  • Without cookies the visitor id changes at midnight UTC, so a visit that runs past it is counted as two visits from two visitors.
  • Bot filtering uses jaybizzle/crawler-detect and also drops requests with no user agent. Bind your own AmjadIqbal\FirstPartyAnalytics\Contracts\DetectsBots to replace it.
  • PHP events inside a request are skipped for bots, Do-Not-Track, GPC and ignored visitors; events tracked from jobs and commands are stored for the given user without a visit.
  • The local CI runs PHP 8.3, 8.4 and 8.5 on Laravel 12 and 13, plus MySQL 8.4 and PostgreSQL 17; the in-browser test of a real app used SQLite.
  • php artisan about shows the version, whether tracking is on (with or without cookies) and the dashboard path.

Hire me

Need this built into your app, or something custom?

I wrote Laravel First-Party Analytics. I can wire it into your Laravel app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

More Laravel packages

All packages
Laravel Driver.js: Build Driver.js product tours in PHP and render them with one Blade component.

Laravel Driver.js

Build Driver.js product tours in PHP and render them with one Blade component.

Laravel Sonner: Sonner toast notifications for Laravel, sent from PHP and shown without React.

Laravel Sonner

Sonner toast notifications for Laravel, sent from PHP and shown without React.

Laravel Ninja Keys: A Cmd+K / Ctrl+K command palette for Laravel, with the actions defined in PHP.

Laravel Ninja Keys

A Cmd+K / Ctrl+K command palette for Laravel, with the actions defined in PHP.

Laravel SweetAlert2: SweetAlert2 popups, toasts and confirm dialogs from Laravel controllers, Livewire and Alpine, plus styled wire:confirm and dialogs you can await.

Laravel SweetAlert2

SweetAlert2 popups, toasts and confirm dialogs from Laravel controllers, Livewire and Alpine, plus styled wire:confirm and dialogs you can await.

Laravel Grid.js: Turn any Eloquent query into a searchable, sortable, paginated Grid.js data table with one fluent PHP call.

Laravel Grid.js

Turn any Eloquent query into a searchable, sortable, paginated Grid.js data table with one fluent PHP call.

Laralink: Work on a Composer package inside the Laravel app that uses it, then switch back to the Packagist release.

Laralink

Work on a Composer package inside the Laravel app that uses it, then switch back to the Packagist release.

Refresher: A drop-in replacement for Laravel's RefreshDatabase that restores a cached snapshot of your test database instead of running migrate:fresh.

Refresher

A drop-in replacement for Laravel's RefreshDatabase that restores a cached snapshot of your test database instead of running migrate:fresh.

Kiln: Artisan commands and an optional signed HTTP route to clear, warm and inspect OPcache when you deploy.

Kiln

Artisan commands and an optional signed HTTP route to clear, warm and inspect OPcache when you deploy.

Laravel Media Viewer: Show private PDFs, video and audio from any Laravel disk without making the files public.

Laravel Media Viewer

Show private PDFs, video and audio from any Laravel disk without making the files public.

Laravel Swiper: Swiper sliders, carousels and image galleries for Laravel as Blade components.

Laravel Swiper

Swiper sliders, carousels and image galleries for Laravel as Blade components.

Laravel MySQL Advisor: Read-only health checks for the MySQL or MariaDB database behind your Laravel app, with the SQL and the Laravel migration to fix a problem wherever a fix applies.

Laravel MySQL Advisor

Read-only health checks for the MySQL or MariaDB database behind your Laravel app, with the SQL and the Laravel migration to fix a problem wherever a fix applies.

Laravel Webhook Console: A dashboard and replay tool for the webhooks spatie/laravel-webhook-client stores in your Laravel app.

Laravel Webhook Console

A dashboard and replay tool for the webhooks spatie/laravel-webhook-client stores in your Laravel app.

Laravel Pennant Dashboard: A browser dashboard for laravel/pennant feature flags: per-scope toggles, sticky percentage rollouts, purge and an audit log.

Laravel Pennant Dashboard

A browser dashboard for laravel/pennant feature flags: per-scope toggles, sticky percentage rollouts, purge and an audit log.

Laravel Notification Inbox: Catches every non-mail Laravel notification in local and staging instead of sending it, and shows each one as it would look on the other end.

Laravel Notification Inbox

Catches every non-mail Laravel notification in local and staging instead of sending it, and shows each one as it would look on the other end.

Laravel WebPush Kit: The browser side of Laravel web push: a subscribe button with a two-step permission prompt, a service worker, and routes that save each user's subscription. Tested in Chrome so far.

Laravel WebPush Kit

The browser side of Laravel web push: a subscribe button with a two-step permission prompt, a service worker, and routes that save each user's subscription. Tested in Chrome so far.

Laravel Redis Advisor: Read-only health checks for the Redis or Valkey behind your Laravel cache, queues, sessions and Horizon, with the redis.conf line or Laravel config that fixes each problem.

Laravel Redis Advisor

Read-only health checks for the Redis or Valkey behind your Laravel cache, queues, sessions and Horizon, with the redis.conf line or Laravel config that fixes each problem.

Need something built?

Free quote in 60 seconds