Why Does At-Least-Once Delivery Cause Duplicate Processing in Laravel?
Distributed message brokers such as Amazon SQS, RabbitMQ, and Redis prioritize fault tolerance over exactly-once delivery, guaranteeing only that a message will be delivered at least once. Duplicate processing happens when network partitions, worker crashes, or long-running tasks exceed invisibility timeouts, causing the message broker to redeliver a job that has already started or finished executing.
In high-throughput Laravel applications, developers often assume that dispatching a job via dispatch() guarantees a single execution. In reality, multiple failure modes trigger redundant execution:
Visibility Timeout Expiration: If a job's processing duration exceeds the queue connection's
retry_afterconfiguration, the queue broker assumes the worker died and places the message back onto the queue for another worker to claim.Worker Heartbeat and OOM Crashes: If a PHP worker hits the
memory_limitor receives aSIGKILLduring execution, the job is not marked as completed in the broker, making it eligible for redelivery after the reservation or visibility timeout expires.HTTP Client and Consumer Retries: Upstream services or end users frequently resubmit API requests due to transient gateway timeouts (504s), dispatching identical payloads multiple times in milliseconds.
Treat redelivery and repeated business requests as expected failure cases. Enforce the operation's invariant at the application and data layer instead of relying on dispatch to imply one execution.
How Do You Prevent Concurrent Runs Using Cache Locks and WithoutOverlapping?
Laravel prevents overlapping job execution across multiple workers through atomic distributed cache locks backed by Redis or Memcached. The built-in WithoutOverlapping middleware inspects a distinct lock key before job execution, releasing the overlapping job for another attempt or discarding it, according to the configured options. A released overlap also consumes an attempt.
To guard a job against parallel execution, apply the Illuminate\Queue\Middleware\WithoutOverlapping middleware within your job class:
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ProcessOrderPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 120;
public function __construct(public Order $order) {}
public function middleware(): array
{
return [
(new WithoutOverlapping((string) $this->order->id))
->releaseAfter(30)
->expireAfter(180),
];
}
public function handle(): void
{
// Implement an idempotent operation here; the lock alone is insufficient.
}
}Three configuration options affect how WithoutOverlapping behaves:
releaseAfter($seconds): Instructs Laravel to delay and re-queue the incoming overlapping job if the lock cannot be acquired, rather than terminating it.expireAfter($seconds): Sets the absolute TTL (time-to-live) for the atomic lock in Redis. This prevents deadlocks if the worker process is killed before clearing the lock.dontRelease(): Drops subsequent jobs entirely if another instance is currently holding the lock, and should be reserved for work that can safely be discarded. Do not combine it with releaseAfter() when the intended behavior is to retry the overlapping job.
Manual Atomic Locks for Granular Control
When middleware lacks the granularity needed to wrap only external API calls (e.g., Stripe charges), use the Cache::lock() driver directly inside the handle() method:
// Fragment inside a queued job that uses InteractsWithQueue.
public function handle(): void
{
$lock = \Illuminate\Support\Facades\Cache::lock(
'process_payment_' . $this->order->id,
60,
);
if (! $lock->get()) {
$this->release(10);
return;
}
try {
// Perform an idempotent operation within the valid lease.
} finally {
$lock->release();
}
}How to Architect Safe Retries with Idempotency Keys and Unique Constraints?
Safe retries require tracking execution state and unique request identifiers inside an ACID-compliant database. By persisting a unique idempotency key alongside the target operation within a database transaction, duplicate runs exit safely without repeating irreversible operations like credit card debits or transactional emails.
An atomic lock only handles concurrency; it does not protect against a job that runs sequentially twice (for example, when a job fails after charging a credit card but before updating the database). To solve this, design a dedicated state storage mechanism.
Database Schema for Processed Operations
Create an execution registry table to record every processed side effect:
// Migration fragment; import Schema and Blueprint in the migration file.
Schema::create('processed_jobs', function (Blueprint $table) {
$table->id();
$table->string('idempotency_key')->unique();
$table->string('handler');
$table->json('response_payload')->nullable();
$table->enum('status', ['PENDING', 'COMPLETED', 'FAILED']);
$table->timestamps();
});Implementing the Idempotency Gatekeeper
Inside your job's execution flow, use pessimistic locking (lockForUpdate()) or atomic unique inserts to guarantee a single transition:
<?php
namespace App\Jobs;
use App\Models\ProcessedJob;
use App\Services\PaymentGateway;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
// Requires an application-specific model and gateway adapter.
// The provider must enforce a stable key for the same operation and amount.
class ChargeSubscriptionJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $userId,
public int $amountInCents,
public string $idempotencyKey,
) {}
public function handle(PaymentGateway $gateway): void
{
$record = DB::transaction(function () {
return ProcessedJob::firstOrCreate(
['idempotency_key' => $this->idempotencyKey],
['handler' => self::class, 'status' => 'PENDING'],
);
});
if ($record->status === 'COMPLETED') {
return;
}
// firstOrCreate identifies the operation; it does not exclusively claim it.
$response = $gateway->charge([
'amount' => $this->amountInCents,
'user_id' => $this->userId,
'idempotency_key' => $this->idempotencyKey,
]);
$record->update([
'status' => 'COMPLETED',
'response_payload' => $response->toArray(),
]);
}
}Decoupling Dispatch from State: How Does the Transactional Outbox Pattern Work in Laravel?
The Transactional Outbox pattern avoids the dual-write problem by saving background messages into an outbox database table within the same transaction as your business data. A separate publisher process reads the outbox and dispatches jobs to the queue broker, ensuring that uncommitted database writes never trigger premature background workers.
The Dual-Write Problem in Laravel
Consider the following common anti-pattern:
// Dispatch fragment: behavior depends on the connection's after_commit setting.
DB::transaction(function () use ($order) {
$order->update(['status' => 'paid']);
// May reach another broker before commit if after_commit is disabled.
SendOrderConfirmationEmail::dispatch($order);
});If the background worker claims the job before the database transaction completes, the worker reads stale data (an unpaid order) and errors out. Alternatively, if the queue driver crashes right after the database commits, the intended enqueue may be missed unless a durable record or recovery mechanism detects it.
Solving Dual-Writes with afterCommit
Laravel provides a built-in solution for simple cases using the afterCommit configuration:
// Choose the appropriate style in your queued job or dispatch site.
public bool $afterCommit = true;
// Or set post-commit dispatch explicitly:
SendOrderConfirmationEmail::dispatch($order)->afterCommit();For a durable record of messages that still need publishing, consider an explicit Outbox table. An event producer records changes directly to an outbox_events table inside the domain transaction. A resilient daemon runs chunkById() over pending outbox rows, dispatches them to Laravel queues, and marks them dispatched only upon broker acknowledgment.
How Do You Handle Edge Cases: Job Timeouts, Stale Locks, and Failed State Recovery?
Robust edge-case handling requires synchronizing worker process timeouts with cache lock expiration times, defining dead-letter queue (DLQ) policies, and setting up automated cleanup routines for abandoned tasks. When a worker terminates unexpectedly, its lock and database state must fail cleanly without permanently blocking subsequent attempts.
Failure Mode | Underlying Cause | Mitigation Strategy |
|---|---|---|
Stale Lock Deadlock | Worker killed by OOM or deployment while holding a Redis lock. | Always provide an explicit TTL via |
Orphaned 'PENDING' State | An operation record committed as PENDING before a later worker interruption. | Reconcile old PENDING operations against authoritative downstream state before deciding whether they can be retried. |
Duplicate Downstream Execution | Queue timeout fired, triggering a retry while the original worker was still running. | Keep the worker timeout shorter than the connection retry interval, with a margin chosen for observed execution and shutdown behavior. SQS uses its visibility timeout instead. |
Timeout and retry_after Alignment Rules
To avoid race conditions created by Laravel's queue runner, strictly enforce these relationship limits in your queue workers and job configurations:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
class GenerateAnnualReport implements ShouldQueue
{
// Execution timeout; process termination details depend on the environment.
public int $timeout = 120;
public int $tries = 3;
public int $backoff = 15;
public function handle(): void
{
// Generate the report with application-specific logic.
}
}In config/queue.php, ensure that the connection's retry_after is strictly greater than $timeout:
// Fragment in config/queue.php; adjust the margin for the actual workload.
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 180,
'block_for' => null,
],Timeout alignment reduces overlapping retries but does not prove exactly-once effects. Combine bounded execution with database invariants, stable business-operation keys and recovery checks.
Recovery boundaries in these examples
The firstOrCreate registry identifies the operation; it is not an exclusive claim. Two workers can both read PENDING and reach the gateway. The payment sketch therefore depends on the external provider actually enforcing the same idempotency key for the same operation and amount. Otherwise it needs a durable claim and a reconciliation design before use.
A transaction that has not committed is normally rolled back when its connection ends. A separately committed PENDING record is different: its age alone cannot tell you whether an external side effect succeeded. Query the provider or another authoritative record before retrying an uncertain operation.
An outbox publisher can crash after enqueueing but before marking an event as dispatched. Its consumer must still tolerate duplicates. Likewise, afterCommit() prevents early consumption of uncommitted rows; it does not make a separate queue broker part of the database transaction.
Validate these paths with isolated fixtures: deliver the same operation twice, run two workers concurrently, terminate one after the side effect and before the local status update, and verify the final effect count. These sketches are design examples, not a tested payment integration. Reference: Laravel 12 queue middleware, transactions and timeouts.



