Skip to content

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

Model events and observers both react to Eloquent lifecycle changes, but picking the wrong one creates tangled, untestable code. Here is a precise guide to when each belongs in a production Laravel codebase.

Published · Updated · 4 min read

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

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.

Frequently asked questions

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.

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.

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*.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project