Introduction
Observers move model event logic out of the model and into dedicated classes — ideal once a model accumulates more than two or three event listeners.
Key Concepts
- Observer class: A PHP class in
app/Observers/with a method per event (creating,updated,deleted, etc.). make:observergenerator:php artisan make:observer PostObserver --model=Postscaffolds the class with method stubs.#[ObservedBy]attribute: A Laravel 11+ PHP attribute that wires the observer to the model declaratively.- Manual registration: The alternative — calling
Post::observe(PostObserver::class)inbooted()or in a service provider.
Real World Context
Once an observer accumulates more than two or three hooks, a dedicated class keeps the model file readable. Observers also make it easier to test, document, and reason about side effects.
Deep Dive
Observers are classes dedicated to listening to model events. They're ideal when a model has many event listeners or when the logic is complex.
Why Use Observers?
php// ❌ Model becomes cluttered with event logic class Post extends Model { protected static function booted(): void { static::creating(function ($post) { /* 20 lines */ }); static::created(function ($post) { /* 15 lines */ }); static::updating(function ($post) { /* 25 lines */ }); static::deleting(function ($post) { /* 30 lines */ }); } } // ✅ Clean model, logic in dedicated observer class class Post extends Model { // Clean! } class PostObserver { // All event logic organized here }
Creating an Observer
bashphp artisan make:observer PostObserver --model=Post
This creates app/Observers/PostObserver.php:
php<?php namespace App\Observers; use App\Models\Post; use Illuminate\Support\Str; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Log; class PostObserver { /** * Handle the Post "creating" event. * Fires BEFORE the model is saved for the first time. */ public function creating(Post $post): void { // Generate slug $post->slug = Str::slug($post->title); // Set author if not set $post->user_id = $post->user_id ?? auth()->id(); // Generate UUID $post->uuid = Str::uuid(); } /** * Handle the Post "created" event. * Fires AFTER the model is saved for the first time. */ public function created(Post $post): void { Log::info('Post created', ['id' => $post->id, 'title' => $post->title]); // Notify followers $post->user->followers->each(function ($follower) use ($post) { $follower->notify(new NewPostNotification($post)); }); } /** * Handle the Post "updating" event. * Fires BEFORE an existing model is saved. */ public function updating(Post $post): void { // Regenerate slug if title changed if ($post->isDirty('title')) { $post->slug = Str::slug($post->title); } } /** * Handle the Post "updated" event. * Fires AFTER an existing model is saved. */ public function updated(Post $post): void { // Clear cache Cache::forget("post:{$post->id}"); Log::info('Post updated', ['id' => $post->id]); } /** * Handle the Post "saving" event. * Fires BEFORE any save (create or update). */ public function saving(Post $post): void { // Sanitize content $post->body = strip_tags($post->body, '<p><br><strong><em><ul><li>'); } /** * Handle the Post "saved" event. * Fires AFTER any save (create or update). */ public function saved(Post $post): void { // Index for search SearchIndex::update($post); } /** * Handle the Post "deleting" event. * Return false to cancel deletion. */ public function deleting(Post $post): bool { // Prevent deletion if post has comments if ($post->comments()->exists()) { return false; } return true; } /** * Handle the Post "deleted" event. */ public function deleted(Post $post): void { // Clean up associated files if ($post->featured_image) { Storage::delete($post->featured_image); } // Remove from search index SearchIndex::remove($post); Log::info('Post deleted', ['id' => $post->id]); } /** * Handle the Post "restored" event (soft deletes). */ public function restored(Post $post): void { // Re-index for search SearchIndex::update($post); } /** * Handle the Post "forceDeleted" event. */ public function forceDeleted(Post $post): void { // Permanently delete all comments $post->comments()->forceDelete(); } }
Registering Observers
Method 1: Using the #[ObservedBy] Attribute (Laravel 11+)
php<?php namespace App\Models; use App\Observers\PostObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; #[ObservedBy([PostObserver::class])] class Post extends Model { // ... }
Method 2: In the Model's booted() Method
php<?php namespace App\Models; use App\Observers\PostObserver; use Illuminate\Database\Eloquent\Model; class Post extends Model { protected static function booted(): void { static::observe(PostObserver::class); } }
Method 3: In a Service Provider
php// app/Providers/AppServiceProvider.php use App\Models\Post; use App\Observers\PostObserver; public function boot(): void { Post::observe(PostObserver::class); }
Preventing Infinite Loops
Be careful when updating models in observers:
php// ❌ DANGER: Infinite loop! public function updating(Post $post): void { $post->view_count++; // This triggers updating again! } // ✅ SAFE: Use saveQuietly() public function updated(Post $post): void { $post->timestamps = false; // Don't update updated_at $post->saveQuietly(); // Skip events $post->timestamps = true; } // ✅ SAFE: Use DB query directly public function updated(Post $post): void { DB::table('posts') ->where('id', $post->id) ->update(['last_activity' => now()]); }
Conditional Logic in Observers
phppublic function updating(Post $post): void { // Only regenerate slug if title actually changed if ($post->isDirty('title')) { $post->slug = Str::slug($post->title); } // Check what the old value was if ($post->isDirty('status')) { $oldStatus = $post->getOriginal('status'); $newStatus = $post->status; if ($oldStatus === 'draft' && $newStatus === 'published') { $post->published_at = now(); } } }
Observer Best Practices
- Keep observers focused - One observer per model
- Don't make HTTP calls in observers - Use queued jobs instead
- Be careful with relationships - They may not be loaded
- Test your observers - They can cause subtle bugs
- Document side effects - Make it clear what happens when
Common Pitfalls
- Registering the same observer twice — Side effects fire twice. Pick one registration method per observer.
- Mutating the model inside
updating— Causes infinite loops unless you usesaveQuietly. - Making HTTP calls from observers — Blocks the request. Dispatch a queued job instead.
Best Practices
- One observer per model — Keeps scope clear and tests focused.
- Use
#[ObservedBy]for explicit wiring — Cleaner thanbooted()or service-provider registration. - Dispatch queued jobs for side effects — Keep observers fast and the request path lean.
Summary
- Observer classes live in
app/Observers/with one method per model event. - Register via
#[ObservedBy](Laravel 11+),booted(), or a service provider. - Test observers by calling the model methods that trigger them.
- Use
saveQuietly/withoutEventsto avoid infinite loops. - Queue heavy side effects; keep observer code fast.