Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Pivot Events Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install, trait, listener. Then verify before you build.

composer require genealabs/laravel-pivot-events

Add the trait to both sides of every belongsToMany relationship you intend to observe:

use GeneaLabs\LaravelPivotEvents\Traits\PivotEventTrait;

class User extends Model
{
    use PivotEventTrait;

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)->withTimestamps();
    }
}

class Role extends Model
{
    use PivotEventTrait;
}

The trait overrides newPivot(), so it only affects relations instantiated from that model. Trait on User alone means $user->roles()->attach($id) fires events while $role->users()->attach($id) silently does not. That asymmetry is the number-one reason people conclude the package is broken. Default to both models and trim later only for a reason you can write down.

Where to look first: src/Pivot.php in the vendor tree. It is a few dozen lines and it is the authoritative answer to four questions your design depends on: the exact event names this version fires, what object arrives as the listener argument, whether the …ing events honour a false return, and (since 13.1.0) exactly when an event is suppressed. Read it before the README, and re-read it after any bump. Also confirm the trait's fully-qualified name against the installed version — README and src/Traits/ have diverged before, and a wrong use statement fails loudly but confusingly.

First use case — audit trail for the writes Eloquent hides. attach, detach, sync, and updateExistingPivot bypass normal model events entirely, so "who granted admin, and when" has no hook without this package:

// AppServiceProvider::boot()
Event::listen('eloquent.pivotAttached: App\Models\User', function ($model) {
    ActivityLog::create([
        'actor_id'    => auth()->id(),
        'subject'     => get_class($model).'#'.$model->id,
        'change'      => 'attached',
        'attributes'  => $model->pivot?->getAttributes(),
    ]);
});

The fluent registration works too, since these are ordinary Eloquent events routed through Model::__callStatic:

User::pivotAttached(fn ($model) => /* ... */);

Ten-second sanity check before writing the table or the schema. Run once in tinker:

Event::listen('eloquent.*', fn ($name) => dump($name));
$user->roles()->attach($roleId);

Look at the real names, the real order, and the real shape of the argument. That single command distinguishes "not wired up" from "suppressed because nothing changed" from "the subject is the pivot, not the parent" — the three failure modes you will actually hit.


Implementation Patterns

1. Split the rulebook by scope. Cross-cutting concerns — audit, cache busting, search re-index, webhooks — belong in a service provider (or a dedicated PivotEventServiceProvider) where one file shows the whole policy surface. Invariants that only make sense for one model ("a project can have exactly one owner") belong in that model's booted(), adjacent to the relationship they constrain. Reviewers should never have to grep six models to learn what a pivot write does.

2. Treat change-detection as the primary signal, not the side effect. A user's permission cache or search document is derived from pivots. Listen on the granting side (pivotAttached/pivotDetached on Role) and forget permissions:user:{id} once, centrally, instead of chasing every controller that calls sync(). Since 13.1.0 a no-op sync() no longer fires pivotSynced, so the redundant "save the same form again" path stops flushing caches and re-queuing index jobs — a real reduction in background load on your most common write.

3. Make sync() the default write path. Pre-13.1.0 people avoided sync() to keep the audit stream clean and hand-rolled attach/detach pairs. That trade-off is gone: sync() is now diff-aware and emits only for genuinely changed rows. Prefer it in controllers and form requests, and let the diff decide what listeners see. Keep explicit attach/detach for cases where the intent matters and you want to record it yourself.

4. Wrap the write, not the listener, when atomicity matters. Pivot events fire immediately around the write, so a synchronous listener is inside whatever transaction you opened:

DB::transaction(fn () => $user->roles()->sync($request->roles));

A ShouldQueue listener runs after commit and outside that guarantee — design it for at-least-once delivery and make it idempotent (upsert on (subject, change, pivot_id), not blind insert).

5. Batch the event storm deliberately. attach([1, 2, … 50]) dispatches fifty event pairs. If each listener does a query or an HTTP call, a bulk admin screen collapses. Either buffer into a request-scoped accumulator flushed on DB::afterCommit, or bypass events for import paths entirely (DB::table('role_user')->insert(...)) and emit one domain event after the loop. Do not try to mute the package from outside — you will end up in a fight with the trait.

6. Use …ing for last-line defence, policy checks at the call site. pivotAttaching/pivotDetaching are the natural place to abort "no super-admin grants without 2FA". Verify in src/Pivot.php that a false return actually halts the operation before you depend on it, and keep the real Gate::authorize() at the call site. A listener is not an authorization layer.

7. Pick exactly one mechanism per relationship when custom pivots exist. Laravel's ->using(MyPivot::class) already routes attach/detach/updateExistingPivot through a real model, so ordinary created/deleted fire. Running the trait and using() on one relation is where duplicate-and-missing events come from. Choose per relationship and leave a comment saying which, and why.

8. Snapshot pivot attributes before you queue. Queued listeners receive models serialised by ID with no pivot context. Pull everything you need into the listener's payload at dispatch time:

Event::listen('eloquent.pivotAttached: App\Models\User', function ($model) {
    SyncMembership::dispatch([
        'user_id'     => $model->id,
        'role_id'     => $model->pivot->role_id,
        'assigned_by' => $model->pivot->assigned_by,
        'at'          => now()->toIso8601String(),
    ]);
});

9. Test the full string names, and test the suppression. Event::fake() swaps the dispatcher — the DB write still happens, your listener does not. Assert on the literal name, and assert the row separately, otherwise a passing test proves nothing about persistence:

Event::assertDispatched('eloquent.pivotAttached: App\Models\User');
$this->assertDatabaseHas('role_user', ['user_id' => $user->id, 'role_id' => $role->id]);

// 13.1.0 regression guards
Event::assertNotDispatched('eloquent.pivotSynced: App\Models\User');   // after a no-op sync()
Event::assertNotDispatched('eloquent.pivotDetached: App\Models\User');  // after detaching absent rows

These are string-named Eloquent events, not class-based ones — you cannot fake or assert them by class.


Gotchas and Tips

  • It is not one write, it is N writes. Forcing a custom pivot class means attach()/sync() stop issuing a single bulk insert() and instead do one save() per row. On a 10k-row sync() that is a measurable regression versus stock Laravel. Benchmark on your hottest relation before adopting; leave genuine bulk paths on vanilla behaviour.
  • Silent no-ops since 13.1.0. pivotSynced and pivotDetached are suppressed when nothing changed. "My listener didn't fire" is now ambiguous between broken and nothing to do — always reach for the eloquent.* wildcard logger first. Also, an audit design that recorded intent ("attempted detach of role 5") quietly changes meaning on a patch bump. Capture intent at the call site if you need it.
  • sync() is a detach and an attach. It diffs, then routes through both paths, plus updateExistingPivot for attribute-only changes. 13.1.0 removes the no-change halves, but a genuine reorder still shows as two events. Decide explicitly whether your listener collapses them (e.g. debounce per (subject, related_id) inside one request).
  • Everything funnels through the same methods. syncWithoutDetaching, toggle, updateExistingPivot, and array attach all land on one code path. There is no "true attach only" filter — filter in the listener.
  • ->using() and third-party pivot classes can shadow the trait. Permission and role libraries ship their own pivots that bypass your newPivot() override, or get stomped by it. When a relation "just doesn't fire", dump $relation->getPivotClass() and compare it to what you expect.
  • The event subject may be the Pivot instance, not your parent model. Never assume the first argument is a User. dd() it once, then funnel extraction through one helper (PivotPayload::from($arg)) rather than scattering $model->pivot?->… across a dozen closures.
  • Never write to the same pivot table from inside its own listener. $model->roles()->attach(...) in a pivotAttached handler is unbounded recursion, and it will find you in production, not in tests.
  • Pin a minor version. The package tracks Laravel's InteractsWithPivotTable internals, which move between majors, and 13.1.0 proves event volume semantics can shift on a minor. Pin deliberately in composer.json and re-run your pivot-event tests on every Laravel upgrade and every package bump.
  • Extension point. If you need behaviour on every pivot write (a common payload shape, a tenant guard), subclass the package's Pivot and override newPivot() in your own trait to return it. One seam beats duplicating listeners across models, and it keeps your customisations out of vendor/ upgrades.
  • Debugging checklist, in order: wildcard eloquent.* log → $relation->getPivotClass() → trait present on both models → src/Pivot.php for suppression rules → Event::fake() in the failing test. That sequence resolves almost every "it doesn't fire" ticket in under five minutes.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator