Moving listeners onto background queues is the standard procedure in Laravel. You fire an event when a user finishes checkout, respond with an HTTP response, and let queue workers handle the heavy processing parts like sending them receipt emails, generating PDF invoices, notifying 3rd party fulfillment services, and updating ERP systems.
But there are two significant operational problem while attaching listeners to a queue:
- The Uncommitted Transaction Race Condition: The event is fired within a database transaction but the queue worker picks up and processes the queued listener before the database transaction has committed. The worker looks for the new record but can't find it and throws a ModelNotFoundException.
- Listener Failure & Retries: If an external API (mailer, webhook) is down the listener fails. Without appropriate failure handling either the job fails silently or retries blindly creating duplicate side effects.
In this tutorial, you will learn how queued event listeners execute, how to eliminate database transaction race conditions using transactional events, and how to configure fine-grained retries, backoff intervals, and custom failure hooks.
1. Converting a Listener to a Queued Listener
To make an event listener run asynchronously on your queue workers, implement the Illuminate\Contracts\Queue\ShouldQueue interface on the listener class:
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderNotification implements ShouldQueue
{
use InteractsWithQueue;
// The name of the connection the job should be sent to.
public string $connection = 'redis';
// The name of the queue on which the listener should be placed.
public string $queue = 'notifications';
// The time (in seconds) before the job should be processed.
public int $delay = 5;
public function handle(OrderPlaced $event): void
{
// Executes in the background via queue workers
$order = $event->order;
// Send email or push notification...
}
}
When OrderPlaced is fired, Laravel will serialize the event payload and then push a queued job (actually, an instance of Illuminate\Events\CallQueuedListener) onto your queue driver. This is all done without waiting for the response to complete.
2. The Race Condition: Firing Events Inside Transactions
Consider this standard controller code:
// Susceptible to race conditions
DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
// Dispatches the event IMMEDIATELY to Redis
event(new OrderPlaced($order));
// Heavy processing before transaction commits...
sleep(1);
});
Here is what is going on behind the scenes:
- The web request starts an SQL transaction and inserts the order with id = 45. The row is locked and not yet committed.
- event(new OrderPlaced($order)) pushes the queued listener to Redis right away.
- A fast queue worker pulls the job off of Redis in 20 milliseconds.
- The queue worker then queries the database: SELECT FROM orders WHERE id = 45.
- Since the web request transaction has not yet committed, the database query returns empty. The worker then crashes with ModelNotFoundException.
The Solution: Transactional Events (afterCommit)
Laravel provides two clean ways to delay dispatching until after the parent database transaction commits successfully.
Approach A: Implementing ShouldDispatchAfterCommit on the Event
If an event should never be queued until the active database transaction commits, implement the Illuminate\Contracts\Events\ShouldDispatchAfterCommit contract on the event class:
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced implements ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Order $order
) {}
}
Now, whenever OrderPlaced::dispatch($order) is called inside DB::transaction(), Laravel intercepts the dispatch, waits for the COMMIT of the database transaction, and only then pushes the event to the queue.
In the case of an error on the database level and calling ROLLBACK, Laravel simply discards the event. Your queue will never process phantom orders from failed transactions.
Approach B: Setting $afterCommit on the Listener
If you don't control the event class, or if only one specific listener needs to wait for the database commit, you can set the $afterCommit property directly on the listener:
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class GeneratePdfInvoice implements ShouldQueue
{
use InteractsWithQueue;
/**
* Only dispatch this listener after open database transactions commit.
*/
public bool $afterCommit = true;
public function handle(OrderPlaced $event): void
{
// Guaranteed to find the persisted Order row in the database
}
}
3. Managing Listener Retries and Backoffs
Queued listeners can be failed due to network issues, rate limiting or external server downtime. By default queue workers will retry failed jobs according to your global worker settings. You may however, define custom retry policies within the listener class itself.
Configuring tries, backoff, and maxExceptions
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Http;
use Exception;
class SyncOrderToThirdPartyErp implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 5;
public array $backoff = [15, 60, 180];
public int $maxExceptions = 3;
public function handle(OrderPlaced $event): void
{
$response = Http::timeout(5)->post('https://erp.internal/api/orders', [
'order_id' => $event->order->id,
'total' => $event->order->total_amount,
]);
if ($response->failed()) {
throw new Exception("ERP Sync failed with status: {$response->status()}");
}
}
}
With $backoff = [15, 60, 180], the first retry waits 15 seconds, the second waits 1 minute, and the third waits 3 minutes before trying again.
4. Conditional Queueing: shouldQueue()
Sometimes you just want to queue the listener based under a condition. Say you want to notify the user right away for VIP orders or do not do anything at all for test orders.
Define a shouldQueue() method on your listener:
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
class NotifyWarehouseDispatch implements ShouldQueue
{
/**
* Determine whether the listener should be pushed to the queue.
*/
public function shouldQueue(OrderPlaced $event): bool
{
// Only queue if the order requires physical delivery
return ! $event->order->is_digital;
}
public function handle(OrderPlaced $event): void
{
// Warehouse dispatch notification logic
}
}
If shouldQueue() returns false, Laravel drops the listener execution without adding a job to your queue.
5. Handling Permanent Listener Failures with failed()
When a listener depletes all of its retry attempts, Laravel will mark the job as failed in your failed_jobs database table. You can catch these terminal failures directly from your listener using the failed() method to notify your team or perform any other necessary cleanup activities.
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncOrderToThirdPartyErp implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
public function handle(OrderPlaced $event): void
{
// Main sync logic...
}
public function failed(OrderPlaced $event, Throwable $exception): void
{
Log::critical("Permanent failure syncing Order #{$event->order->id} to ERP", [
'order_id' => $event->order->id,
'exception' => $exception->getMessage(),
]);
// Flag the order in the database for manual administrative review
$event->order->update([
'erp_sync_failed' => true,
]);
}
}
Manual Retries and Releases Inside handle()
Instead of throwing unhandled exceptions, you can manage retry releases directly using the InteractsWithQueue trait:
namespace App\Listeners;
use App\Events\PaymentWebhookReceived;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class ProcessStripeWebhook implements ShouldQueue
{
use InteractsWithQueue;
public function handle(PaymentWebhookReceived $event): void
{
// Check if rate limited
if ($this->isRateLimited()) {
// Release the job back onto the queue to try again in 30 seconds
$this->release(30);
return;
}
// Process webhook...
}
protected function isRateLimited(): bool
{
// Custom rate check
return false;
}
}
Queued Listener Configuration Options Reference
| Property / Method | Type | Purpose |
|---|---|---|
$afterCommit |
bool |
Delays queueing until the database transaction completes. |
$connection |
string |
Custom queue connection (e.g. redis, sqs). |
$queue |
string |
Specific queue name (e.g. notifications, high). |
$tries |
int |
Number of retry attempts before terminal failure. |
$backoff |
int|array |
Seconds to wait before retrying a failed attempt. |
shouldQueue() |
method |
Determines dynamically if the listener should be dispatched. |
failed() |
method |
Executes cleanup logic when all retry attempts fail. |
Things to Remember
- 📌 Serialization of Models: Make sure your event utilize the SerializesModels trait. When your event is queued, Eloquent models are not stored as PHP objects in Redis, but their IDs and class names are serialized. The worker will query fresh model instances when executing the job.
- ⚡️ Beware of Idempotency: As your listener may be executed more than once due to job retries, make sure your handle() method is idempotent. You should never charge a customer twice or send them three identical order confirmation emails.
- Use flags or transaction logs to ensure that a given action has not already been performed before.
- ⚠️ Failed Job Table: Make sure you've created the failed jobs database table by running php artisan queue:failed-table followed by php artisan migrate so that exhausted listeners can be inspected and retried via php artisan queue:retry
Conclusion
Queued event listeners are an excellent way to keep your application’s response time quick by offloading long-running tasks to workers while also enabling them to handle heavy lifting. By using ShouldDispatchAfterCommit to avoid transaction races, exponentially backing off on database dependent jobs and utilizing Laravel’s failed() method on listeners, you can create a robust and reliable event queue in Laravel.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬