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 = trueon the job to prevent side-effects from rolled-back transactions. - Use
Order::withoutObservers()orWithoutModelEventsin tests to keep assertions focused. - For complex domain reactions with multiple consumers, prefer a proper
Event+Listenerpair over an observer — it scales better and is easier to test in isolation. - Observers are an organisational tool, not a reliability guarantee.