chrickell / laraprints
Track page views and element clicks in Laravel applications. Ships with a Vue 3 + Tailwind dashboard component.
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
- geoip2/geoip2: Alternative geo driver for MaxMind GeoLite2 (analytics.geo_driver=maxmind)
- hisorange/browser-detect: Alternative user agent parser (analytics.ua_parser=hisorange)
- jenssegers/agent: Required for user agent parsing (analytics.ua_parser=jenssegers)
- stevebauman/location: Required for IP geolocation (analytics.geo_driver=stevebauman)
README
Track page views and element clicks across your Laravel application. Stores data asynchronously via queued jobs, and ships with a ready-to-use Vue 3 dashboard component you can drop into any Inertia or Vue-powered page.
What it does
- Page view tracking — Middleware captures every request (or only those you choose) and stores path, referrer, query params, device type, session ID, and optional user ID.
- Click tracking — A lightweight frontend composable attaches a global click listener and posts events to a built-in API endpoint.
- Analytics dashboard — Two JSON endpoints aggregate your data by date range. A self-contained Vue 3 UI with date range filtering, trend charts, sortable tables, and device breakdowns — publish it and embed it into any page in your app.
- Horizon-style authorization — Access to the dashboard is gated by a
viewLaraprintsgate. Publish a one-file service provider, add your email addresses, done. - Subdomain / multi-app tracking — Point any subdomain app at the primary app's database via a named connection so all analytics land in one place and the dashboard shows a unified view.
- Async by design — Both page view and click jobs implement
ShouldQueue. Nothing blocks the response. - Auto-pruning —
MassPrunableon both models. Schedulemodel:pruneand configure a retention window.
Requirements
| PHP | 8.1+ |
| Laravel | 10, 11, or 12 |
| Frontend scaffold | Laravel + Vue/Inertia starter kit |
| Queue | Redis (via queue:work or Laravel Horizon) |
Frontend scaffold
The dashboard component and click tracking composable are built for the Vue 3 + Inertia Laravel starter kit. This is the scaffold you get when you select Vue during laravel new. React and Livewire scaffold support is planned for a future release.
The backend — middleware, API endpoints, models, and queue jobs — works with any frontend or no frontend at all.
Queue
A real queue driver is required in production. Jobs are dispatched asynchronously and will not run without a worker.
Using queue:work on Laravel Forge:
Set QUEUE_CONNECTION=redis in your .env, then configure a queue worker daemon in Forge under your site's Queue tab.
Using Laravel Horizon:
Horizon gives you a UI for monitoring your queues and is the recommended approach for Redis. Install it separately:
composer require laravel/horizon php artisan horizon:install
Then run Horizon as a daemon process on Forge (or your server) instead of queue:work.
Local development:
Set QUEUE_CONNECTION=sync in your .env to process jobs immediately without a worker running.
Installation
composer require chrickell/laraprints php artisan laraprints:install
The install command publishes the authorization provider, config file, and Vue components, then runs migrations. The service provider is auto-discovered.
Quickstart
After running laraprints:install, three things remain:
# 1. Add your email to the published authorization provider # app/Providers/LaraprintsServiceProvider.php → $emails = ['you@example.com'] # 2. Add the tracking middleware to the routes you want to track # routes/web.php → Route::middleware('track.requests')->group(...) # 3. Set up click tracking in your frontend JS entry point # setupClickTracking({ inertia: true })
That's it. The AnalyticsDashboard Vue component is published to resources/js/vendor/laraprints/ and ready to embed in any Inertia page.
Install command flags
| Flag | Description |
|---|---|
--no-migrate |
Skip running migrations (useful if you manage migrations separately) |
--no-components |
Skip publishing Vue components (backend-only installs) |
# Backend only, no frontend components, no migrations
php artisan laraprints:install --no-migrate --no-components
Manual publishing (if needed)
php artisan vendor:publish --tag=laraprints-provider # authorization gate php artisan vendor:publish --tag=laraprints-config # config file php artisan vendor:publish --tag=laraprints-components # Vue components php artisan vendor:publish --tag=laraprints-migrations # migrations only
Page View Tracking
The TrackPageViews middleware intercepts incoming requests and dispatches a queued StorePageView job. Nothing is written synchronously — the response is not affected.
Register the middleware
Option A — on specific route groups (recommended):
// routes/web.php Route::middleware('track.requests')->group(function () { Route::get('/', HomeController::class); Route::get('/about', AboutController::class); // only these routes are tracked });
Option B — globally via bootstrap/app.php (Laravel 11+):
->withMiddleware(function (Middleware $middleware) { $middleware->append(\Chrickell\Laraprints\Http\Middleware\TrackPageViews::class); })
Option C — automatically on boot:
Set requests.auto_register_middleware => true in config/laraprints.php and the middleware is pushed onto the global stack automatically when the package boots.
What gets recorded
Each page view stores:
| Column | Description |
|---|---|
domain |
Hostname of the request (e.g. example.com) — useful when tracking multiple apps into one database |
session_id |
Laravel session ID |
visit_id |
UUID, persisted in session across requests (groups a browsing session) |
user_id |
Authenticated user's ID (nullable, omitted when store_user_id is false) |
device_type |
desktop, mobile, or unknown (derived from User-Agent) |
country_code |
Two-letter country code (nullable) |
method |
HTTP method |
current_path |
Request path |
current_params |
URL query parameters as JSON (nullable) |
referrer_path |
Referring URL path (nullable) |
referrer_params |
Referrer query params as JSON (nullable) |
viewed_at |
Timestamp |
Configuration — requests section
'requests' => [ 'enabled' => true, 'auto_register_middleware' => false, 'methods' => ['GET'], 'track_page_views' => true, 'excluded_paths' => ['api/*', '_debugbar/*', 'livewire/*', 'telescope/*', 'horizon/*'], 'exclude_assets' => true, 'ignore_bots' => true, 'only_authenticated' => false, 'exclude_admins' => false, 'store_user_id' => true, 'store_referrer' => true, 'store_params' => true, 'session_key' => 'laraprints_visit_id', ],
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master toggle. Set false to disable all request tracking without removing middleware |
auto_register_middleware |
false |
Push middleware onto the global stack automatically on boot |
methods |
['GET'] |
HTTP methods to track. Add 'POST' etc. to capture form submissions |
track_page_views |
true |
Whether to persist to the database. Set false to run the middleware without storing |
excluded_paths |
see above | Paths that are never tracked. Supports * wildcards |
exclude_assets |
true |
Skip requests for static assets (images, CSS, JS, fonts) based on file extension |
ignore_bots |
true |
Skip requests from known bots and crawlers |
only_authenticated |
false |
Only track logged-in users. Guest visits are silently skipped |
exclude_admins |
false |
Skip admin users. true checks $user->is_admin. Pass a callable for custom logic |
store_user_id |
true |
Store the authenticated user's ID. Set false for anonymized analytics |
store_referrer |
true |
Capture and store the referrer URL path and query params |
store_params |
true |
Capture and store the current URL's query params |
session_key |
'laraprints_visit_id' |
The session key used to persist the per-visit UUID |
exclude_admins examples:
// Checks $user->is_admin attribute 'exclude_admins' => true, // Custom callable — works with any role/permission system 'exclude_admins' => fn ($user) => $user->hasRole('admin'), 'exclude_admins' => fn ($user) => $user->role === 'superuser',
Click Tracking
Clicks are tracked from the frontend via a lightweight JavaScript composable. It attaches a global listener to the document, detects interactions with buttons, links, inputs, and elements with data-event-click, and posts the event to a built-in API endpoint.
Endpoint
POST /api/clicks
Rate limited to 60 requests per minute per IP. The path is configurable under clicks.route.
Frontend setup
The JS composable is published by laraprints:install. Call setupClickTracking() once when your Vue app mounts — typically in app.js or app.ts:
import { setupClickTracking } from '@/vendor/laraprints/composables/useAnalyticsTracking' setupClickTracking()
With Inertia
When using Inertia, share the tracking IDs from the server so that server-side page view records and client-side click records can be correlated by the same session_id and visit_id:
In HandleInertiaRequests.php:
public function share(Request $request): array { return array_merge(parent::share($request), [ 'tracking_session_id' => $request->session()->getId(), 'tracking_visit_id' => $request->session()->get( config('laraprints.requests.session_key', 'laraprints_visit_id') ), ]); }
Then call with the inertia flag:
setupClickTracking({ inertia: true })
When inertia: true, the composable reads tracking_session_id and tracking_visit_id from Inertia's shared page props instead of generating its own IDs from sessionStorage.
What gets recorded
Each click event stores:
| Column | Description |
|---|---|
domain |
Hostname of the request — useful when tracking multiple apps into one database |
session_id |
Session ID shared with the server-side page view record |
visit_id |
Visit UUID shared with the server-side page view record |
user_id |
Authenticated user's ID (nullable, omitted when store_user_id is false) |
element |
HTML tag name of the clicked element (e.g. button, a) |
element_class |
CSS classes on the element (nullable) |
element_id |
id attribute of the element (nullable) |
element_style |
Inline style attribute (nullable) |
path |
Page path where the click occurred |
clicked_at |
Timestamp |
Configuration — clicks section
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master toggle. Setting false also prevents the click route from being registered |
route |
'/api/clicks' |
Endpoint path. Change this if it conflicts with your own routes |
only_authenticated |
false |
Return 401 for unauthenticated click submissions |
store_user_id |
true |
Store the authenticated user's ID on each click |
Analytics Dashboard
The dashboard is a Vue 3 component — it is not a standalone page hosted by this package. You embed it into an Inertia page (or any Vue-mounted Blade view) in your own application. The package registers the JSON API endpoints that power it.
Vue + Inertia only (for now). The dashboard component requires the Vue 3 + Inertia Laravel starter kit. React and Livewire scaffold support is coming in a future release. The backend API endpoints work with any frontend.
Dashboard API endpoints
These are registered automatically when dashboard.enabled is true. Both require the viewLaraprints gate.
GET /laraprints/page-views?start=YYYY-MM-DD&end=YYYY-MM-DD
GET /laraprints/clicks?start=YYYY-MM-DD&end=YYYY-MM-DD
Both start and end are optional. The default range is the last 30 days.
Embedding the dashboard
The AnalyticsDashboard component is published to resources/js/vendor/laraprints/ by laraprints:install.
Inertia page (resources/js/Pages/Analytics.vue):
<script setup> import AnalyticsDashboard from '@/vendor/laraprints/components/AnalyticsDashboard.vue' </script> <template> <AnalyticsDashboard /> </template>
Blade with a Vue mount point:
<div id="laraprints-app"></div> @vite(['resources/js/laraprints.js'])
// resources/js/laraprints.js import { createApp } from 'vue' import AnalyticsDashboard from '@/vendor/laraprints/components/AnalyticsDashboard.vue' createApp(AnalyticsDashboard).mount('#laraprints-app')
Protecting the route
// routes/web.php Route::get('/laraprints', fn () => inertia('Laraprints')) ->middleware(['auth', 'can:viewLaraprints']) ->name('laraprints');
AnalyticsDashboard props
| Prop | Default | Description |
|---|---|---|
baseUrl |
'/laraprints' |
The URL prefix for the data API. Match this to dashboard.route_prefix in your config if changed |
<AnalyticsDashboard base-url="/my-custom-prefix" />
Dashboard features
- Date range selector — Today, last 7 days, last 30 days, last 90 days, or a custom date range
- Stat cards — total page views, unique sessions, total clicks, mobile traffic percentage
- Page Views tab:
- Trend chart (views over time)
- Sortable top-25 pages table with desktop/mobile split
- Device breakdown with proportion bars (desktop / mobile / unknown)
- Top-15 referrers table
- Clicks tab:
- Trend chart (clicks over time)
- Top-25 clicked pages
- Top-25 clicked elements with
<tag>,.class,#idshown
- Loading skeletons, error banners with retry buttons, and empty states throughout
Configuration — dashboard section
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master toggle. false prevents the API routes from being registered |
route_prefix |
'laraprints' |
URL prefix for the data endpoints |
middleware |
['web'] |
Middleware wrapping the routes (session, CSRF, etc.). The viewLaraprints gate is always enforced on top of this |
Direct model access
Both models are available for custom queries and integrations beyond the dashboard:
use Chrickell\Laraprints\Models\PageView; use Chrickell\Laraprints\Models\Click; // All views for a date range $views = PageView::byDateRange('2025-01-01', '2025-12-31')->get(); // Aggregated stats: path + device_type + count $stats = PageView::getAnalytics('2025-06-01', '2025-06-30'); // Raw click data for a specific page $clicks = Click::where('path', '/checkout')->latest('clicked_at')->get();
Authorization
Dashboard access is controlled by the viewLaraprints gate — the same pattern Laravel Horizon uses.
Publish the authorization provider
laraprints:install publishes this automatically. To publish manually:
php artisan vendor:publish --tag=laraprints-provider
This creates app/Providers/LaraprintsServiceProvider.php. The package auto-discovers and registers it on every boot — no manual step in config/app.php or bootstrap/providers.php required.
// app/Providers/LaraprintsServiceProvider.php class LaraprintsServiceProvider extends LaraprintsApplicationServiceProvider { protected array $emails = [ 'you@example.com', ]; protected function gate(): void { Gate::define('viewLaraprints', function ($user) { return in_array($user->email, $this->emails); }); } }
Add the email addresses of users who should have access to the $emails array. That's it.
Customizing the gate
The gate() method is yours to replace with any logic you need:
// Allow anyone in the local environment protected function gate(): void { Gate::define('viewLaraprints', function ($user) { return app()->environment('local') || in_array($user->email, $this->emails); }); } // Check a role via spatie/laravel-permission protected function gate(): void { Gate::define('viewLaraprints', function ($user) { return $user->hasRole('admin'); }); } // Check a model attribute protected function gate(): void { Gate::define('viewLaraprints', function ($user) { return $user->is_admin === true; }); }
Default behavior (before publishing)
If you haven't published the provider yet, a fallback gate is registered automatically:
- Local environment → access granted
- All other environments → access denied (403)
This means the dashboard works out of the box during local development, and is safely locked down in production until you publish the provider and configure it.
Subdomain / Multi-App Tracking
By default, Laraprints uses your application's default database connection. You can point it at any named connection from config/database.php:
'database' => [ 'connection' => 'mysql', // any connection name from config/database.php ],
A common pattern is running an admin subdomain (e.g. admin.example.com) as a separate Laravel installation while writing analytics to the primary application's database, so all data lands in one place and the dashboard shows a unified view.
Setup
-
In the primary app — run the Laraprints migrations as normal. They create the
page_viewsandclickstables. -
In your
config/database.phpon the subdomain app — add a named connection that points to the primary app's database:'connections' => [ // ... your other connections ... 'primary' => [ 'driver' => 'mysql', 'host' => env('PRIMARY_DB_HOST', '127.0.0.1'), 'database' => env('PRIMARY_DB_DATABASE', 'myapp'), 'username' => env('PRIMARY_DB_USERNAME', 'forge'), 'password' => env('PRIMARY_DB_PASSWORD', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'strict' => true, 'engine' => null, ], ],
-
In the subdomain app's
config/laraprints.php— set the connection name:'database' => [ 'connection' => 'primary', ],
-
Do not run Laraprints migrations on the subdomain app. The tables already exist on the primary connection and the subdomain app will write directly to them.
Queue Configuration
Both StorePageView and StoreClick implement ShouldQueue. A queue worker must be running in production:
php artisan queue:work
During local development, set QUEUE_CONNECTION=sync in your .env to process jobs immediately without a worker.
Configuration — queue section
| Key | Default | Description |
|---|---|---|
connection |
null |
Queue connection name ('redis', 'database', 'sqs', etc.). null uses the application default |
requests_queue |
null |
Queue name for page view jobs. null uses the default queue |
clicks_queue |
null |
Queue name for click jobs. null uses the default queue |
Data Pruning
Add model:prune to your scheduler to automatically delete old records:
// routes/console.php (Laravel 11+) Schedule::command('model:prune')->daily(); // app/Console/Kernel.php (Laravel 10) $schedule->command('model:prune')->daily();
Configure the retention window in config/laraprints.php:
'pruning' => [ 'page_views_after_days' => 90, // delete page_views older than 90 days 'clicks_after_days' => 90, // delete clicks older than 90 days ],
Both values default to null (keep forever). Set a value to enable pruning.
You can also prune manually:
php artisan model:prune --model="Chrickell\Laraprints\Models\PageView" php artisan model:prune --model="Chrickell\Laraprints\Models\Click"
Publishing Assets
| Tag | What it publishes |
|---|---|
laraprints-provider |
app/Providers/LaraprintsServiceProvider.php — authorization gate |
laraprints-config |
config/laraprints.php — all configuration options |
laraprints-migrations |
database/migrations/ — table definitions |
laraprints-components |
resources/js/vendor/laraprints/ — Vue components and composable |
# Publish everything at once php artisan vendor:publish --provider="Chrickell\Laraprints\LaraprintsServiceProvider"
Testing
composer test
License
MIT — see LICENSE.