Laravel Queues: Job Middleware for Idempotency, Rate Limiting, and Graceful Failure
#laravel #queues #job-middleware #redis #reliability

Laravel Queues: Job Middleware for Idempotency, Rate Limiting, and Graceful Failure

1 min read Mohamed Said Mohamed Said

Why Job Middleware Belongs in Your Toolkit

Laravel's job middleware API — introduced quietly but matured significantly — lets you wrap the handle() call of any job with reusable pipeline stages. Think of it as route middleware, but for your queue workers. The payoff: your job class stays focused on its single responsibility while cross-cutting concerns like idempotency, throttling, and failure policy live in composable, testable middleware.


Enforcing Idempotency with a Cache Lock

Double-dispatching is a real production hazard: retries, webhook replays, and race conditions can all cause a job to run twice. A dedicated middleware solves this cleanly.

<?php

namespace App\Queue\Middleware;

use Illuminate\Support\Facades\Cache;

final class WithIdempotencyKey
{
    public function __construct(
        private readonly string $key,
        private readonly int $ttlSeconds = 3600,
    ) {}

    public function handle(mixed $job, callable $next): void
    {
        $acquired = Cache::lock(
            "idempotency:{$this->key}",
            $this->ttlSeconds
        )->get();

        if (! $acquired) {
            // Already processed — silently discard
            $job->delete();
            return;
        }

        $next($job);
    }
}

Attach it from the job itself:

public function middleware(): array
{
    return [
        new WithIdempotencyKey("charge:{$this->payment->id}"),
    ];
}

Using Cache::lock() with an atomic backend (Redis, DynamoDB) guarantees only one worker proceeds even under concurrent retries.


Fine-Grained Rate Limiting with RateLimited

Laravel ships Illuminate\Queue\Middleware\RateLimited, but you often need per-tenant or per-resource limits rather than a global cap. Build on top of it:

<?php

namespace App\Queue\Middleware;

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;

final class ThrottlePerTenant
{
    public function __construct(private readonly int $tenantId) {}

    public function handle(mixed $job, callable $next): void
    {
        (new RateLimited("tenant-jobs:{$this->tenantId}"))
            ->handle($job, $next);
    }
}

Register the limiter in a service provider:

RateLimiter::for('tenant-jobs', function (mixed $job) {
    // $job is the queue payload wrapper, not your class
    return Limit::perMinute(60);
});

When the limit is hit, Laravel automatically releases the job back onto the queue with a calculated backoff — no manual release() needed.


Graceful Failure: Skip on Precondition Failure

Sometimes the right answer is not to retry at all. If the model a job depends on was deleted between dispatch and execution, retrying is pointless noise in your failed jobs table.

<?php

namespace App\Queue\Middleware;

use App\Exceptions\PreconditionFailedException;

final class SkipOnPreconditionFailure
{
    public function handle(mixed $job, callable $next): void
    {
        try {
            $next($job);
        } catch (PreconditionFailedException $e) {
            // Log for observability, but don't retry
            logger()->warning('Job skipped: precondition failed', [
                'job' => get_class($job),
                'reason' => $e->getMessage(),
            ]);
            $job->delete();
        }
    }
}

Inside handle(), throw PreconditionFailedException when the required state is absent:

public function handle(OrderRepository $orders): void
{
    $order = $orders->find($this->orderId)
        ?? throw new PreconditionFailedException("Order {$this->orderId} not found");

    // proceed safely
}

Composing Middleware in Order

Middleware runs in the order returned from middleware(). Put idempotency first so you bail out before rate-limit counters are consumed:

public function middleware(): array
{
    return [
        new WithIdempotencyKey("invoice:{$this->invoice->uuid}"),
        new ThrottlePerTenant($this->invoice->tenant_id),
        new SkipOnPreconditionFailure(),
    ];
}

Key Takeaways

  • Job middleware is a first-class pipeline — use it to keep handle() free of cross-cutting logic.
  • Atomic cache locks are the correct primitive for idempotency; avoid boolean cache flags that can't survive race conditions.
  • RateLimited middleware releases automatically — you get exponential backoff for free without manual release() calls.
  • Delete, don't fail when a precondition is permanently unmet; it keeps your failed-jobs table meaningful.
  • Middleware order matters: idempotency → throttle → domain guards is the safest composition sequence.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does job middleware work with batched and chained jobs?
Yes. Middleware returned from `middleware()` applies regardless of whether the job is standalone, part of a batch, or in a chain. Each job in the chain runs its own middleware stack independently.
Q02 What cache driver should back the idempotency lock?
Use Redis or another driver that supports atomic locks (`Cache::lock()`). The file and database drivers also support locks, but Redis is preferred in production for its performance and TTL precision.
Q03 Can I unit-test job middleware in isolation?
Yes. Instantiate the middleware directly and pass a mock job and a `$next` closure. Assert whether `delete()` was called or `$next` was invoked. No queue infrastructure is required for these tests.

Continue reading

More Articles

View all