Table of Contents
The Problem: Race Condition Between Job Queuing and Processing
You fire off a queued event with an Eloquent model, everything looks good, but hours later your queue worker crashes with a ModelNotFoundException. What happened?
When you pass an Eloquent model to a queued event or listener, Laravel doesn’t serialize the entire model object. Instead, it just stores the model’s class name and its ID. When the queue worker picks up the job later, it tries to refetch that model using firstOrFail().
The problem? If the record gets deleted between queuing and processing, your job fatally crashes.
Example: The Classic Gotcha
// In your controller or service
event(new OrderUpdated($order, $changes));
// Meanwhile, in OrderUpdated event class...
class OrderUpdated
{
use SerializesModels;
public $order; // This gets serialized as: Order::class, id: 123
public function __construct(Order $order, array $changes)
{
$this->order = $order;
$this->changes = $changes;
}
}
// Later, when the queue worker processes this job...
// Laravel does: Order::findOrFail(123)
// If order #123 was deleted: ModelNotFoundException!
This is especially common in high-traffic applications where records get created and deleted quickly โ cancelled transactions, temporary data, race conditions between user actions and background cleanup jobs.
Solution 1: Pass Only What You Need
Instead of serializing the entire model, extract just the data you’ll actually need:
class OrderUpdated
{
public $orderId;
public $changes;
public $customerEmail;
public function __construct(Order $order, array $changes)
{
// Extract primitives, not objects
$this->orderId = $order->id;
$this->customerEmail = $order->customer->email;
$this->changes = $changes;
}
}
Now if the order gets deleted, your listener can handle it gracefully โ maybe just log “order no longer exists” instead of crashing.
Solution 2: Handle Missing Models Gracefully
If you do serialize the model, check for null in your listener:
class SendOrderUpdateEmail
{
public function handle(OrderUpdated $event)
{
// Laravel sets the property to null if model can't be restored
if (!$event->order) {
Log::info('Order no longer exists, skipping notification');
return;
}
// Safe to use here
Mail::to($event->order->customer)->send(new OrderChanged($event->order));
}
}
Solution 3: Use Custom Restoration Logic
For more control, override the model restoration behavior:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
class OrderUpdated implements ShouldQueue
{
use SerializesModels {
__sleep as protected traitSleep;
__wakeup as protected traitWakeup;
}
public $order;
public function __wakeup()
{
$this->traitWakeup();
// If model restoration failed, log and set to null
if (!$this->order) {
logger()->warning('Order model could not be restored for queued event');
}
}
}
When to Worry About This
This pattern matters most when:
- Records are short-lived โ temporary carts, pending transactions, OTPs
- Users can delete things โ if delete actions fire cleanup jobs that might race with notification jobs
- You have cascading deletes โ parent record deletion triggers child deletions while jobs are in flight
- Queue delays are significant โ if your queue is backed up, more time = more opportunity for deletions
Takeaway
Queued events with Eloquent models are convenient, but they assume the record still exists when the job runs. For critical paths, consider passing primitives instead of models, or add defensive checks in your listeners. Your queue workers will thank you.
Leave a Reply