genealabs/laravel-pivot-events
Adds pivot table events to Laravel many-to-many relationships, firing PivotAttached, PivotDetached, PivotUpdated, and PivotSaved so you can hook into attach, detach, and update operations using listeners, observers, or custom pivot models.
belongsToMany relationships — and attach(), detach(), sync(), and updateExistingPivot() bypass normal Eloquent model events entirely. If an audit trail, access review, or SOC 2 control is anywhere on the roadmap, this hook either exists before that work starts or gets retrofitted at the worst possible moment. Adopting it early is a sequencing decision, not a technical one.sync() no longer emits pivotSynced and a detach of rows that were never attached no longer emits pivotDetached — so routine "re-save the same memberships" paths stop generating pointless cache flushes and background jobs. That is a measurable cost reduction on our most common write pattern, and it removes a class of bug where a redundant save masks a stale cache.InteractsWithPivotTable internals, which move between majors. MIT license, small readable source, 141 stars, actively released. Vendoring the few dozen lines of the Pivot class is a legitimate hedge if the dependency ever goes quiet.Adopt when:
belongsToMany relationships that carry business meaning — roles, permissions, memberships, entitlements — and no reliable way to observe writes to them today. "Just listen to model events" is not an alternative; those writes never fire them.save() cost is invisible.composer.json and commit to re-running pivot-event tests on every Laravel major upgrade.Look elsewhere or narrow the scope when:
attach()'s single bulk insert() into one save() per row. On a 10k-row sync() that is a real, measurable regression. Keep import pipelines on stock Laravel behaviour and reserve the trait for administrative relations.->using(MyPivot::class). Laravel's native custom pivot routes writes through real model save()/delete(), so ordinary created/deleted events already fire. Running both mechanisms on one relation is the source of duplicate and missing events. Pick one per relationship and document the choice.newPivot() override, or get stomped by it. Verify $relation->getPivotClass() before committing.To executives: This is a small, MIT-licensed dependency that makes relationship changes — who got which role, who joined which project, who was removed — observable in a single, consistent place. It is the cheapest possible foundation for audit trails, access reviews, customer-facing notifications, and seat-based billing accuracy. Building it ourselves is a day or two of engineering plus an open-ended commitment to track Laravel internals; buying it is a composer line and a pinned version. Since the latest release it also fires only on real changes, which cuts wasted cache flushes and re-index jobs on our most common write pattern.
To engineering: One trait, added to both sides of each belongsToMany relationship we care about (one side only is a silent half-failure — the number one support question in this space). It overrides newPivot() to emit pivotAttaching/pivotAttached/pivotDetaching/pivotDetached/pivotUpdating/pivotUpdated as ordinary string-named Eloquent events, so Event::listen('eloquent.pivotAttached: App\Models\User', ...) and User::pivotAttached(...) both work. The source is a few dozen lines — read Pivot.php before designing on top of it, and dd() the listener argument once, because the subject may be the pivot instance rather than the parent model.
Known costs and constraints we accept: writes become one save() per row instead of a bulk insert, so bulk relations stay on stock behaviour; queue listeners must snapshot pivot attributes before dispatch; listener side effects are only atomic with the write if they share the transaction, and only for synchronous listeners; and the event volume semantics can shift in a minor release, so we pin and re-test on every Laravel upgrade. We should also decide per relationship between this trait and Laravel's native ->using() pivot — not both, which is where duplicate and missing events come from.
How can I help you explore Laravel packages today?