Fluxtail
Laravel

Laravel Queue Job Runs Twice: Dispatch, Timeouts and Retries

Find why a Laravel queue job runs twice. Compare dispatch IDs, retry_after, worker timeouts and SQS visibility, then prevent repeated side effects.

By Fluxtail Engineering

Laravel is a PHP web framework. Its queues move slow work, such as sending emails or importing files, out of a web request and into background workers. If a Laravel queue job runs twice, the application may have dispatched equivalent work twice—or a worker may be attempting the same queued work again.

A worker's reservation is temporary ownership of a job. If that reservation expires before processing finishes, another worker can pick up the work while the first worker is still running. Laravel documents this risk when the effective job timeout exceeds the queue connection's retry interval. Exceptions and deliberate releases provide other paths to another attempt. These mechanisms can repeat an email or database write even when the original dispatch happened only once. See the official Laravel queue documentation.

The backend matters. Database and Redis connections use Laravel's retry_after setting. Amazon SQS uses a visibility timeout: the period during which a received message is hidden from other consumers. AWS explains that an undeleted message becomes available again when this period expires.

Repeated log lines alone do not establish which path occurred. A missing completion record also does not prove that an email, payment or write failed. The side effect may have succeeded before the worker stopped or the log was lost.

Diagnose why your Laravel job ran again

Use these checks when background work produces repeated emails, writes or other effects. First establish whether you have multiple dispatches or repeated attempts. Then compare the worker settings with the actual queue backend. The logs can explain execution; the destination database or external provider must establish what happened to the business operation.

Separate dispatches from attempts

Record three different identifiers:

  • A dispatch identifier, generated once at each dispatch call and stored in the job payload.
  • A backend job identifier, obtained from the queue job during execution.
  • A business operation key, such as an internal invoice identifier plus the operation being performed.

Keep the dispatch identifier unchanged across attempts. Keep the business operation key unchanged across equivalent requests, including requests that accidentally dispatch separate jobs. Include connection, queue, attempt number, worker identity and a UTC timestamp in start, completion and error records. Avoid payload dumps, recipient addresses and credentials.

Evidence Possible explanation What to corroborate
Different dispatch identifiers for one operation Equivalent work was dispatched more than once Request retries, duplicate listeners, scheduled tasks and dispatch call sites
Same dispatch identifier; later attempt starts before earlier completion Reservation or visibility expired during processing Backend timing, effective timeout and worker start records
Same dispatch identifier after a deliberate release Application or middleware returned work to the queue Release branch, delay and middleware configuration
Another attempt follows an exception Automatic exception retry Error record, attempt limit and backoff
Execution follows an operator's failed-job retry Manual retry Operator action and failed-job history
Identical records with no other execution evidence Logging or collection duplication Handler configuration, collector path and actual side effects

These patterns are clues. Backend identifiers can change when work is released or requeued. For example, Laravel's database driver deletes and reinserts a released job, so a new database row ID is not conclusive evidence of a second application dispatch. The database queue implementation shows that release path.

Compare runtime, timeout and reservation duration

For database or Redis queues, aim for this relationship:

normal job runtime < effective job timeout < retry_after

Leave several seconds between the timeout and reservation expiry. Choose values from realistic processing times, including slow dependencies. Increasing the reservation duration also delays recovery when a worker dies.

Inspect the connection the worker actually uses, its process-manager command and any job-level timeout. A changed environment file does not establish what an already-running process loaded.

Laravel 11 documents a job property:

// Inside a Laravel 11 job class:
public $timeout = 45;

Laravel 13 documents an attribute on the job class:

use Illuminate\Queue\Attributes\Timeout;

#[Timeout(45)]
// Apply to the job class declaration.

A job-level timeout takes precedence over the command-line value. Check the documentation matching your installed framework: Laravel 11 timeouts and Laravel 13 timeouts.

Timeout handling requires PHP's PCNTL extension in the worker runtime. Blocking network calls also need their own connection and request timeouts. A worker timeout is not a substitute for those limits. Laravel 13 additionally documents that queue:work --once does not apply the worker timeout option.

After changing code or configuration, replace long-lived workers through your deployment procedure. Verify the replacement processes and their effective settings rather than assuming a restart request completed.

Check SQS separately

Changing Laravel's retry_after does not configure SQS visibility. Inspect the actual SQS queue and any code that changes individual message visibility. Allow enough time for processing and deletion before visibility expires.

Even correctly aligned settings do not guarantee one execution. SQS standard queues use at-least-once delivery, so consumers must tolerate receiving work again.

Trace releases and retries

Inspect exception handlers, explicit $this->release(...) calls, rate-limiting middleware and overlap middleware. A released job can return later without a new dispatch. Return from handle() after releasing if no further work should run; calling release() does not itself stop PHP execution.

Check attempt limits and backoff together. Also check whether someone retried a failed job. Do not replay a production job merely to see whether it duplicates an email or charge. Laravel's error-handling documentation explains exception retries.

Reproduce reservation expiry without real side effects

Use a disposable local Laravel application with a dedicated database, its jobs table and no production integrations. This experiment deliberately uses unsafe timing to demonstrate overlap. It is a test recipe, not a measured production result.

Record the exact framework version, PHP version and worker extension availability first:

php artisan --version
php -v
php -r 'echo "PCNTL=", extension_loaded("pcntl") ? "yes" : "no", PHP_EOL;'

In that application's config/queue.php, set retry_after to 5 on the database connection. Use uncached local configuration. Create app/Jobs/QueueOverlapProbe.php with this Laravel 11 example:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;

class QueueOverlapProbe implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(
        public string $dispatchId,
        public string $operationKey,
    ) {}

    public function handle(): void
    {
        $context = [
            'dispatch_id' => $this->dispatchId,
            'operation_key' => $this->operationKey,
            'job_id' => $this->job->getJobId(),
            'attempt' => $this->attempts(),
            'worker' => gethostname().':'.getmypid(),
        ];

        Log::info('queue_probe_started', $context + [
            'at' => gmdate('Y-m-d\TH:i:s\Z'),
        ]);

        sleep(8); // Harmless work: no email, payment or business write.

        Log::info('queue_probe_completed', $context + [
            'at' => gmdate('Y-m-d\TH:i:s\Z'),
        ]);
    }
}

The example uses Laravel's contextual logging API and queue interaction methods. Keep any added error records equally narrow; an abrupt process exit may prevent them from being written.

Start this command in two terminals in the disposable application. These commands consume jobs, so use only the isolated connection and queue:

php artisan queue:work database --queue=overlap-probe --sleep=1 --timeout=20 --tries=2

From a local Artisan Tinker session, dispatch exactly once:

App\Jobs\QueueOverlapProbe::dispatch(
    (string) Illuminate\Support\Str::uuid(),
    'probe-only-001'
)->onConnection('database')->onQueue('overlap-probe');

Record both worker start times and the resulting events. With an eight-second job, a five-second reservation and a twenty-second timeout, the second worker can reserve the still-running job after expiry. Polling and scheduling affect the exact timing.

This is a synthetic illustrative timeline, not captured output:

12:00:00Z A started   dispatch=probe-a job=41 attempt=1 operation=probe-only-001
12:00:05Z B started   dispatch=probe-a job=41 attempt=2 operation=probe-only-001
12:00:08Z A completed dispatch=probe-a job=41 attempt=1 operation=probe-only-001
12:00:13Z B completed dispatch=probe-a job=41 attempt=2 operation=probe-only-001

The important evidence is B starting before A completes, with the same dispatch identifier. Stop both local workers after collecting the result. Repeat in a fresh isolated queue with retry_after=30 and newly started workers to compare behavior. One successful test does not establish exactly-once processing.

Prevent repeated business effects

Idempotency means that repeating the same operation does not create another effect. Enforce it where the effect happens.

For a database-only change, use a unique operation key and a transaction that covers both the business write and its completion record. A separate “already processed” check followed by an unprotected write can race when two workers run together.

For an external email or payment, a local database marker cannot atomically cover the remote action. If the provider supports idempotency keys, use a stable key for the business operation and follow its retention and retry rules. If the result is uncertain, check the provider's records before resending. A crash after remote success but before local completion is exactly the gap that causes duplicates.

Dispatch uniqueness and overlap locks solve narrower problems. Laravel's ShouldBeUnique restricts dispatch through a cache lock; WithoutOverlapping controls concurrent processing. Neither makes an external side effect atomic. Their lock keys, shared cache and expiry must match the operation they protect. See unique jobs and preventing overlaps.

Compare worker events in Fluxtail

When workers run on different containers or hosts, retained logs make the comparison easier. Configure application lifecycle records to reach the worker's collected output. The PHP Monolog guide covers structured output, and the Docker Compose logs guide explains the supported collector-to-receiver path.

Emit a harmless unique marker such as queue-probe-20261101-a7f2 in a test dispatch identifier. Verify that exact event in Fluxtail Live Tail. Then search retained events in a bounded UTC window around the execution and compare dispatch identifier, operation key, attempt and worker.

Collector parsing determines field mapping. Confirm the accepted record before relying on a custom identifier as a separate filter. Keep the structured logging fields consistent across web dispatch records and worker events.

Fluxtail supplies the recorded execution evidence; it does not inspect Laravel's queue state or guarantee one execution. If a completion event is missing, check collection health and the destination's business records before deciding whether to retry.