chelout/laravel-relationship-events
Adds the missing relationship events to Laravel Eloquent models. Traits for hasOne, belongsTo, hasMany, belongsToMany, morphOne, morphTo, morphMany, morphToMany, and morphedByMany let you listen for saved, updated, attached, detached, and other events, useful for cache invalidation and audit logs.
Install and pin to your Laravel version. The package is version-locked to Laravel majors, so pick explicitly instead of letting Composer guess:
| Package | Laravel | PHP |
|---|---|---|
v5.x |
13.x | ^8.3 |
v4.x |
12.x | ^8.2 |
v3.x |
11.x | ^8.2 |
v2.x |
10.x | ^8.1 |
v1.x |
6.x – 9.x | ^7.2 | ^8.0 |
composer require chelout/laravel-relationship-events:^4.0
There is nothing to publish — no config file, no migrations, no service provider registration. The entire setup is a trait on the model plus listeners in boot().
Where to look first. Read doc/3-many-to-many.md and doc/1-one-to-one.md in the repo before touching the source; they are the most complete and describe the payload shapes. Then skim src/Concerns/* for the trait you intend to use — it's a small codebase and the listener registration is mechanical. Treat the README's "Todo: Tests, tests, tests" as a signal that you are the test suite.
First use case: audit trail on pivot changes. The highest-value, lowest-risk entry point is belongsToMany. Laravel gives you nothing for attach/detach/sync, and this is exactly where silent data changes happen.
use Chelout\RelationshipEvents\Concerns\HasBelongsToManyEvents;
class User extends Model
{
use HasBelongsToManyEvents;
protected static function boot(): void
{
parent::boot();
static::belongsToManyAttached(function ($relation, $parent, $ids) {
ActivityLog::record($parent, 'attached', $relation, $ids);
});
static::belongsToManyDetached(function ($relation, $parent, $ids) {
ActivityLog::record($parent, 'detached', $relation, $ids);
});
}
public function roles()
{
return $this->belongsToMany(Role::class)->withPivot('is_active');
}
}
That's it — $user->roles()->attach($role, ['is_active' => true]) now writes an audit row. No model overrides, no pivot model, no booted() gymnastics.
Trait-per-relationship-type, not per-model. You only add the trait for the kind of relation you care about. The parent model owns HasOneEvents, HasManyEvents, BelongsToManyEvents, MorphOneEvents, MorphManyEvents, MorphToManyEvents; the child model owns HasBelongsToEvents, HasMorphToEvents. A model can carry several traits at once, and this is the normal case for a User with a profile, posts, roles, and tags.
Small apps: closures in boot(). Larger apps: observers. The closure style is fine for one or two handlers, but it puts business logic inside the model and makes it hard to test in isolation. For anything beyond a log line, switch to HasRelationshipObservables plus an observer class:
use Chelout\RelationshipEvents\Traits\HasRelationshipObservables;
class User extends Model
{
use HasRelationshipObservables;
use HasOneEvents;
}
// app/Observers/UserObserver.php
class UserObserver
{
public function hasOneCreating(User $user, Model $related): void { /* ... */ }
public function hasOneCreated(User $user, Model $related): void { /* ... */ }
}
// AppServiceProvider::boot()
User::observe(UserObserver::class);
The observer method names are just the event names, so the surface stays identical — this is a refactor, not a rewrite.
Dispatchable events when you need queues, broadcasting, or Event::fake(). The package's static-style events are model-event listeners, which means they are awkward to assert against and impossible to queue cleanly. The HasDispatchableEvents trait plus $dispatchesEvents maps them onto real event classes, which is what you want the moment a listener does I/O (email, HTTP, search index):
use Chelout\RelationshipEvents\Traits\HasDispatchableEvents;
use App\Events\HasOneSaved;
class User extends Model
{
use HasDispatchableEvents;
use HasOneEvents;
protected $dispatchesEvents = [
'hasOneSaved' => HasOneSaved::class,
];
}
Then make the listener ShouldQueue and implement ShouldHandleEventsAfterCommit so it doesn't fire on a transaction that rolls back — a classic source of "phantom audit rows" in this kind of package.
Disambiguate by $relation when a model has several relations of one type. Event::listen-style registration is per model class, not per relation method. A Post with comments(), revisions(), and attachments() all firing morphManySaved needs a branch:
static::morphManySaved(function ($parent, $related) {
match (true) {
$related instanceof Comment => $this->onCommentSaved($parent, $related),
$related instanceof Attachment => Cache::forget("post:{$parent->id}:attachments"),
default => null,
};
});
Note the morphMany* handlers receive ($parent, $related) while morphToMany* handles receive ($relation, $parent, $ids, $attributes) — check the signature per trait rather than assuming a uniform shape.
Good day-to-day targets for these hooks:
sync on roles/permissions) — the single most common real bug this package fixes.count() in every view.is_dirty() on the parent won't tell you anything because the change is in the join table.*ing (pre) hooks to validate and the *ed (post) hooks to notify.Prefer the pre-hooks for intent, the post-hooks for side effects. hasOneCreating, hasManySaving, belongsToManyAttaching, belongsToManySyncing, morphToAssociating run before persistence. Use them to stamp attributes on the related model or to log intent. Use created/saved/attached/synced for anything that must observe committed state.
Traits are opt-in and silently inert. Forget use HasBelongsToManyEvents; and everything compiles, runs, and fires nothing. There is no warning. When an event "isn't firing," the first check is always the trait, the second is that parent::boot() is still called.
sync() and toggle() fire nested events. Per the docs, sync fires belongsToManySyncing/Synced and the underlying attach and detach events; toggle behaves the same way. If you listen at both layers you will double-count, double-log, or double-charge. Pick one level — usually the Syncing/Synced pair, since it maps to one user intent — and ignore the inner events for that relation.
Payload shapes are inconsistent across traits. hasOne*/hasMany* give you ($parent, $related); belongsToMany* gives ($relation, $parent, $ids) (plus $attributes for Attached); morphTo* gives ($relation, $related, $parent) with the related model first. Write small adapter methods instead of destructuring inline in boot(), or you will chase argument-order bugs.
Several hooks cost an extra query. The docs flag it explicitly: detach runs "an additional query to get related ids," hasMany::update fetches the related collection, and sync inherits the detach cost. On a bulk re-sync of thousands of rows this is a real N+1 pattern. If you are doing a large import, consider bypassing the relation methods (direct pivot table writes) and skipping these events deliberately.
associate() / dissociate() do not persist. The docs note the related model is "dirty" and must be saved afterwards — so belongsToAssociated / morphToAssociated fire before the foreign key hits the database. Never let a listener on those events read the model back from the DB and assume the relation is set. Do the save yourself, or listen on the model's own saved event instead.
You cannot veto by returning false. These are custom model events, not Eloquent's built-in creating/saving events where a false return halts the operation. Treat the *ing hooks as observability, not validation gates. If you need to prevent an attach, enforce it at the domain layer ($user->assignRole() in a service) and keep the events purely reactive.
Nothing fires for query-builder or bulk operations. DB::table('role_user')->insert(...), upsert, raw SQL, and mass Model::insert() all bypass the relation methods entirely and therefore bypass these events. Same for eager loading — reading relations is silent. If your audit completeness matters, the events are a supplement, not a guarantee; consider DB triggers or a periodic reconciliation for anything regulated.
Octane and long-lived workers. Listeners registered in boot() are attached to the static model class per process. Keep closures stateless — no captured request data, no auth()->user() captured at boot time. Resolve request context inside the closure. In Octane, a boot-time closure capturing a request object will leak user data between requests.
Version-drift is the most likely production incident. This package majors on Laravel majors. Bumping Laravel without bumping the package (or vice versa) will fail at composer update with confusing constraint errors. Add a comment next to the requirement in composer.json noting the coupling, and check the compatibility table on every framework upgrade.
Debugging technique. Drop a single dump($relation, get_class($parent), $related) into a *ed handler with $relation as the first thing you log. Because the events are model-wide, logging the relation name is the fastest way to discover that the event you're seeing is coming from a different relation method than the one you edited. If you get nothing at all, dd() inside boot() after parent::boot() to confirm the registration line is reached.
Wrap it, don't spread it. With ~500 stars, one maintainer, and an explicit "tests, tests, tests" TODO, treat this as a thin convenience layer rather than infrastructure. Put your handlers behind your own domain events or a single RelationshipAuditor service and route the package hooks into it. You'll get testability today and a straightforward migration path later if the package falls behind a Laravel release.
How can I help you explore Laravel packages today?