protonemedia/laravel-analytics-event-tracking
Laravel Analytics Event Tracking lets you track user events and page views in your Laravel app, storing them locally or sending them to Google Analytics. Simple configuration, middleware support, and a clean API for logging custom events.
1. Install and publish (assumes Laravel 10 / PHP 8.1+).
composer require protonemedia/laravel-analytics-event-tracking
php artisan vendor:publish --tag=analytics-event-tracking-config
The package is auto-discovered. Everything you need is in two places: the published config/analytics-event-tracking.php and the Analytics facade. Read those before reading the README — the config is the documentation for which drivers are wired and how event names get mapped.
2. Set your tracking ID(s) per environment.
ANALYTICS_EVENT_TRACKING_GOOGLE_ANALYTICS_TRACKING_ID=G-XXXXXXXXXX
ANALYTICS_EVENT_TRACKING_FACEBOOK_PIXEL_ID=...
Keep these out of config/app.php and out of anything that gets committed. Use a distinct property (or a no-op driver) locally so dev traffic never pollutes production reports. Set APP_ENV-scoped values, not hardcoded defaults.
3. Send your first event from the server, not the browser.
use ProtoneMedia\AnalyticsEventTracking\Facades\Analytics;
Analytics::send('signup', [
'method' => 'google',
'plan' => 'trial',
]);
4. Immediately verify it actually lands. This is the single most important step. The Measurement Protocol accepts requests and silently discards invalid payloads — HTTP 200 with nothing recorded is the normal failure mode. Do not trust a green response. Open GA4 Realtime against a live property, fire one event from php artisan tinker, and confirm you see it. If you don't, stop and inspect the emitted payload (see Gotchas) before writing another line of code.
5. First useful use case: backend-confirmed conversions — signup, trial_started, purchase, subscription_cancelled. These are the events where client-side gtag loses data to ad blockers and consent rejections, and where the server already knows the ground truth. That's the payoff; wire one of those first, not pageviews.
Fire from a domain/action seam, never from a controller or Blade. Put analytics dispatch behind the point where the business fact becomes true:
class StartTrial
{
public function __invoke(User $user): Subscription
{
$subscription = $user->subscriptions()->create([...]);
Analytics::send('trial_started', [
'plan' => $subscription->plan,
'trial_days' => $subscription->trialDays(),
]);
return $subscription;
}
}
Better still, dispatch from a Laravel event listener so the tracking concern never mixes with the write. Either way, the call site should read like a business statement, not an HTTP call.
Let the queue absorb it — and be deliberate about when it flushes.
Analytics::queue('purchase', [
'value' => 49.00,
'currency' => 'EUR',
]);
Two rules. First, if you queue from inside a DB transaction, dispatch after commit (Laravel's afterCommit or a listener registered post-commit) — otherwise a rolled-back order still shows up in analytics. Second, understand that queueing trades latency for delivery risk: a missing worker or a full queue means silently missing events, so monitor the queue like you'd monitor a payment job.
Use the config's event mapping instead of branching in code. Define the canonical name once and let the driver do the translation:
// config/analytics-event-tracking.php
'events' => [
'trial_started' => [
'google_analytics' => 'begin_trial',
'facebook_pixel' => 'StartTrial',
],
],
This is what keeps your event taxonomy reviewable in a pull request rather than scattered across the codebase — arguably the biggest day-to-day leverage the package offers.
Build a thin internal gateway over the facade. The facade is convenient but it's the thing you may have to replace, so don't let it leak into 90 call sites:
final class AnalyticsGateway
{
public function trialStarted(Subscription $s): void
{
Analytics::queue('trial_started', $this->payload($s));
}
private function payload(Subscription $s): array
{
return array_intersect_key([...], array_flip(['plan', 'trial_days']));
}
}
The array_intersect_key allow-list is the pattern that matters: construct payloads explicitly, never spread a model. $user->toArray() is how email addresses and names end up in third-party analytics.
Test it with a fake, and assert in CI.
Analytics::fake();
$action->handle($user);
Analytics::assertSent('trial_started');
Analytics::assertNotSent('purchase');
The payload-allow-list pattern above combined with a fake turns "we do not send PII" into a testable claim. This is the strongest argument for adopting the package's shape even if you end up rewriting the internals.
Server-side deduplication where it matters. If you also fire the same event client-side, send a shared event_id from both paths so GA4 can dedupe. Doing this intentionally is the difference between "we added server-side tracking" and "we doubled our conversion count."
Which events to route server-side:
| Event | Server-side? | Why |
|---|---|---|
purchase / refund |
Yes | Backend-confirmed; refunds often never touch the browser |
trial_started, subscription_cancelled |
Yes | Churn measurement is a decision input; must be complete |
webhook_received, job_completed |
Yes | No browser exists at all |
page_view |
No | The browser is genuinely the authority here |
| Anything with PII in params | No | Allow-list it out before it leaves your server |
The repository is archived — this is the defining constraint. Last release February 2023, zero dependents, no upstream fixes, no Laravel 11/12 support, no security advisories. Treat it as fork-or-reference, not depend. If you adopt: fork to your own namespace, vendor it, pin the exact version, name an owner, and put a dated "replace this" entry in the roadmap. Do not let it sit in composer.json looking like a normal dependency.
Verify GA4 before anything else. The 1.6.0 baseline predates the wide GA4 migration and the July 2023 Universal Analytics sunset. GA4 uses Measurement Protocol v2, whose payload shape differs from UA's v1. Check for a G- prefixed measurement ID in the config rather than a UA- tracking ID, fire one event, and confirm it appears in the GA4 Realtime report. If it emits UA-shaped payloads, the package is functionally dead and no amount of code quality saves it — GA4 will accept the HTTP request and drop the event without an error. This check takes ten minutes and decides everything else.
Silent discard is the failure mode you must instrument. The Measurement Protocol returns 2xx for payloads it rejects. Add logging around the dispatch, alert on non-2xx, and — during rollout — set GA4's debug_mode parameter so events show up in DebugView where you can see validation errors instead of guessing.
Client ID is the classic GA4 footgun. Server-side events need a client_id; without one, sessions don't stitch and attribution breaks in ways that look like "the event didn't arrive." Decide deliberately: persist a client ID from the browser onto the user session and pass it through, or accept a synthetic per-user ID and lose cross-session stitching. Either is defensible; stumbling into one accidentally is not.
Retries double-count. Queued jobs retry. The Measurement Protocol is not idempotent by default. Set sensible tries/backoff on the job and, for high-value events like purchases, send a stable event_id so a retry can be deduplicated. Otherwise your revenue number drifts up whenever the network hiccups.
Server-side calls do not respect consent mode. You are bypassing the browser entirely, which means the gating the frontend does for you is now your job. Gate dispatch on whatever your consent record says, and keep the PII allow-list at the gateway. This is the one area where "we send it from the backend" quietly creates regulatory exposure rather than removing it.
Config caching and per-environment drivers. The config is cached with php artisan config:cache, so env-var changes need a rebuild. Set the driver to a null/log implementation in local and test environments — and in CI, use Analytics::fake() rather than a null driver so assertions still work.
Payload limits are real. GA4 caps event names at 40 characters, parameter names at 40, and parameter values at 100, with 25 parameters per event. Overlong values are truncated or dropped. Enforce this in your gateway, not in your head.
Extension point: custom drivers. If you need Segment/PostHog/self-hosted plumbing, the driver contract is the seam — implement it, register it in config, and keep call sites untouched. Given the archive status, this is also your realistic replacement path for GA itself: swap the driver, keep the facade and the event taxonomy.
Two smaller things worth knowing. The description advertises a Facebook Pixel driver alongside Google Analytics, but Pixel is fundamentally a browser construct — confirm whether it's a Conversions API call or something that expects a browser context before relying on it. And because 1.6.0 shipped a PHP-floor break as a minor version, treat any pinned version as terminal rather than assuming patch-level safety.
How can I help you explore Laravel packages today?