The Problem With Inline Model Events
Eloquent ships with a rich lifecycle: creating, created, updating, updated, saving, saved, deleting, deleted, and more. The quickest way to hook into them is directly inside the model:
// App\Models\Order.php
protected static function booted(): void
{
static::created(function (Order $order) {
SendOrderConfirmationEmail::dispatch($order);
InventoryService::reserve($order);
AuditLog::record('order.created', $order->id);
});
}
This works — until it doesn't. Three side effects buried in booted() make the model hard to read, impossible to disable in tests without hacks, and a magnet for more logic over time.
What Observers Actually Buy You
An observer moves each event into a named method on a dedicated class, giving you a single place to reason about lifecycle reactions:
// App\Observers\OrderObserver.php
final class OrderObserver
{
public function __construct(
private readonly AuditLogger $logger,
) {}
public function created(Order $order): void
{
SendOrderConfirmationEmail::dispatch($order);
$this->logger->record('order.created', $order->id);
}
public function deleted(Order $order): void
{
$this->logger->record('order.deleted', $order->id);
}
}
Register it in a service provider (or via #[ObservedBy] in Laravel 10+):
// Using the attribute — no service provider registration needed
#[ObservedBy(OrderObserver::class)]
class Order extends Model {}
Because the observer is resolved through the container, constructor injection works out of the box. That alone is worth the switch from closures.
When to Stick With Inline Events
Observers are not always the right tool:
- Simple, single-purpose hooks — setting a UUID or slug on
creatingbelongs inbooted(). It is model-internal behaviour, not a cross-cutting side effect. - Package models you do not own — you cannot add
#[ObservedBy]to a vendor class; register viaModel::observe()in a service provider instead. - Conditional registration — if the hook only applies in certain contexts (e.g., a specific tenant feature flag), a closure in a service provider is clearer than an observer that checks a flag on every event.
// Good: model-internal concern stays in booted()
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->uuid ??= (string) Str::uuid();
});
}
Testing: Faking Observers Without Touching the Database
The biggest win observers give you is testability. Pest makes it trivial:
use App\Observers\OrderObserver;
use App\Models\Order;
it('dispatches confirmation email after order creation', function () {
Mail::fake();
$order = Order::factory()->create();
Mail::assertQueued(OrderConfirmationMail::class, fn ($mail) =>
$mail->order->is($order)
);
});
Need to suppress the observer entirely for a test that does not care about side effects?
it('calculates totals correctly', function () {
Order::withoutObservers(function () {
$order = Order::factory()->create(['subtotal' => 100]);
expect($order->total)->toBe(110); // tax applied via cast
});
});
withoutObservers is a first-class Laravel API — no monkey-patching required.
Avoiding the Observer Bloat Trap
Observers can become a second model if you are not careful. Keep them thin:
- Dispatch jobs, do not execute work. The observer fires synchronously inside the request cycle. Heavy logic belongs in a queued job.
- One observer per model. Multiple observers on the same model fire in registration order — a subtle source of bugs.
- No cross-model writes. An observer that saves a related model triggers that model's observers, creating hard-to-trace chains. Use a dedicated action or service instead.
Takeaways
- Use
booted()closures for model-internal concerns (default values, derived attributes). - Use observers for cross-cutting side effects that benefit from DI and named methods.
- Prefer
#[ObservedBy]in Laravel 10+ to keep registration co-located with the model. - Keep observers as dispatchers, not executors — push real work into queued jobs.
- Use
Model::withoutObservers()in tests to isolate the unit under test cleanly.