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

Eventually Laravel Package

altek/eventually

Eventually is a Laravel event sourcing package for modeling applications around domain events. It offers aggregates, event storage, projectors, and reactors, giving you an expressive way to persist state changes as an append-only event log.

View on GitHub
Deep Wiki
Context7

Getting Started

altek/eventually fills a real gap in Laravel: Eloquent fires saving/saved/creating/created on models, and it fires attaching/attached on many-to-many relationships, but it does not fire model events when pivot rows are created, updated, or deleted. eventually adds those missing pivot events (pivotAttaching, pivotAttached, pivotDetaching, pivotDetached, pivotUpdating, pivotUpdated, pivotSaving, pivotSaved) so you can hook audit logs, cache invalidation, or notifications into pivot changes the same way you do model changes.

Minimal setup:

composer require altek/eventually

The package is auto-discovered (it ships a service provider). No config file to publish in the typical install — check config/eventually.php only if the README for your version mentions it.

Apply the trait to the model whose pivots you want observable. The trait is Altek\Eventually\Eventually:

use Altek\Eventually\Eventually;

class User extends Model
{
    use Eventually;

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

Where to look first:

  1. src/Eventually.php — the trait. This is where the pivot events are dispatched; reading it once tells you the exact payload of each event.
  2. The README on GitLab — event names and signatures.
  3. src/Observers/ (or equivalent) if present — how pivot events are hooked to the model's event dispatcher.

First use case — audit trail on a role assignment:

class User extends Model
{
    use Eventually;

    protected $dispatchesEvents = [
        'pivotAttached'  => RoleAttached::class,
        'pivotDetached'  => RoleDetached::class,
    ];
}

Or, without an event class, a model event listener:

User::pivotAttached(function ($model, $relation, $pivot) {
    Log::info('pivot attached', [
        'model'    => $model->getKey(),
        'relation' => $relation,
        'pivot'    => $pivot,
    ]);
});

Implementation Patterns

1. Registering listeners three ways. The package funnels pivot events through Laravel's normal model-event dispatcher, so all three Laravel idioms work:

  • protected $dispatchesEvents = ['pivotAttached' => MyEvent::class]; on the model.
  • A dedicated observer: User::observe(UserPivotObserver::class); where the observer defines pivotAttached(User $user, string $relation, $pivot) etc.
  • Closures via User::pivotAttached(fn ($model, $relation, $pivot) => ...), typically in a service provider's boot().

Prefer observers when you have several pivot events on the same model — it keeps dispatchesEvents from growing into a wall of one-liners.

2. Audit logging of many-to-many changes. This is the headline use case. Write one observer that logs attach/detach/update against a polymorphic activity_log table. Because each event hands you the model, relation name, and pivot, you can record a full before/after for pivotUpdated:

public function pivotUpdated(User $user, string $relation, array $pivot)
{
    Activity::create([
        'subject' => $user,
        'type'    => "pivot.updated.{$relation}",
        'changes' => $pivot['changes'] ?? $pivot,
    ]);
}

3. Coordinating side effects with model saves. When you $user->roles()->attach($roleId), Laravel has already saved nothing on the parent — only the pivot row changes. If you need "attach the role and bump the user's roles_updated_at", do it inside the observer (or dispatch a queued job) rather than wrapping it in a transaction around the attach call. If you do wrap in DB::transaction, remember attached/detached fire inside the transaction, so a failure to save the parent field still rolls back the pivot.

4. Cache invalidation keyed on relations. Because $relation is passed as a string, one observer can invalidate the right cache bucket:

public function pivotAttached($model, string $relation, $pivot)
{
    Cache::forget("{$model->getTable()}.{$model->getKey()}.{$relation}");
}

This keeps User::with('roles') style cache keys coherent without sprinkling Cache::forget calls throughout controllers.

5. Queued event classes. The $dispatchesEvents approach works cleanly with ShouldQueue event classes — the pivot data gets serialized, so make sure whatever you put in the event is serializable (IDs, not full models, or use SerializesModels). This is the cleanest way to keep pivot logging off the request path.

6. sync() and toggle(). sync() fires attach and detach events for the respective rows, so a single sync() can produce multiple pivot events. If you need one event per sync operation, handle aggregation in your listener or wrap the sync call and suppress per-row listeners with a guard flag (see gotchas).

Gotchas and Tips

Trait placement matters — and it is asymmetric. The pivot events are fired on one side of the relationship (historically the model from which the operation is being made, but check Eventually.php for your version). If you put the trait only on Role and call $user->roles()->attach(...), you may not get events. Put the trait on the model you actually call the relationship from, or on both models if you have bidirectional operations. This is the single most common "it doesn't fire" bug.

Events fire before and after — decide which you want. pivotAttaching fires before the row exists; pivotAttached fires after. If you need the pivot's auto-increment id or a DB default, you want the ...ed variant. For validation-style vetoes, you'd want ...ing, but be aware the package relies on halt() semantics; if you fire an exception from pivotAttaching, verify the underlying attach actually aborts in your Laravel version — this is worth a test.

attach() in bulk fires once, not per ID. attach([1, 2, 3]) triggers one pivot event with an array in the pivot payload, whereas attach(1); attach(2) fires twice. If your audit log assumes one row per event, normalize the array in the listener. Same caution applies to detach with no arguments (which detaches everything — one event with null/empty pivot).

sync() produces noisy event streams. sync([1,2,3]) on a user with [3,4] fires detach for 4 and attach for 1 and 2. If you are hooking notifications into pivotAttached, guard against sync() being called from seeders, factories, or importers. A common pattern is a $quietPivotEvents static flag toggled around bulk operations, checked at the top of each listener.

$dispatchesEvents is per-model, not per-relationship. If User has both roles() and teams() many-to-many relations, the same pivotAttached event class fires for both. Disambiguate inside the listener using the $relation argument, not by trying to register a different event per relation.

Debugging: listen at the dispatcher level. If a closure listener doesn't fire, Event::listen('eloquent.pivotAttached:*', fn (...$args) => dump($args)); in a tinker session tells you whether the event is dispatched at all. That distinguishes "trait missing / wrong side" from "listener not registered."

Interaction with observers and $dispatchesEvents. Because pivot events ride the normal dispatcher, the standard Laravel observer resolution applies — an observer's pivotAttached method is called like any other model event method. Note that observer-style created/updated hooks will not fire when you call attach() alone; only pivot events will. If you have logic in updated that you also need to run on attach, factor it into a shared method called from both.

Version drift with Laravel. This is an unbundled third-party package (0 stars, 0 dependents on the scorecard), so it lags Laravel major releases. Check the composer.json illuminate/* constraints before upgrading Laravel; if a new release breaks internals, the fix is usually to override the trait method that dispatches the event. Because the trait is small, vendoring a patched copy into app/Concerns/Eventually.php is a pragmatic escape hatch.

Don't rely on it for critical invariants. With zero reported dependents, treat the package as a convenience wrapper, not a load-bearing dependency. Anything that must be true (financial ledger entries, hard audit requirements) should be enforced at the database or service layer, not only inside a pivot listener — because a raw query builder attach style insert via DB::table('role_user')->insert(...) will bypass Eloquent entirely and fire nothing.

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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager