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

signifly/laravel-pivot-events

Laravel package that fires Eloquent model events for pivot tables on many-to-many relationships. It dispatches attaching, attached, detaching, detached, updating, updated, saving, and saved events, letting you hook into sync, attach, and detach operations without custom code.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm compatibility first. The package is archived (last release Nov 2023), so before anything else open its composer.json and check the illuminate/* constraints against your app's Laravel version. On Laravel 11+ you will most likely need to fork it or pin ^10.0 and test carefully. Install with:

composer require signifly/laravel-pivot-events

Where to look first. The package is tiny — read src/ end to end in ten minutes. The two things that matter are (a) the trait you add to your model and (b) how it hooks the belongsToMany relation to wrap attach / detach / updateExistingPivot and dispatch events. Once you've seen that wrapper, the package holds no surprises; there is no config file, no migration, no facade.

Minimal setup. Add the pivot-events trait to the parent model — the one that owns the belongsToMany relationship:

use Signifly\PivotEvents\Traits\HasPivotEvents;

class Team extends Model
{
    use HasPivotEvents;

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)->withPivot('role');
    }
}

First use case. The canonical one is an audit trail for membership changes: you have team_user pivot rows with a role column and you want a record of who was added, removed, or promoted. Register an observer on Team and log from the pivotAttached / pivotDetached / pivotUpdated handlers. This is the fastest way to validate the package does what you expect before wiring it into anything load-bearing — attach a user in php artisan tinker and watch the event fire.

Implementation Patterns

Observer-based handling (preferred). Register an observer in EventServiceProvider::$observers and implement only the handlers you need:

class TeamObserver
{
    public function pivotAttaching(Team $team, $relation, $pivotIds, array $attributes): void
    {
        // validation / guard clauses run BEFORE the DB write
    }

    public function pivotAttached(Team $team, $relation, $pivotIds, array $attributes): void
    {
        foreach ($pivotIds as $userId) {
            activity()->performedOn($team)->withProperties([
                'user_id' => $userId,
                'attributes' => $attributes[$userId] ?? [],
            ])->log('member added');
        }
    }

    public function pivotDetaching(...) {}
    public function pivotDetached(...) {}
    public function pivotUpdating(...) {}
    public function pivotUpdated(...) {}
}

The *ing variants fire before the query executes; the *ed variants fire after. Use the ing phase for authorization and validation, and the ed phase for side effects.

Audit / activity logging. Because the handler receives the full array of pivot IDs plus the per-ID attributes array, you can write one log row per affected pivot inside a single handler. This is what makes the package worth the dependency versus hand-rolling: sync() and bulk attach([1,2,3]) produce one event carrying all the IDs instead of N separate hooks.

Cache invalidation and denormalisation. If you cache a Team::$memberCount or a "members" fragment, pivotAttached / pivotDetached are the natural flush points. Keep the handler to a single Cache::forget() call — anything heavier belongs in a queued listener.

Queued listeners for heavy work. Don't send emails, hit webhooks, or rebuild search indexes synchronously from a pivot observer; the attach call happens inside the request that triggered it. Resolve the event to a job (ShouldQueue) so the request stays fast and a failing webhook doesn't roll back the membership change.

Guard rails via the ing events. pivotAttaching is the right place to enforce business rules — e.g. "a team cannot have more than 5 admins", or "you cannot attach a user to a team in another organisation". Throwing from the ing handler aborts the attach before the insert, which is a clean, cheap way to enforce invariants.

Broadcasting real-time membership changes. Map pivotAttached → MemberJoined broadcast event, pivotDetached → MemberLeft. This is a clean integration point because the pivot events give you the model plus the affected IDs, which is exactly the broadcast payload.

Testing. Fake the events and assert on the string form Laravel uses for these model-style events:

Event::fake();
$team->users()->attach($user->id, ['role' => 'admin']);
Event::assertDispatched('eloquent.pivotAttached: '.Team::class);

Then test the observer in isolation by instantiating it and calling the handler directly — that keeps the integration test suite from depending on the archived package's internals.

Gotchas and Tips

It is archived — treat it as vendored code. Zero dependents, no release since 2023, and no Laravel 11/12 support guarantee. Fork it into packages/ or vendor it rather than relying on Packagist, and add a test that attaches a pivot and asserts the event fires so your upgrade path is obvious when it breaks.

The modern replacement is a custom pivot model. On Laravel 9+ you can do ->using(MemberPivot::class) where MemberPivot extends Pivot, and that pivot model fires real Eloquent events (created, updated, deleted) with full model semantics — including $dispatchesEvents, observers, and withoutEvents(). If you're starting fresh on a current Laravel, prefer this over the package; it also gives you per-row events, which is often what you actually want.

One event per call, not per row. attach([1, 2, 3]) fires a single pivotAttached with three IDs. sync() fires a mix of attach and detach events — and on sync() the detach and attach are separate dispatches, so a handler that assumes "one call = one final state" will be wrong. Order matters if your observer asserts on current state.

detach() with no arguments is a trap. Detaching everything may hand you an empty or null ID list. Handle that branch explicitly or you'll silently skip the audit entry for "removed all members".

No transaction semantics. If the attach succeeds but your pivotAttached handler throws, the pivot row is already written and there is no compensating event. Either wrap the whole call in DB::transaction() yourself, or make handlers fail-soft (catch, log, queue a retry) so a downstream failure never leaves you with members added but no audit row.

Verify the observer signature against the source. The argument order (model, relation name, pivot IDs, attributes) is the kind of thing that shifts between minor versions of an unmaintained package. Read the trait before writing six observers against the wrong signature — the type errors are opaque.

Events are dispatched through the global dispatcher, not the model's. That means Model::withoutEvents(fn () => ...) and $model->withoutEvents() will not suppress these — useful to know for seeders and imports, where you almost certainly want them suppressed. Wrap seeders in Event::fake() or check for an opt-out flag, otherwise a large import will fire thousands of observer handlers and crawl.

Don't reach for it for anything but belongsToMany. Morph-to-many and hasManyThrough pivots are outside the wrapper's scope. If your relationship is exotic, hand-roll a service method around the attach and dispatch your own domain event — it's clearer than debugging why an event never fired.

Debugging. When an event seems missing, check in this order: (1) the trait is on the parent model that owns the relation, not the related model; (2) the observer is registered in $observers and the method name matches exactly; (3) you're calling the relation through the model ($team->users()->attach(...)), not through a raw query builder or DB::table('team_user')->insert(...) — the latter bypasses Eloquent entirely and the package has no way to see it.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin