Laravel Observers vs. Model Events: Choosing the Right Hook for Side Effects
#laravel #eloquent #observers #model-events #clean-code

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

4 min read Mohamed Said Mohamed Said

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 Invoice in 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() inside booted() — 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I register multiple observers on the same model?
Yes. Call `Model::observe()` multiple times in your service provider, once per observer class. Laravel fires each registered observer in registration order for every lifecycle event.
Q02 Do observers fire when using Eloquent mass updates like `Model::query()->update()`?
No. Mass updates bypass the Eloquent model lifecycle entirely, so neither model events nor observers are triggered. You must iterate with `each()` or `eachById()` if you need observers to fire.
Q03 Should observers dispatch jobs or perform the work inline?
Prefer dispatching a queued job from the observer. Doing heavy work inline blocks the request and makes the observer harder to test. Keep the observer thin — it should only decide *that* something should happen, not *how*.

Continue reading

More Articles

View all