panphp/pan
Pan is a lightweight, privacy-focused analytics package for Laravel. Track page views and custom events directly in your own database, with no cookies, JavaScript bloat, or third-party services required.
Pan is a Laravel package that captures product-usage events and stores them in your database. The pitch is a first-party, cookie-free analytics layer — the practical reality is that you install a package, publish a config and migration, inject a tiny same-origin script into your layout, and then annotate Blade elements or call the facade.
Install and wire it up
composer require panphp/pan
php artisan vendor:publish --tag="pan-config"
php artisan vendor:publish --tag="pan-migrations"
php artisan migrate
Inject the tracker in your primary layout <head>:
{{-- resources/views/layouts/app.blade.php --}}
<head>
...
@pan
</head>
Verify the write path end-to-end before doing anything else — visit a page in an allowed environment and query the table:
DB::table('pan_page_views')->latest()->limit(5)->get();
If that returns nothing, stop and fix it here. Everything downstream (dashboards, funnels, retention jobs) assumes this path works.
Where to look first
config/pan.php — the enabled flag, allowed_environments, and route/host exclusions. Roughly 90% of "why isn't it recording?" answers live in this one file.Pan facade — your entry point for custom, server-side events.First real use case
Pick one product question and instrument only what answers it. The canonical starter: "how many page views does it take before someone registers?"
// app/Actions/RegisterUser.php
use Pan\Pan;
public function handle(array $data): User
{
$user = User::create($data);
Pan::track('signup', [
'plan' => $data['plan'],
]);
return $user;
}
Automatic page views plus one custom signup event gives you a funnel without a vendor, without a consent banner, and without a data pipeline. Resist the urge to instrument twenty things on day one — the cost is never wiring, it's taxonomy debt.
Attribute-based tracking for UI elements. The idiomatic path is data-pan attributes on Blade elements rather than imperative JS:
<button data-pan="cta-upgrade">Upgrade</button>
<a href="/pricing" data-pan="nav-pricing">Pricing</a>
This is what makes Pan cheap to adopt — impressions and clicks on decision-relevant elements are declarative. Keep the attribute namespace consistent (nav-*, cta-*, form-*) or you'll be reconciling string variants in SQL six weeks from now.
Server-side events at domain boundaries. Don't sprinkle Pan::track() through controllers. Put it where the business event actually happens — an action class, an observer, or a domain event listener:
// app/Listeners/TrackProjectCreated.php
public function handle(ProjectCreated $event): void
{
Pan::track('project_created', [
'template' => $event->project->template,
'is_first' => $event->user->projects()->count() === 1,
]);
}
This keeps tracking tied to real outcomes rather than HTTP verbs, survives refactors, and is trivially testable (Event::assertDispatched / Http::fake-free assertions).
Instrument a funnel, not a page. The pattern that produces decisions: define 5–10 named events in a config/pan-events.php or a TrackingEvents enum, document what each means, and cap it. signup → first_project → invite_teammate answers a roadmap question; user_hovered_button does not.
Guard noisy routes in config. Admin panels, health checks, /horizon, asset routes, and anything with high cardinality in the URL will flood the table. Use the config's route exclusions rather than adding Pan:: conditionals in code.
Queue the writes if volume matters. If the package allows deferring the write, do it — analytics writes should never be in the request path of a user-facing response. If it doesn't, isolate the cost another way: a dedicated database connection, or sampling on high-traffic pages.
Build your own read layer. Pan is an event-capture primitive, not a BI tool. Plan for a Blade admin dashboard, or point Metabase/Grafana at the table. Pre-aggregate into daily rollup tables on a schedule rather than letting a dashboard issue COUNT(*) ... GROUP BY DATE(created_at) against a growing table.
"Server-side" in the description is doing heavy lifting. The package's copy emphasises no third-party scripts — but in practice @pan injects a small first-party script and events are emitted from the browser. The difference is that it's same-origin and the data lands in your DB, not that no request is made. This matters for CSP headers, for ad-blocker exposure, and for how you explain it internally. Read the snippet before you repeat the marketing claim.
Silent failure is the default failure. Client-side beacons are blocked by ad-blockers, dropped by CSP violations, swallowed on non-2xx responses, and undercounted in SPAs. You will not be alerted when tracking stops. Cheapest insurance: emit a synthetic canary event on a scheduled command and assert daily that expected counts are non-zero — alert on deviation or on a flat zero.
SPA / Inertia / Livewire / Turbo undercounts. The automatic page-view tracking is coupled to full-page loads. Client-side navigation won't fire it. You must emit events explicitly from JS on route change, or accept that your numbers are wrong for those surfaces. Validate this in a staging spike before committing to the package.
enabled and allowed_environments first, always. When nothing records, the answer is nearly always in config/pan.php — not in your Blade. Check the flag, the environment allowlist, and the host allowlist before you start debugging the frontend.
Raw rows vs. counters drives your scaling plan. If the published migration stores raw event rows, retroactive analysis is possible but you own retention: add a pruning command and, under real volume, partitioning — before traffic arrives, not after. If it stores incrementing counters, the table stays tiny but you can never re-cut yesterday's data. Know which one you published.
No backfill. Your history starts the day you migrate. If trend lines matter, sequence the cutover and consider dual-running the same 5–10 events against your existing tool for a quarter (expect 5–15% divergence from ad-blocking and bot filtering).
Multi-instance deploys. A published migration means migration collisions in rolling deploys — the standard Laravel rules apply, and php artisan migrate --force in a pre-deploy step. Also watch the write path under Octane: no long-lived state should be assumed.
Pin the version and own the fork. MIT, community-maintained, modest maintainer footprint. Publish the config and migration, treat the schema as your own, and make upgrades deliberate reviewed changes rather than composer update side effects. Designate one engineer as owner and keep a runbook: event naming rules, pruning schedule, upgrade procedure.
Extension points to reach for. The config exclusions, the Pan facade for custom events, and your own rollup/pruning commands cover nearly every real need. If you find yourself wanting to change the tracking payload itself, that's a fork decision — flag it early rather than patching in vendor/.
How can I help you explore Laravel packages today?