Laravel Queues: Reliable Job Middleware, Idempotency, and Graceful Failure Handling
#laravel #queues #jobs #reliability

Laravel Queues: Reliable Job Middleware, Idempotency, and Graceful Failure Handling

4 min read Mohamed Said Mohamed Said

The Problem With Naive Job Dispatch

Most Laravel queue tutorials stop at dispatch(new SendInvoice($order)). That works fine until a worker crashes mid-execution, a downstream API times out, or a deployment restarts Horizon at exactly the wrong moment. The job retries, the invoice is sent twice, and your customer is furious.

Production queues demand three things: custom job middleware for cross-cutting concerns, idempotency guards to prevent duplicate side-effects, and structured failure handling that distinguishes transient errors from permanent ones.


Custom Job Middleware

Job middleware in Laravel is underused. Unlike HTTP middleware, it wraps the handle() call directly and can short-circuit execution cleanly.

// app/Queue/Middleware/SkipIfAlreadyProcessed.php
class SkipIfAlreadyProcessed
{
    public function handle(object $job, callable $next): void
    {
        $key = 'job_processed:' . $job->idempotencyKey();

        if (Cache::has($key)) {
            $job->delete();
            return;
        }

        $next($job);

        Cache::put($key, true, now()->addHours(24));
    }
}

Attach it in the job itself:

class SendInvoice implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public readonly Order $order) {}

    public function middleware(): array
    {
        return [new SkipIfAlreadyProcessed];
    }

    public function idempotencyKey(): string
    {
        return 'invoice:' . $this->order->id;
    }

    public function handle(InvoiceService $service): void
    {
        $service->send($this->order);
    }
}

This pattern keeps idempotency logic out of handle() and makes it reusable across job classes.


Idempotency at the Database Level

Cache-based guards are fast but not durable across cache flushes. For financial or notification jobs, back the idempotency key with a database record.

Schema::create('processed_jobs', function (Blueprint $table) {
    $table->string('idempotency_key')->primary();
    $table->timestamp('processed_at');
});
class DatabaseIdempotency
{
    public function handle(object $job, callable $next): void
    {
        $key = $job->idempotencyKey();

        $inserted = DB::table('processed_jobs')->insertOrIgnore([
            'idempotency_key' => $key,
            'processed_at'    => now(),
        ]);

        if ($inserted === 0) {
            $job->delete();
            return;
        }

        try {
            $next($job);
        } catch (Throwable $e) {
            DB::table('processed_jobs')->where('idempotency_key', $key)->delete();
            throw $e;
        }
    }
}

insertOrIgnore is atomic on a primary key conflict. If the job throws, we delete the record so a retry can proceed — but only for transient failures.


Distinguishing Transient vs. Permanent Failures

Not every exception should trigger a retry. A PaymentGatewayUnavailableException is transient; a CardPermanentlyDeclinedException is not.

public function failed(Throwable $exception): void
{
    if ($exception instanceof PermanentFailureException) {
        // Notify, log, and do NOT re-queue
        Log::error('Permanent job failure', [
            'job'   => static::class,
            'order' => $this->order->id,
            'error' => $exception->getMessage(),
        ]);
        return;
    }

    // For transient failures, let Laravel's retry logic handle it
    // but cap attempts to avoid infinite loops
}

public int $tries = 5;
public int $backoff = 60; // seconds between retries

Pair this with $this->fail($exception) inside handle() when you detect a permanent condition — it marks the job failed immediately without exhausting retry attempts.


Combining Everything With a Base Job

Avoid repeating middleware declarations across dozens of job classes:

abstract class ReliableJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 5;
    public int $backoff = 30;

    abstract public function idempotencyKey(): string;

    public function middleware(): array
    {
        return [new DatabaseIdempotency];
    }
}

class SendInvoice extends ReliableJob
{
    public function __construct(public readonly Order $order) {}

    public function idempotencyKey(): string
    {
        return 'invoice:' . $this->order->id;
    }

    public function handle(InvoiceService $service): void
    {
        $service->send($this->order);
    }
}

Every subclass inherits idempotency and retry configuration automatically.


Key Takeaways

  • Job middleware is the right place for cross-cutting concerns like idempotency, rate-limiting, and telemetry — keep handle() focused on domain logic.
  • insertOrIgnore on a primary key gives you atomic, durable deduplication without race conditions.
  • Roll back the idempotency record on transient failure so retries can proceed; leave it in place on permanent failure.
  • $this->fail() short-circuits retry logic immediately — use it when you know a retry cannot succeed.
  • A shared base job class enforces reliability conventions across your entire application without repetition.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why use a database table for idempotency instead of Redis/Cache?
Cache entries can be evicted under memory pressure or lost during a Redis restart. A database primary key constraint is durable and atomic — `insertOrIgnore` guarantees exactly-once insertion even under concurrent workers processing the same job.
Q02 Does rolling back the idempotency record on failure cause duplicate processing?
Only intentionally. You delete the record only when the job throws, allowing a retry. For permanent failures you leave the record in place (or call `$this->fail()`) so the job is never retried. The key is classifying your exceptions correctly.
Q03 Can I use Laravel's built-in WithoutOverlapping middleware instead?
`WithoutOverlapping` prevents concurrent execution of the same job but does not prevent re-execution after a successful run. It solves a different problem — use it alongside idempotency guards, not instead of them.

Continue reading

More Articles

View all