Introduction
Eloquent fires events at key lifecycle moments — before/after save, before/after delete, on retrieval — so you can hook in without touching every call site. Observers are the tidy way to collect those hooks in one class per model instead of sprinkling closures in booted().
Key Concepts
- Model event: A signal fired by Eloquent during a lifecycle operation (creating, created, updating, updated, deleting, deleted, saving, saved, etc.).
- Observer: A class that declares a method per event it wants to handle.
#[ObservedBy]: A PHP attribute (Laravel 11+) that wires a model to its observer declaratively.saveQuietly(): A save that doesn't fire events — used inside observers to avoid infinite loops.
Real World Context
Auto-generating slugs, cascading cache invalidation, stamping audit trails, sending welcome emails — all classic model-event use cases. Putting them in an observer instead of the controller means every code path that creates a Post gets the slug, not just the one you remembered.
Deep Dive
Model Events
Eloquent fires these events:
| Event | When Fired |
|---|---|
retrieved | After model is retrieved from database |
creating | Before model is created |
created | After model is created |
updating | Before model is updated |
updated | After model is updated |
saving | Before model is created OR updated |
saved | After model is created OR updated |
deleting | Before model is deleted |
deleted | After model is deleted |
trashed | After model is soft deleted |
forceDeleting | Before force deleting |
forceDeleted | After force deleting |
restoring | Before restoring soft deleted |
restored | After restoring soft deleted |
replicating | When model is replicated |
Using Closures in Models
phpclass User extends Model { protected static function booted(): void { static::creating(function (User $user) { $user->uuid = Str::uuid(); }); static::created(function (User $user) { $user->profile()->create(); }); static::deleting(function (User $user) { $user->posts()->delete(); }); } }
Creating Observers
bashphp artisan make:observer UserObserver --model=User
This creates app/Observers/UserObserver.php:
php<?php namespace App\Observers; use App\Models\User; class UserObserver { public function creating(User $user): void { $user->uuid = Str::uuid(); } public function created(User $user): void { $user->profile()->create(['bio' => '']); $user->notify(new WelcomeNotification()); } public function updated(User $user): void { if ($user->isDirty('email')) { $user->email_verified_at = null; $user->saveQuietly(); // Don't trigger events $user->sendEmailVerificationNotification(); } } public function deleted(User $user): void { Log::info('User deleted', ['user_id' => $user->id]); } public function forceDeleted(User $user): void { Storage::delete($user->avatar_path); } }
Registering Observers
In AppServiceProvider:
phpuse App\Models\User; use App\Observers\UserObserver; public function boot(): void { User::observe(UserObserver::class); }
Or using the attribute:
phpuse App\Observers\UserObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; #[ObservedBy(UserObserver::class)] class User extends Model { // ... }
Preventing Infinite Loops
Use saveQuietly() to avoid triggering events:
phppublic function updated(User $user): void { // This would cause infinite loop: // $user->update(['last_activity' => now()]); // Use saveQuietly instead: $user->last_activity = now(); $user->saveQuietly(); }
Conditional Logic
phppublic function saving(User $user): bool|void { // Return false to cancel the operation if ($user->isBanned()) { return false; } } public function deleting(User $user): bool|void { // Prevent deletion if user has active subscriptions if ($user->hasActiveSubscription()) { return false; } }
Complete Observer Example
php<?php namespace App\Observers; use App\Models\Post; use App\Jobs\GeneratePostSlug; use App\Jobs\NotifySubscribers; use Illuminate\Support\Str; class PostObserver { public function creating(Post $post): void { $post->user_id ??= auth()->id(); $post->slug ??= Str::slug($post->title); } public function created(Post $post): void { cache()->forget('posts:count'); if ($post->published_at) { NotifySubscribers::dispatch($post); } } public function updating(Post $post): void { if ($post->isDirty('title')) { $post->slug = Str::slug($post->title); } } public function updated(Post $post): void { cache()->forget("post:{$post->id}"); cache()->forget('posts:latest'); } public function deleted(Post $post): void { cache()->forget("post:{$post->id}"); cache()->forget('posts:count'); } }
Common Pitfalls
- Infinite loops from
update()insideupdated()— callingupdate()from anupdatedobserver firesupdatingandupdatedagain, forever. UsesaveQuietly()or$model->setAttribute(...)+$model->save()with quieting to break the cycle. - Observers not firing on mass updates —
Model::query()->update([...])skips Eloquent events entirely because it's a raw SQL update. If you need the events, iterate and save each model individually.
Best Practices
- Use
#[ObservedBy]on the model — declarative, grep-able, and impossible to forget inAppServiceProvider. - Return
falsefromsaving/creating/deletingto cancel — cleaner than throwing an exception when you want to veto an operation.
Summary
- Eloquent fires lifecycle events (
creating,created,updating,updated,deleting,deleted, etc.). - Observers collect handlers for a model in one class.
#[ObservedBy(SomeObserver::class)]attaches the observer declaratively.- Use
saveQuietly()inside observers to avoid event recursion.