The Laravel Observer Ordering Bug That Only Bites Under Queue Pressure
Model observers and event listeners both feel like the right tool until queues get involved. Here's the subtle ordering bug I kept hitting in production.
Model observers and event listeners solve the same surface-level problem — react when something happens to a model — but they don't behave identically under load, and that difference has burned me more than once. The bug I'm going to describe doesn't show up in php artisan tinker, doesn't show up in feature tests with Queue::fake(), and doesn't show up on a staging server with two workers. It shows up at 2am when your production queue depth is 4,000 jobs and a customer is missing their confirmation email.
What Both Tools Actually Do
Quick level-set before I get into the bug.
A model observer hooks directly into Eloquent's lifecycle events — creating, created, updating, updated, deleting, deleted, etc. You register a class and Laravel calls the corresponding method synchronously, in the same request cycle, before control returns to you.
An event listener attached to a model event (via $dispatchesEvents on the model, or by listening for eloquent.created: App\Models\Order in your EventServiceProvider) is functionally similar when it's synchronous. But listeners can be queued. That's where the divergence starts.
// Observer — registered in AppServiceProvider or ObserverServiceProvider
Order::observe(OrderObserver::class);
// OrderObserver.php
class OrderObserver
{
public function created(Order $order): void
{
// This runs synchronously, right now, in this request
InventoryService::reserve($order);
}
}
// Event listener approach — model fires the event
class Order extends Model
{
protected $dispatchesEvents = [
'created' => OrderCreated::class,
];
}
// Listener implements ShouldQueue
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderCreated $event): void
{
Mail::to($event->order->customer)->send(new OrderConfirmationMail($event->order));
}
}
This looks clean. It is clean — right up until the queue gets involved.
The Bug
Here's the scenario that bit me on a real project — an e-commerce client with variable order volume and a shared Redis queue for multiple job types.
We had an observer doing a synchronous post-create action (reserving inventory, which needed to be immediate) and a queued listener sending the confirmation email. That separation seemed intentional and correct. Inventory reservation is side-effect-heavy and needs to be atomic with the request. Email can wait.
The problem: when the queue is under pressure, the queued listener sometimes serializes and dispatches the job before the database transaction has fully committed and replicated.
This is the sequence that breaks things:
- Controller opens a DB transaction.
Order::create([...])fires inside the transaction.- Eloquent's
createdevent fires — still inside the transaction. OrderCreatedevent is dispatched. The queued listener serializes theOrdermodel (or just its ID) and pushes the job to Redis. This happens beforeDB::commit().- Transaction commits.
- A queue worker picks up the job — potentially within milliseconds, before replication lag resolves on a read replica.
Order::find($event->order->id)inside the listener returnsnullor stale data.- Exception. Job retries. Retry hits the same read replica. Same null. Three retries later: failed job, no email, angry customer.
The observer doesn't have this problem because it ran synchronously and didn't serialize anything to Redis. The queued listener does because it detaches from the request lifecycle the moment it hits the queue.
Reproducing It
You won't see this with Queue::fake() because that doesn't actually run workers. You won't see it with QUEUE_CONNECTION=sync in .env. You need real async workers and a real (or simulated) transaction boundary.
Here's the minimal reproduction:
// In a controller or command
DB::transaction(function () {
$order = Order::create([
'customer_id' => 1,
'total' => 99.99,
'status' => 'pending',
]);
// OrderCreated queued listener dispatches here, BEFORE commit
});
// Commit happens after the closure returns
Run php artisan queue:work with high concurrency (--sleep=0 --tries=3) and hammer the endpoint. On a fast machine with Redis, the worker will occasionally win the race.
The Fix
Laravel has had afterCommit on queued listeners since 8.x. It's one line:
class SendOrderConfirmation implements ShouldQueue
{
public $afterCommit = true;
public function handle(OrderCreated $event): void
{
Mail::to($event->order->customer)->send(new OrderConfirmationMail($event->order));
}
}
With $afterCommit = true, Laravel holds the job dispatch until the surrounding transaction commits. If there's no transaction, it dispatches immediately. If the transaction rolls back, the job is never dispatched. This is almost always what you want for queued side effects.
You can also set this globally in config/queue.php:
'connections' => [
'redis' => [
'driver' => 'redis',
'after_commit' => true, // dispatch all jobs after transaction commits
// ...
],
],
I'd set it globally and only opt out explicitly when you have a reason to dispatch inside the transaction (which is rare and usually wrong).
The Subtler Observer Problem
Observers have their own version of this. If you're dispatching jobs from inside an observer, you inherit the same race condition:
class OrderObserver
{
public function created(Order $order): void
{
// This queues a job from inside the observer, which runs inside the transaction
ProcessOrderFulfillment::dispatch($order->id); // same bug
}
}
The observer itself is synchronous, but the dispatch() call inside it isn't. You're back to the same race. The fix is the same — either use afterCommit on the job, or use DB::afterCommit(fn() => ProcessOrderFulfillment::dispatch($order->id)) inline.
class OrderObserver
{
public function created(Order $order): void
{
$id = $order->id;
DB::afterCommit(fn() => ProcessOrderFulfillment::dispatch($id));
}
}
I actually prefer this pattern when I need the observer for the synchronous part (validation, cache busting, something that has to happen in-request) but also need to kick off async work. It's explicit. Anyone reading the code sees the transaction boundary acknowledged.
The Real Difference Between Observers and Listeners
After years of reaching for both, here's how I actually think about it:
Observers are for synchronous, model-local concerns. Cache invalidation. Audit log writes. Slug generation. Things that should feel like part of saving the model. They're convenient because you get all lifecycle hooks in one class, and they're registered cleanly without touching EventServiceProvider.
Event listeners (queued or not) are for cross-cutting concerns that belong to the domain, not the model. Sending email, triggering webhooks, updating external systems. Things that could fail without the whole operation failing. They're more explicit — you have to define the event class, wire it in the model, register the listener — but that explicitness is a feature when you're debugging at 2am.
The mistake I see most often: using a queued listener for something that looks async but actually has ordering constraints, and not thinking hard enough about the transaction boundary.
When I'd Reach for Each
Reach for an observer when:
- The side effect is synchronous and tightly coupled to the model's state
- You want all lifecycle hooks in one place for a busy model
- You're doing things like auto-filling fields, generating UUIDs, touching timestamps on related models
Reach for event listeners when:
- Multiple parts of the system care about the same model event (observer gets messy fast with multiple concerns)
- The action is async and queued
- You want the listener to be independently testable without loading the model
- You need to be able to add new reactions without touching the model or its observer
In both cases: if you're dispatching queued jobs anywhere near a database transaction, set afterCommit = true and stop thinking about it.
The Test You Should Write
This bug is testable if you set up correctly:
public function test_order_confirmation_dispatches_after_commit(): void
{
Queue::fake();
DB::transaction(function () {
Order::factory()->create();
// Job should NOT be dispatched yet
Queue::assertNothingPushed();
});
// Now it should be dispatched
Queue::assertPushed(SendOrderConfirmation::class);
}
This test fails without $afterCommit = true and passes with it. Add it to your suite. You'll thank yourself later.
I've shipped observers and I've shipped event listeners and I've gotten burned by both in different ways. The ordering bug is the sneaky one because it's invisible in development and catastrophic at scale. afterCommit is the right default for any queued work that touches data you just wrote. The fact that it isn't the framework default is, frankly, a design mistake Laravel should correct — but until they do, you have to remember to set it yourself.
Need help shipping something like this? Get in touch.