Silent failures, duplicate sends and the retry trap you’re probably running in production right now.

A user emails you. They received the same invoice three times. You check the logs. The job ran once, succeeded and there are no retries recorded. Horizon shows everything green. You spend an hour on it, find nothing and chalk it up to a “one-time thing.”
It was not a one-time thing.
Laravel’s queue system is genuinely good. But it comes with a set of failure modes that bite experienced engineers because they look like normal behavior from the outside. Jobs appear to succeed. Logs show no errors. Horizon is calm. Meanwhile, your users are getting duplicated emails, silently dropped notifications and phantom state updates that happen twice or not at all.
This is not about beginner mistakes. It is about the production edge cases that the documentation covers in footnotes, and that only become obvious after something goes wrong at 2 AM.
The Timeout Trap That Runs Your Job Twice
This is the most common source of duplicate side effects, and it is caused by a configuration mismatch almost every team makes at least once.
Laravel’s queue system has two separate timeout concepts that need to stay in sync:
--timeout(on the queue worker): How long a worker waits before killing a job processretry_after(in your queue connection config): How long before Laravel assumes a job is lost and re-queues it
The problem: if retry_after is shorter than --timeout, Laravel will re-queue the job while the original is still running. You end up with two workers executing the same job simultaneously.
The Laravel docs warn about this, but it is easy to miss when you inherit a codebase or deploy with default config. The default retry_after for Redis queues is 90 seconds. If you have a job that can run for two minutes and you have not explicitly set --timeout, you have a race condition that fires every time that job gets slow.
The fix is simple: retry_after should always be higher than your worker's --timeout, and your --timeout should be higher than any job's actual expected runtime. A safe margin is at least 30 seconds.
# In your Supervisor config or deployment script
php artisan queue:work redis \
--timeout=120 \
--tries=3 \
--backoff=30 // In config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 180, // Always higher than --timeout
'block_for' => null,
],
If you are using Laravel Horizon, set timeout per queue in config/horizon.php. Do not rely on the global default.
“It Succeeded”: The Silent No-Op Failure
A job can complete without errors and still accomplish nothing. This is the failure mode that hurts most because nothing in your monitoring will catch it.
The scenario: your job fetches a model, does some work on it, and saves. But between the time the job was dispatched and the time it runs, something changed. The record was deleted. Another process updated the same field. The user’s subscription status changed. Your job runs on stale data, produces a result that is immediately overwritten or makes no sense and returns without complaint.
// This looks fine. It is not.
class SendInvoiceJob implements ShouldQueue
{
public function __construct(public int $invoiceId) {}
public function handle(): void
{
$invoice = Invoice::find($this->invoiceId);
// If invoice was deleted between dispatch and handle(),
// this silently does nothing. No exception. No log. Nothing.
if (!$invoice) {
return;
}
$invoice->sendToCustomer();
}
}The return there is reasonable, but it is invisible. You need to make this failure explicit.
public function handle(): void
{
$invoice = Invoice::find($this->invoiceId);
if (!$invoice) {
Log::warning('SendInvoiceJob: invoice not found, job abandoned', [
'invoice_id' => $this->invoiceId,
]);
return;
}
if ($invoice->status !== 'pending') {
Log::info('SendInvoiceJob: invoice no longer pending, skipping', [
'invoice_id' => $this->invoiceId,
'status' => $invoice->status,
]);
return;
}
$invoice->sendToCustomer();
}This is not about being defensive for its own sake. It is about having a log trail when your users call you.
Idempotency: The Property Your Jobs Probably Do Not Have
An idempotent operation produces the same result whether it runs once or ten times. Most Laravel jobs are not idempotent by default, and that is a problem because the queue system is designed around at-least-once delivery, not exactly-once.
A job retrying after a timeout will run its side effects again. Sending an email, charging a card, creating a record in a third-party system: these all happen twice if the job is not designed to prevent it.
The pattern for making jobs idempotent depends on the operation:
For operations with natural keys: check if the result already exists before doing the work.
class CreateStripeCustomerJob implements ShouldQueue
{
public function handle(): void
{
$user = User::findOrFail($this->userId);
// Idempotency check: only create if not already done
if ($user->stripe_customer_id) {
return;
}
$customer = \Stripe\Customer::create([
'email' => $user->email,
'name' => $user->name,
]);
$user->update(['stripe_customer_id' => $customer->id]);
}
}For operations driven by state transitions: verify the state before acting, then transition atomically.
public function handle(): void
{
// Pessimistic lock: prevents two workers from both passing this check
$order = Order::where('id', $this->orderId)
->where('status', 'payment_received')
->lockForUpdate()
->first();
if (!$order) {
return; // Either already processed or in wrong state
}
$order->fulfil();
}For third-party API calls: use the API’s idempotency key support if it has one. Stripe, Paddle and most payment processors accept an Idempotency-Key header. Generate a deterministic key from the job input, not from a timestamp.
$idempotencyKey = 'charge-order-' . $this->orderId;
\Stripe\Charge::create([
'amount' => $this->amount,
'currency' => 'usd',
'source' => $this->token,
], [
'idempotency_key' => $idempotencyKey,
]);This way, if the job runs twice with the same data, Stripe ignores the second request and returns the original response.
ShouldBeUnique Is Not a Silver Bullet
Laravel’s ShouldBeUnique interface prevents the same job from being on the queue more than once at a time. That is useful, but it is not the same as preventing duplicate execution.
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
public $uniqueFor = 3600;
public function uniqueId(): string
{
return (string) $this->product->id;
}
}The ShouldBeUnique lock is acquired at dispatch time and released when the job completes or exhausts its retry attempts. During the time the job is running, the lock is held, which means a second dispatch with the same ID will be silently dropped.
The gotcha: by default, if the job fails partway through, the lock is released and the job can be retried. If your job has already performed some side effects before the failure point, you now have a partially completed operation running twice.
If you want the lock released before processing starts (so retries can run immediately), use ShouldBeUniqueUntilProcessing instead. If you want the lock held for the entire retry lifecycle, ShouldBeUnique is correct. Know which behavior your job actually needs.
There is also an edge case documented in a GitHub issue (#51798): ShouldBeUnique does not universally prevent duplicates in all scenarios, particularly when using batched jobs. If you are dispatching unique jobs inside batches, test this behavior explicitly.
WithoutOverlapping for Long-Running Jobs
ShouldBeUnique is about preventing duplicate queuing. WithoutOverlapping is about preventing concurrent execution. These are different problems.
Use WithoutOverlapping when you have a long-running job that should not run in parallel for the same resource, but you do not need to prevent multiple instances from being queued.
use Illuminate\Queue\Middleware\WithoutOverlapping;
class GenerateMonthlyReport implements ShouldQueue
{
public function middleware(): array
{
return [
(new WithoutOverlapping($this->userId))
->expireAfter(600) // Release lock after 10 minutes if job hangs
->releaseAfter(30) // Re-queue after 30 seconds if locked
];
}
}The expireAfter value is critical. If a job crashes without releasing the lock, and you have not set an expiration, the lock sticks around until it naturally expires based on your cache driver's TTL. For long-running jobs on production, always set an explicit expiry.
WithoutOverlapping requires a cache driver that supports atomic locks: Redis, Memcached, DynamoDB, database or file. The array driver does not support locks in a meaningful way for production use.
The failed() Method You Skipped
Every job class can implement a failed() method. Most do not.
public function failed(\Throwable $exception): void
{
// This runs when the job has exhausted all retry attempts
Log::error('GenerateMonthlyReport failed permanently', [
'user_id' => $this->userId,
'exception' => $exception->getMessage(),
]);
// Notify the user something went wrong
$this->user->notify(new ReportGenerationFailed());
// Maybe clean up partial state
Report::where('user_id', $this->userId)
->where('status', 'generating')
->delete();
}Without failed(), a permanently failing job writes to your failed_jobs table and stops there. You find out when Horizon's failure count climbs, or when a user asks where their thing is.
With failed(), you control the recovery path. Notify the user. Roll back partial state. Alert your team. Clean up orphaned records. This is where you turn a silent failure into a handled one.
One thing to keep in mind: failed() does not run on every failed attempt, only when the job has been exhausted. For per-attempt logic, use the job's handle() method with a try/catch.
What Horizon Tells You (And What It Hides)
Laravel Horizon gives you a real-time view of your queues, throughput and failure rates. It is genuinely useful. But it has some blind spots.
Horizon does not track jobs that complete but do nothing. A job that finds a missing record and returns early looks identical to a job that successfully sent an email. Both show as “completed.” This is why logging inside handle() matters more than your failure count in Horizon.
Horizon also does not help you if workers are not running. If your Supervisor configuration restarts workers with startsecs=0, a worker that crashes immediately will trigger an infinite restart loop without any Horizon alerts. Monitor the process count at the OS level, not just inside Horizon.
For jobs with high throughput, watch the “Wait Time” metric. A consistently high wait time means your queue depth is growing faster than your workers can process it. Adding more Horizon processes for that queue is the fix, not tuning the jobs themselves.
Three Things to Fix This Week
If you have a production Laravel app with queued jobs, here is what to audit:
Check your retry_after vs --timeout alignment. Open config/queue.php and your Supervisor or Horizon process config. Confirm retry_after is at least 30 seconds longer than your worker's --timeout. If you find a mismatch, that is your most likely source of duplicates.
Identify your non-idempotent jobs. Look for any job that sends emails, makes external API calls or creates records without a guard against duplication. Add a pre-condition check or use the provider’s idempotency key support.
Add failed() to your critical jobs. Any job touching payments, notifications or user-facing state should have an explicit failed() implementation. Log it, notify somebody, clean up partial state.
Queue failures are not random. They are deterministic responses to specific configuration choices and design patterns. The fixes are not complicated. The cost of ignoring them is showing up in your support queue right now.


