Copied!
Programming
Laravel
PHP

Laravel Job Middleware Deep Dive – Rate Limiting, Overlapping & Custom Middleware

Laravel Job Middleware Deep Dive – Rate Limiting, Overlapping & Custom Middleware
Shahroz Javed
Mar 16, 2026 . 52 views

What is Job Middleware?

HTTP middleware wraps around incoming web requests. Job middleware does the same thing for queued jobs — it wraps around the handle() method and lets you add cross-cutting logic without cluttering the job class itself.

Without job middleware, you end up with handle() methods full of boilerplate: rate limit checks, overlap checks, retry logic, feature flag checks. Job middleware moves all that out of the job and into a reusable, testable class.

Job middleware can be applied to any queueable class:

  • Job classes (ShouldQueue)

  • Queued event listeners

  • Queued mailables

  • Queued notifications

Creating Custom Job Middleware

There's no Artisan generator for job middleware — you create the class manually. By convention, place it in app/Jobs/Middleware/.

A job middleware class needs a single handle() method that receives the job and a $next closure:

<?php

namespace App\Jobs\Middleware;

use Closure;

class EnsureFeatureIsEnabled
{
    public function __construct(protected string $feature) {}

    public function handle(object $job, Closure $next): void
    {
        // Check a feature flag before running the job
        if (! feature_enabled($this->feature)) {
            // Silently delete the job — feature is off
            $job->delete();
            return;
        }

        // Feature is enabled, proceed with the job
        $next($job);
    }
}

A Practical Example: Logging Middleware

Here's a middleware that logs job start and finish times — useful for identifying slow jobs in production:

<?php

namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\Log;

class LogJobExecution
{
    public function handle(object $job, Closure $next): void
    {
        $jobName = class_basename($job);
        $start = microtime(true);

        Log::info("[Queue] Starting: {$jobName}");

        $next($job);

        $duration = round(microtime(true) - $start, 2);
        Log::info("[Queue] Finished: {$jobName} in {$duration}s");
    }
}

Attaching Middleware to Jobs

Add a middleware() method to your job class that returns an array of middleware instances:

use App\Jobs\Middleware\EnsureFeatureIsEnabled;
use App\Jobs\Middleware\LogJobExecution;

class SendWeeklyDigest implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            new EnsureFeatureIsEnabled('weekly-digest'),
            new LogJobExecution(),
        ];
    }

    public function handle(): void
    {
        // send the weekly digest email
    }
}

Built-in: RateLimited

The RateLimited middleware throttles how many jobs of a type run within a time period. This is essential when calling external APIs that have rate limits (e.g. 100 requests/minute).

Define the Rate Limiter

First, define a named rate limiter in AppServiceProvider::boot():

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

// In App\Providers\AppServiceProvider::boot()
RateLimiter::for('external-crm', function (object $job) {
    return Limit::perMinute(30);  // max 30 jobs per minute
});

// Per-user rate limit
RateLimiter::for('notifications', function (object $job) {
    return Limit::perMinute(5)->by($job->user->id);  // 5 per user per minute
});

Apply the Middleware

use Illuminate\Queue\Middleware\RateLimited;

class SyncToCrm implements ShouldQueue
{
    public int $tries = 10;  // allow retries while rate limit is active
    public array $backoff = [10, 30, 60];

    public function middleware(): array
    {
        return [new RateLimited('external-crm')];
    }

    public function handle(): void
    {
        // call the CRM API
    }
}

When the rate limit is hit, the job is automatically released back to the queue and retried after the rate limit window resets.

RateLimited with Redis Throttle (Custom)

For more control, you can build a custom rate-limited middleware using Redis directly — useful when you need token-bucket rate limiting instead of fixed windows:

<?php

namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\Redis;

class RedisRateLimited
{
    public function handle(object $job, Closure $next): void
    {
        Redis::throttle('stripe-api')
            ->block(0)       // don't wait for lock, fail immediately
            ->allow(5)       // allow 5 jobs
            ->every(10)      // per 10 seconds
            ->then(function () use ($job, $next) {
                // Lock obtained — run the job
                $next($job);
            }, function () use ($job) {
                // Could not obtain lock — release back to queue in 10 seconds
                $job->release(10);
            });
    }
}
⚠️ This Redis throttle middleware requires the Redis queue driver or at least Redis available in your app. It won't work correctly with the database queue driver alone.

Built-in: WithoutOverlapping

As covered in the Unique Jobs post, WithoutOverlapping prevents two instances of the same job from running at the same time. Here's the complete usage:

use Illuminate\Queue\Middleware\WithoutOverlapping;

class ProcessUserReport implements ShouldQueue
{
    public function __construct(protected int $userId) {}

    public function middleware(): array
    {
        return [
            (new WithoutOverlapping($this->userId))
                ->releaseAfter(30)   // release back to queue after 30s if overlapping
                ->expireAfter(300)   // the lock expires after 5 minutes (in case of crash)
        ];
    }

    public function handle(): void
    {
        // generate report for $this->userId
    }
}

dontRelease — Skip Instead of Retry

// If another instance is running, just delete this job (don't retry)
return [(new WithoutOverlapping($this->userId))->dontRelease()];

The expireAfter() is important for production safety. If a worker crashes mid-job, the lock would normally stay forever (preventing future runs). Setting an expiry ensures the lock eventually releases even if the job never finished cleanly.

Built-in: ThrottleExceptions

ThrottleExceptions is for situations where your job calls an external service that's temporarily down. Instead of burning through all your retries immediately, this middleware slows down retries when exceptions start occurring.

use Illuminate\Queue\Middleware\ThrottleExceptions;

class CallPaymentGateway implements ShouldQueue
{
    public int $tries = 10;

    public function middleware(): array
    {
        return [
            // Allow 3 exceptions per 5 minutes
            // If exceeded, release the job and wait 5 minutes before trying again
            new ThrottleExceptions(3, 5),
        ];
    }

    public function handle(): void
    {
        // call payment API
    }
}

Practical use case: your payment gateway goes down. Without this middleware, your worker hammers the gateway 10 times in seconds, fails all retries, and marks the job as permanently failed. With ThrottleExceptions(3, 5), after 3 failures it backs off for 5 minutes — giving the gateway time to recover.

ThrottleExceptions with byJob()

// Throttle per unique job instance (based on job ID), not globally
new ThrottleExceptions(3, 5)->byJob()

Built-in: Skip

The Skip middleware conditionally skips (silently deletes) a job based on a condition. Useful for feature flags or dynamic conditions evaluated at dispatch time:

use Illuminate\Queue\Middleware\Skip;

class SendMarketingEmail implements ShouldQueue
{
    public function __construct(protected User $user) {}

    public function middleware(): array
    {
        return [
            // Skip this job entirely if the user unsubscribed
            Skip::when(fn () => ! $this->user->marketing_emails_enabled),

            // Or: skip unless a condition is true
            Skip::unless(fn () => $this->user->hasActiveSubscription()),
        ];
    }

    public function handle(): void
    {
        // send marketing email
    }
}

Reusable Middleware Across Job Types

The biggest win of job middleware is reusability. Write the logic once, apply it everywhere. Here's a real-world example — a middleware that skips jobs when the app is in maintenance mode:

<?php

namespace App\Jobs\Middleware;

use Closure;

class SkipDuringMaintenance
{
    public function handle(object $job, Closure $next): void
    {
        if (app()->isDownForMaintenance()) {
            // Release back to queue — retry in 60 seconds when maintenance ends
            $job->release(60);
            return;
        }

        $next($job);
    }
}

Now apply it to any job that should pause during maintenance:

// Works on jobs, mailables, notifications, event listeners
class SendInvoice implements ShouldQueue
{
    public function middleware(): array
    {
        return [new SkipDuringMaintenance()];
    }
}

class ProcessOrder implements ShouldQueue
{
    public function middleware(): array
    {
        return [new SkipDuringMaintenance()];
    }
}

Conclusion

Job middleware transforms how you build queue systems. Instead of writing the same checks in every job's handle() method, you centralize cross-cutting concerns in small, testable classes.

  • Create middleware in app/Jobs/Middleware/ with a handle(object $job, Closure $next) method

  • Return them from the job's middleware() method

  • Use RateLimited for API rate limiting — jobs wait instead of failing

  • Use WithoutOverlapping to prevent concurrent execution of the same job

  • Use ThrottleExceptions to back off when an external service is struggling

  • Use Skip for clean conditional job skipping

  • Write your own middleware for feature flags, maintenance mode, logging, and more

📑 On This Page