Laravel Observers vs. Model Events: Choosing the Right Hook for Domain Side-Effects
#laravel #eloquent #domain-events #testing

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

4 min read Mohamed Said Mohamed Said

The Problem Nobody Talks About

Every Laravel developer reaches for creating, updated, or deleted hooks early in a project. They work — until they don't. Bulk updates skip them entirely, transactions roll back after the hook already fired, and test suites become brittle because observers registered in AppServiceProvider bleed across test cases.

This article is about making deliberate choices, not just reaching for whichever API is closest.


Model Events: Inline and Immediate

Model events are dispatched by Eloquent's internal fireModelEvent() call. You can listen to them directly on the model:

protected static function booted(): void
{
    static::created(function (Order $order): void {
        Cache::tags('orders')->flush();
    });
}

This is fine for low-stakes, synchronous cache busting that belongs to the model's own concern. The closure lives next to the model, is easy to read, and is automatically unregistered when the model class is garbage-collected.

The Bulk-Update Trap

// This fires ZERO model events:
Order::where('status', 'pending')->update(['status' => 'expired']);

Eloquent's Builder::update() goes straight to the query layer. No model is hydrated, no event fires. If your observer sends emails on updated, those emails will never arrive for bulk operations. This is the most common silent bug I see in production codebases.


Observers: Organised, but Still Synchronous

An observer groups all lifecycle hooks for a model into one class:

class OrderObserver
{
    public function created(Order $order): void
    {
        dispatch(new SendOrderConfirmation($order->id));
    }

    public function deleted(Order $order): void
    {
        dispatch(new ReleaseInventory($order->id));
    }
}

Register it in a service provider:

Order::observe(OrderObserver::class);

Observers are cleaner than scattered closures, but they share the same fundamental limitation: they fire before the surrounding database transaction commits.

Transaction Safety with afterCommit

Laravel's queue system respects $afterCommit = true on a job, but the observer itself fires immediately. If the transaction rolls back, your dispatched job has already been pushed to the queue.

The correct pattern:

class SendOrderConfirmation implements ShouldQueue
{
    public bool $afterCommit = true;
    // ...
}

Alternatively, wrap the dispatch explicitly:

public function created(Order $order): void
{
    DB::afterCommit(fn () => dispatch(new SendOrderConfirmation($order->id)));
}

DB::afterCommit() (available since Laravel 9) queues the callback until the outermost transaction commits, or runs it immediately when there is no active transaction.


Test Isolation

Observers registered globally in AppServiceProvider fire during every test. This causes:

  • Unexpected mail/queue side-effects in unit tests
  • Slow test suites because observers hit external services
  • False positives when a test passes only because an observer mutated state

Disable observers per test:

it('calculates order total without side effects', function () {
    Order::withoutObservers(function () {
        $order = Order::factory()->create(['subtotal' => 100]);
        expect($order->total)->toBe(110); // tax applied by cast, not observer
    });
});

Or use a dedicated WithoutModelEvents trait in a Pest uses() call for an entire test file:

uses(WithoutModelEvents::class);

When to Use What

| Scenario | Recommendation | |---|---| | Cache invalidation tightly coupled to model | Inline booted() closure | | Cross-cutting concerns (audit log, search index) | Observer class | | Side-effects that must survive a transaction | Observer + DB::afterCommit() | | Bulk operations | Explicit service method, no observer reliance | | Domain events with multiple listeners | Dedicated event + listeners, not model events |


Key Takeaways

  • Bulk update() / delete() queries never fire model events or observer hooks.
  • Always wrap queue dispatches in DB::afterCommit() or use $afterCommit = true on the job to prevent side-effects from rolled-back transactions.
  • Use Order::withoutObservers() or WithoutModelEvents in tests to keep assertions focused.
  • For complex domain reactions with multiple consumers, prefer a proper Event + Listener pair over an observer — it scales better and is easier to test in isolation.
  • Observers are an organisational tool, not a reliability guarantee.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do Eloquent observers fire when using `Model::query()->update()`?
No. Any query-builder-level update or delete — including `Model::where(...)->update()` — bypasses Eloquent's event system entirely because no model instances are hydrated. You must iterate with `each()` or `cursor()` if you need events to fire, or handle the side-effect explicitly in a service method.
Q02 What is the difference between `DB::afterCommit()` and setting `$afterCommit = true` on a job?
`$afterCommit = true` on a job tells Laravel's queue dispatcher to hold the job in memory until the outermost transaction commits before actually writing it to the queue backend. `DB::afterCommit()` is a general-purpose callback that runs any code after commit, not just job dispatches. Both solve the same transaction-safety problem; use `$afterCommit` for jobs and `DB::afterCommit()` for arbitrary side-effects like sending notifications directly.
Q03 Should I register observers in `AppServiceProvider` or in a dedicated provider?
For small applications, `AppServiceProvider` is fine. In a modular monolith or bounded-context architecture, register observers inside the domain's own service provider so each module is self-contained and the registration is co-located with the domain code it belongs to.

Continue reading

More Articles

View all