The Problem With "Just Use an Observer"
Every Laravel developer reaches for an observer the moment they need to react to a model change. Observers are convenient, but convenience without intention produces bloated observer classes that mix unrelated concerns and become impossible to test in isolation.
Model events and observers solve the same problem — reacting to Eloquent lifecycle hooks — but they have meaningfully different trade-offs. Choosing deliberately keeps your codebase maintainable.
Model Events: Inline and Intentional
Model events are closures or method calls registered directly on the model. They are best for simple, model-owned behaviour that has no external dependencies.
// app/Models/Invoice.php
protected static function booted(): void
{
static::creating(function (Invoice $invoice): void {
$invoice->uuid = (string) Str::uuid();
$invoice->number = InvoiceNumberSequence::next();
});
}
This is appropriate because:
- The logic belongs to the model's own invariants.
- There are no injected services or I/O.
- It is trivially tested by creating an
Invoicein a feature test.
The moment you reach for app() or inject a service inside booted(), you have outgrown inline events.
Observers: Coordinating External Side Effects
Observers shine when a model change must trigger external work — sending a notification, dispatching a job, or writing an audit log. The key discipline is keeping each observer focused on a single concern.
// app/Observers/OrderObserver.php
final class OrderObserver
{
public function __construct(
private readonly AuditLogger $audit,
) {}
public function created(Order $order): void
{
$this->audit->record('order.created', $order->id);
}
public function updated(Order $order): void
{
if ($order->wasChanged('status')) {
$this->audit->record('order.status_changed', $order->id, [
'from' => $order->getOriginal('status'),
'to' => $order->status,
]);
}
}
}
Register it in a service provider, not AppServiceProvider:
// app/Providers/DomainServiceProvider.php
public function boot(): void
{
Order::observe(OrderObserver::class);
}
Laravel resolves the observer through the container, so AuditLogger is injected automatically.
The Pitfall: Fat Observers
A single observer handling notifications, cache invalidation, search indexing, and audit logging is a maintenance trap. Split by concern:
OrderObserver → audit log only
OrderSearchObserver → Meilisearch sync
OrderCacheObserver → cache invalidation
Register all three. Each remains small and independently testable.
Testing Observers Without Hitting Real Services
Observers are easy to test when dependencies are injected:
// tests/Unit/Observers/OrderObserverTest.php
it('records a status change audit entry', function (): void {
$audit = Mockery::mock(AuditLogger::class);
$observer = new OrderObserver($audit);
$order = Order::factory()->make(['status' => 'shipped']);
$order->syncOriginal(); // simulate a prior save
$order->status = 'delivered';
$audit->shouldReceive('record')
->once()
->with('order.status_changed', $order->id, Mockery::any());
$observer->updated($order);
});
No database, no HTTP, no queue — pure unit test.
Suppressing Observers When You Need To
Bulk operations should skip observers to avoid thousands of side-effect calls:
Order::withoutObservers(function (): void {
Order::query()->where('migrated', false)->eachById(function (Order $order): void {
$order->update(['migrated' => true]);
});
});
This is also essential in seeders and data migrations where observers would fire redundant jobs.
Decision Checklist
- Use
booted()model events when the logic enforces a model invariant with no I/O. - Use an observer when the side effect involves external services, jobs, or notifications.
- Split observers by concern — one responsibility per class.
- Inject dependencies into observers so they remain unit-testable.
- Use
withoutObservers()in bulk operations and migrations. - Never call
app()insidebooted()— that is a sign you need an observer.
Takeaways
- Model events are for model-owned invariants; observers are for external coordination.
- Fat observers are a code smell — split by concern, not by event type.
- Constructor injection makes observers testable without a database.
withoutObservers()is a first-class tool for bulk data work, not a hack.