Laravel Observers vs. Model Events: Choosing the Right Hook for Side Effects
#laravel #eloquent #observers #testing #architecture

Laravel Observers vs. Model Events: Choosing the Right Hook for Side Effects

3 min read Mohamed Said Mohamed Said

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 creating belongs in booted(). 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 via Model::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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does using `#[ObservedBy]` affect performance compared to registering in a service provider?
No meaningful difference. The attribute is read once during boot and the observer is registered identically to the service provider approach. Choose based on readability — the attribute keeps registration close to the model.
Q02 Can I have multiple observers on the same Eloquent model?
Yes, but they fire in registration order and there is no built-in priority mechanism. Multiple observers on one model often signal that the model has too many responsibilities. Consider consolidating into one observer or extracting domain events.
Q03 Will observers fire when using `Model::query()->update()` or bulk inserts?
No. Mass updates and bulk inserts bypass Eloquent model hydration entirely, so no lifecycle events — and therefore no observers — are triggered. If you need hooks on bulk operations, dispatch an explicit event or job after the query.

Continue reading

More Articles

View all