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.
Architecture fit. This package solves a real gap rather than a cosmetic one: Laravel's attach(), detach(), sync(), and updateExistingPivot() deliberately bypass Eloquent model events because they write to the join table, not the model. Any domain where the pivot is the business object — role grants, team membership, entitlement assignment, tagging, project assignment — therefore has no first-class observation point today. The package closes that with a trait (PivotEventsTrait) that overrides newPivot() to return a package-provided Pivot subclass, and a vocabulary of six-plus events (pivotAttaching, pivotAttached, pivotDetaching, pivotDetached, pivotUpdating, pivotUpdated, plus pivotSynced). This is a small, comprehensible seam — one trait, one class override, string-named Eloquent events — and it composes with existing listener infrastructure (Event::listen, Model::booted(), Event::fake()). It does not patch the framework core, which is the single most important architectural property in its favour.
Strategic value. The strongest argument is not the events themselves but the epics it unlocks cheaply: audit/compliance trails for access review (SOC 2, GDPR erasure scope), cache and search-index invalidation for permission-derived datasets, activity feeds and customer-facing webhooks, seat-based billing accuracy, and warehouse/CRM propagation without standing up CDC. Each of these is a roadmap item that would otherwise require bespoke afterSave code scattered across a dozen models, or a much larger CDC investment. Build-vs-buy is genuinely favourable: in-house is roughly 1–2 days of engineering plus indefinite maintenance against Laravel's InteractsWithPivotTable internals. Buying at MIT with a vendoring option is the cheaper path, provided we accept the maintenance seam as a named risk rather than an implicit one.
Integration feasibility. Low-to-moderate. Install is a single composer require; wiring is one use statement per model. The genuine complexity is organisational, not technical: the trait must be applied to both sides of every belongsToMany relationship we care about, because newPivot() only fires on the side where the trait lives. Trait on User alone means $user->roles()->attach() emits events but $role->users()->attach() silently does not. That half-failure mode is the package's dominant support burden and is the primary delivery risk: it is invisible at code-review time and only surfaces as "the listener didn't fire" months later. Mitigation is mechanical — a required-application checklist, a static analysis or test that asserts the trait on both endpoints per relationship — not inspirational.
Technical risk. Four risks are material enough to name in a design review:
attach()'s single bulk insert() into one save() per row. On a 10k-row sync() this is measurable. This is not a reason to reject the package; it is a reason to scope it to human-scale and administrative writes and leave bulk import paths on stock behaviour.pivotSynced and pivotDetached when no change occurs. That is a good behaviour change — it eliminates pointless cache flushes and spurious seat events — but it is an event-volume semantics change shipped in a minor. Any audit design that recorded intent ("attempted detach of role 5") rather than outcome silently changed meaning on a patch bump. Pin a minor version and treat event semantics as part of the contract under test.->using(MyPivot::class) already routes pivot writes through a real model, and permission/role libraries frequently ship their own pivot classes. Running both mechanisms on one relationship produces duplicate or missing events. The rule must be one mechanism per relationship, chosen explicitly and documented in a comment.Key questions for engineering before commitment.
belongsToMany relationships carry business meaning, and what is the write volume per day on each? Anything with bulk import traffic gets excluded from phase one.->using() or a third-party pivot class (Spatie permissions, role libraries)? If yes, do we standardise on that mechanism and drop this package for that relation?Pivot instance? This determines the shape of every audit and cache-invalidation handler and must be confirmed by inspection, not assumption.granted_at, assigned_by, expires_at) must be snapshotted into the payload before dispatch, because ShouldQueue serialises models by ID only.InteractsWithPivotTable?Stack fit. Clean, provided we are on a supported Laravel major and PHP version — the 13.x line tracks modern Laravel and the license is MIT, so there is no procurement or legal friction. The package is idiomatic Laravel all the way down: Eloquent events, service-provider listeners, Event::fake() in tests. It requires no framework core changes, no custom service container bindings, and no configuration file. That means it slots into an existing application without altering deployment topology, queue configuration, or observability setup. Fit is strongest where pivots already carry domain meaning and weakest where the relationship is pure reference data.
Migration path. Non-breaking and incremental by construction. The trait is additive; removing it restores stock behaviour with no data migration, no schema change, and no backfill. Nothing about the pivot tables themselves changes. This is an unusually forgiving adoption profile: we can enable it per-model, per-relationship, and roll back per-model if a hot path misbehaves. The only truly irreversible decision is the audit data we begin writing — once we start persisting pivot audit rows, the downstream consumers (access reviews, compliance exports) create expectations. So the migration is technically free but operationally sticky, and should be sequenced as such.
Compatibility. Two compatibility surfaces need explicit handling. First, third-party packages that supply their own pivot classes — the trait's newPivot() override can either be shadowed by theirs or stomp on theirs. Resolve per relationship with $relation->getPivotClass() as the diagnostic, and record the decision. Second, Laravel minor and major upgrades: the package tracks InteractsWithPivotTable internals, so every Laravel upgrade is a re-validation event for our pivot-event tests, not a routine bump. We should pin the package to a minor in composer.json and hold the pin deliberately.
Sequencing. Recommended four-stage rollout, each stage independently valuable and independently reversible:
Event::listen('eloquent.*', ...) output. This confirms actual event names, firing order, and listener argument types for the installed version. Cheap, and it prevents designing against assumptions.assertNotDispatched after a no-op sync() and after detaching absent rows).belongsToMany relationship of a flagged type lacks the trait on both endpoints.The critical sequencing constraint: do not start with the audit trail across all models. Start with one relationship and one consumer, prove the event semantics against the installed version, then generalise. The failure mode we are buying insurance against is a broad rollout that discovers the trait-on-one-side problem in production.
Maintenance. Low absolute effort, but it is a pinned dependency with a narrow upstream, not a fire-and-forget library. Ongoing obligations are: re-running pivot-event tests on every Laravel upgrade and every package bump; reviewing release notes specifically for event-volume semantics changes (13.1.0 is the precedent); and monitoring whether the maintainer continues shipping. With 0 dependents there is no ecosystem buffer, so we should assume we may need to fork. Keeping our usage surface to one trait plus centrally-registered listeners makes a fork or replacement a contained, day-scale exercise rather than an archaeology project. Recommend vendoring or a documented fork path as a standing option rather than a contingency to improvise later.
Support. The support burden concentrates in one place: trait applied to one side only, which presents as "the listener doesn't fire" and consumes disproportionate debugging time. A second, growing source is the new ambiguity introduced by 13.1.0 — "my listener didn't fire" now means either broken or nothing changed. Both are addressable with cheap tooling: a ten-second Event::listen('eloquent.*') logger in the runbook, and a documented decision tree ("run the wildcard logger before hunting for a trait/using() conflict"). Team-level mitigation is a written convention — trait on both sides by default, one pivot mechanism per relationship, listeners in providers not models — plus the CI guard mentioned above. Third-party-library owners (permissions, roles) must be consulted early; their pivot classes are the main external support dependency.
Scaling. Two distinct scaling concerns. Write-path: attach() becomes N save() calls instead of one bulk insert(). On administrative and human-scale writes this is negligible; on a 10k-row sync() it is a real regression. Scope the package to business-meaningful pivots and keep high-volume import paths on stock Laravel behaviour — this is a design decision, not a tuning exercise, and it should be written down. Listener fan-out: attach([1..50]) dispatches fifty event pairs. A listener that performs a query or HTTP call per event will collapse. Mitigation is ours, not the package's: buffer into a DB::afterCommit flush, or suppress per-row handling for bulk operations and emit one domain event after the loop. 13.1.0 thins redundant events; it does nothing for genuinely large ones. Note also that 13.1.0's change-only emission is a measurable reduction in background-job cost on our most common write pattern (re-saving unchanged memberships), which partially offsets the write-path regression in aggregate.
Failure modes. Rank by likelihood × impact:
$model->roles()->attach(...) inside a pivotAttached handler — is unbounded recursion. Enforce as a hard review rule.handle() will be absent because models are serialised by ID. Snapshot before dispatch.->using() conflict (moderate likelihood, moderate impact). Per-relationship mitigation: pick one mechanism, document it.Ramp-up. Effectively immediate for a competent Laravel engineer — install, trait, listener, done in an afternoon. The README is short and the source shorter; reading src/Pivot.php (a few dozen lines) is the single highest-leverage onboarding step, because it is the source of truth for event names, event subject type, whether …ing events honour a false return, and exactly when events are suppressed. Budget half a day of onboarding per engineer and require two artefacts before any production listener ships: a dd() of the listener argument, and ten seconds of wildcard event log captured against the installed version. For the TPM: the ramp-up cost is not in learning the package, it is in learning which of our relationships are pivot-bearing and business-meaningful — that inventory work is the real prerequisite and should be scheduled explicitly rather than assumed.
How can I help you explore Laravel packages today?