Observers — grouping model events into a class
Concept
Observers consolidate all of a model's event listeners into a single class, replacing scattered closures or $dispatchesEvents mappings. You create an observer with php artisan make:observer PostObserver --model=Post. The class has public methods named after the events — creating, created, updating, updated, saving, saved, deleting, deleted, retrieved, replicating — and you register it in a service provider or in the model's booted() method via static::observe(PostObserver::class).
The observer pattern is a direct application of the Single Responsibility Principle: the model stays focused on its schema and relationships, while the observer owns cross-cutting concerns like audit logging, cache invalidation, search index updates, and notification dispatching. This separation is measurable — you can test the observer in isolation by instantiating it directly and calling its methods with a fake model, without touching the database.
Under the hood, Model::observe() calls static::registerModelEvent($event, [$observer, $event]) for each event method found on the observer class via reflection. This is equivalent to registering individual listeners in booted() but expressed as a cohesive class. Multiple observers can be registered for the same model; they execute in registration order.
One gotcha: observers only fire when Eloquent model instances are used. Mass updates (Post::where('active', false)->update(['active' => true])) bypass the model entirely and go straight to the query builder, so observers never fire. If your observer logic must run for bulk operations, you need database triggers or must iterate models individually — which has obvious performance implications. This is the key trade-off to communicate in interviews.
| Registration method | Where to use |
|---|---|
static::observe(PostObserver::class) in booted() | Small apps, tight model/observer coupling |
$model->observe(PostObserver::class) in AppServiceProvider::boot() | Preferred for testability |
EventServiceProvider via $observers (Laravel 11+) | Framework-provided convention |
Code Example
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Post;
use App\Services\SearchIndex;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class PostObserver
{
public function __construct(private readonly SearchIndex $search) {}
// Fires before INSERT — can return false to cancel
public function creating(Post $post): void
{
$post->word_count = str_word_count($post->body ?? '');
}
// Fires after INSERT — SQL already ran
public function created(Post $post): void
{
$this->search->index($post); // add to search index
Cache::tags(['posts'])->flush();
Log::info("Post {$post->id} created by user " . auth()->id());
}
// Fires after UPDATE
public function updated(Post $post): void
{
$this->search->update($post);
Cache::forget("post:{$post->id}");
}
// Fires before DELETE — return false to cancel
public function deleting(Post $post): bool|void
{
if ($post->is_featured) {
return false; // block deletion of featured posts
}
}
// Fires after DELETE
public function deleted(Post $post): void
{
$this->search->remove($post->id);
Cache::tags(['posts'])->flush();
}
}
// --- Registration in AppServiceProvider ---
namespace App\Providers;
use App\Models\Post;
use App\Observers\PostObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Post::observe(PostObserver::class);
// Container injects SearchIndex automatically via constructor
}
}
// --- Usage (observer fires automatically) ---
$post = Post::create(['title' => 'Observers', 'body' => 'Some content here']);
// creating: sets word_count = 3
// SQL: INSERT INTO posts (title, body, word_count) VALUES (...)
// created: indexes post, flushes cache, logs creation
$post->title = 'Updated Title';
$post->save();
// SQL: UPDATE posts SET title = 'Updated Title' WHERE id = 1
// updated: re-indexes, busts cache
// WARNING: Observers do NOT fire for mass updates:
Post::where('active', false)->update(['active' => true]);
// SQL: UPDATE posts SET active = 1 WHERE active = 0
// No observer fires — use with cautionInterview Q&A
Q: What problem do Eloquent observers solve compared to registering closures in booted()?
Closures in booted() work but grow unwieldy as a model accumulates side effects. You end up with a long list of anonymous functions inside the model that are hard to test in isolation, impossible to inject dependencies into cleanly, and invisible to static analysis. An observer collects all event handlers in a named class with constructor injection — the container resolves it, so dependencies like a search service or cache are injected automatically. You can unit-test the observer by calling its methods directly. The model itself stays lean, knowing nothing about search indexes or caches.
Q: Mass updates bypass observers — what strategies exist to handle this gap?
There are three main approaches. First, iterate records individually: Post::where(...)->each(fn ($p) => $p->update([...])) — correct but slow for large sets. Second, fire events manually after a mass update using event(new PostsBulkUpdated(...)) with a custom event that carries enough context for listeners. Third, use database-level triggers for truly audit-critical data that must never miss an event, accepting that triggers are harder to test and version-control. The right answer depends on data volume and the criticality of the side effect — there is no universal solution, which is itself the senior answer.
Q: Can multiple observers be registered on the same model, and what happens when two observers both return false from a deleting hook?
Yes, multiple observers can be registered. Laravel registers each observer's methods as individual listeners on the Eloquent event in the order observe() was called. If the first deleting listener returns false, Illuminate\Events\Dispatcher marks the event as halted and stops calling subsequent listeners. Subsequent observers' deleting methods will not run. This is consistent with how Laravel's event dispatcher handles halt: true propagation, which Eloquent enables by passing true as the halt argument to fireModelEvent.