happyr/event-tracker-bundle
HappyR EventTrackerBundle is a Symfony bundle for recording and managing application events. It integrates with Doctrine to persist tracked events and provides services for capturing user activity, logging actions, and building simple analytics.
First, the reality check: this is a Symfony bundle, not a Laravel package. No extra.laravel.providers, no Illuminate\Support\ServiceProvider, no facade. The 0.6.1 release adds exactly one thing — Symfony 5.0 support — which is a compatibility bump, not a Laravel feature. Four stars, zero dependents. Treat it as a reference implementation of a small tracker service that you wire up yourself — not as a dependency you require and forget.
Read these four files before writing any code (in this order):
composer.json — the real constraint list. With 0.6.1 the symfony/* constraints now admit ^4.3|^5.0, which implies a PHP 7.2.5 floor and rules out anything relying on the pre-4.3 dispatcher API. Confirm whether it hard-requires symfony/framework-bundle, doctrine/orm, or psr/log.DependencyInjection/Configuration.php — the config tree. This tells you what is actually tunable (storage driver, enabled flag, logger channel, table/prefix names).Resources/config/services.xml — your real API surface. Service IDs, constructor arguments, tagged listeners. Everything you need to instantiate by hand lives here.Tracker / EventTracker) — the public methods are typically a track(string $name, array $context = []) shape plus maybe identify().Minimal wiring in Laravel — skip the bundle, or bind it manually:
composer require happyr/event-tracker-bundle:0.6.1
// app/Providers/EventTrackerServiceProvider.php
public function register(): void
{
$this->app->singleton(EventTracker::class, function ($app) {
// Inspect services.xml for the real constructor signature.
return new EventTracker(
$app['log']->channel(config('event-tracker.channel', 'events')),
);
});
$this->app->alias(EventTracker::class, 'event-tracker');
}
Because there is no Symfony Kernel::registerBundles(), the bundle's Extension never runs — the Configuration tree is never processed. You are responsible for building any config array yourself and passing it into constructors.
First use case: structured domain-event logging — order.paid, user.invited, webhook.failed — with JSON context, written to a dedicated log channel or an event_log table, then queried by name/date. That is the whole value proposition; everything else is your own code.
1. Adapter boundary — never leak the bundle's namespace into app code.
// app/Contracts/Tracker.php
interface Tracker { public function track(string $name, array $context = []): void; }
// app/Services/BundleTrackerAdapter.php
final class BundleTrackerAdapter implements Tracker
{
public function __construct(private EventTracker $inner) {}
public function track(string $name, array $context = []): void
{
$this->inner->track($name, $context + [
'user_id' => auth()->id(),
'request_id' => request()->header('X-Request-Id'),
'env' => app()->environment(),
]);
}
}
This is the single most important pattern: if the bundle dies (still likely — 0.6.1 is a compat bump, not a revival), you swap one class. If you inject EventTracker directly into 40 controllers, you are rewriting 40 files.
2. Bridge Laravel events into the tracker. One listener beats instrumenting every call site.
// app/Providers/EventServiceProvider.php
Event::listen('*', function (string $event, array $payload) {
if (is_subclass_of($event, TrackableEvent::class)) {
app(Tracker::class)->track($event, $payload[0]->trackingContext());
}
});
Prefer an explicit TrackableEvent marker interface over wildcard-matching everything — the '*' listener fires for framework-internal events too and will flood your log.
3. Swap or duplicate storage deliberately. If the bundle ships Doctrine entities, do not try to bring in doctrine/orm alongside Eloquent. Instead re-bind the storage side to an Eloquent model and keep only the tracker's dispatch/formatting logic:
tracked_events: id, name (indexed), context (json), user_id (nullable, indexed), occurred_at (indexed)
Index (name, occurred_at) — every real query is "how many checkout.failed since Tuesday".
4. Queue it. Tracking should never add latency to the request or fail the operation:
final class QueuedTracker implements Tracker, ShouldQueue
{
public function track(string $name, array $context = []): void
{
TrackEventJob::dispatch($name, $context)->onQueue('telemetry');
}
}
Use Bus::batch() for backfills and ->delay() for coalescing high-frequency events.
5. Enrich context in middleware, not in call sites. A PushTrackingContext middleware that sets user/tenant/request-id once keeps every track() call two arguments wide.
6. The Symfony 5 bump actually matters here. Symfony 5 ships only the PSR-14 dispatcher signature — dispatch(object $event, string $eventName = null). The legacy dispatch($eventName, Event $event) form from 4.2 and earlier is gone. Two consequences:
EventSubscriber, its internals are now written against the listener object shape (__invoke(object $event, string $eventName, EventDispatcherInterface $dispatcher)), which is exactly what you want to mirror.Illuminate\Events\Dispatcher implements PSR-14 on Laravel 9+. You can bind Symfony\Component\EventDispatcher\EventDispatcherInterface to a small forwarder that re-dispatches into Laravel's dispatcher:$this->app->bind(SymfonyDispatcherInterface::class, fn ($app) =>
new class($app['events']) implements SymfonyDispatcherInterface {
public function __construct(private IlluminateDispatcher $events) {}
public function dispatch(object $event, ?string $eventName = null): object
{
$this->events->dispatch($eventName ?? $event::class, $event);
return $event;
}
// addListener/removeListener/getListeners delegate to $this->events
});
Verify with app(SymfonyDispatcherInterface::class) in tinker before committing to this; it remains the most fragile part of the integration.
7. Testing. Assert on your Tracker interface, never the bundle:
$this->mock(Tracker::class)->shouldReceive('track')->once()->with('order.paid', Mockery::type('array'));
Log::spy(); // if you kept the log-channel backend
Event::fake() will not intercept the bundle's internal dispatcher — another reason for the adapter layer.
composer.json (or | a fork under repositories) rather than ^1.0 / ^0.6 — a stray minor bump can break you silently.dispatch(object $event, ?string $eventName)). But the flip side: 0.5.x and earlier used the legacy two-string signature. Never mix code written against both — a dispatch('name', $event) call copied from an old blog post will silently misbehave.php: ^7.2.5 in practice. Modern PHP 8.3 stacks will emit deprecation notices from untyped properties and the Event class hierarchy; #[\ReturnTypeWillChange] patches may be needed if you touch the internals.%parameter% resolution, no tagged-service collection. Any service that expects an injected array $config from Configuration.php must be constructed by hand with the values you choose.services.xml; don't guess class names. Symfony 5 changed default service visibility (private-by-default, .public aliases), so new-ing the class yourself is usually safer than trying to reach into a container that isn't there.DateTime objects will fail on encode or produce useless output. Normalize: Carbon → toIso8601String(), models → ['id' => $m->id]. Add a json_encode guard in the adapter that logs and drops unencodable context rather than throwing mid-request.groupBy('name') after rollout.user.email_changed with a full payload are a GDPR liability. Ship a model:prune / scheduled DELETE WHERE occurred_at < now()->subDays(90) from day one, and allow-list which context keys are persisted.occurred_at) and keep the display conversion in the read layer; mixing now() local and UTC rows makes every "last 24h" query wrong.php artisan tinker → app(Tracker::class), then ->track('test.ping', ['a' => 1]). If that throws a constructor-argument error, the class's real signature is in services.xml — you read the wrong service. composer why-not happyr/event-tracker-bundle 0.6.1 is the fastest way to find out which package is still holding you on Symfony 4.Tracker interface, (b) swap the storage backend by re-binding, (c) subclass the bundle's tracker if its methods aren't final. Check final and private before planning either.How can I help you explore Laravel packages today?