Laravel Job Middleware: Rate Limiting, Throttling, and Skipping Jobs Without Hacks
Laravel's job middleware feature is one of the most underused tools in the queue ecosystem. Most teams reach for static flags, database locks, or custom queue drivers when they need to control job execution flow. Job middleware gives you a cleaner, composable alternative — and it ships with the framework.
What Job Middleware Actually Is
A job middleware is a plain PHP class with a single handle(object $job, Closure $next) method. You attach it via the middleware() method on your job class. Laravel calls each middleware in order before invoking handle() on the job itself.
class EnsureUserIsActive
{
public function handle(object $job, Closure $next): void
{
if (! $job->user->is_active) {
$job->delete();
return;
}
$next($job);
}
}
Attach it to a job:
public function middleware(): array
{
return [new EnsureUserIsActive];
}
No traits, no base classes — just composition.
Built-In Rate Limiting with RateLimited
Laravel ships Illuminate\Queue\Middleware\RateLimited, which integrates with the RateLimiter facade. Define a named limiter in AppServiceProvider (or a dedicated provider):
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
RateLimiter::for('stripe-webhooks', function (object $job) {
return Limit::perMinute(30)->by($job->accountId);
});
Then apply it:
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [new RateLimited('stripe-webhooks')];
}
When the limit is exceeded, the job is automatically released back onto the queue with an exponential backoff. You can control the release delay:
new RateLimited('stripe-webhooks')->dontRelease()
// or
new RateLimited('stripe-webhooks')->releaseAfterBackoff($this->attempts())
releaseAfterBackoff uses the job's attempt count to compute a sensible delay — critical for avoiding thundering-herd re-queues after a rate limit window resets.
Throttling Bursts with WithoutOverlapping
WithoutOverlapping prevents concurrent execution of the same logical job:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping($this->userId))
->releaseAfter(30)
->expireAfter(180),
];
}
The key insight: expireAfter sets a TTL on the atomic lock so a crashed worker doesn't block the job forever. Always set it. The default is no expiry — a silent footgun in production.
Building a Custom Skip Middleware
Sometimes you need to discard a job based on state that only exists at execution time — not dispatch time. A skip middleware handles this cleanly:
class SkipIfAlreadyProcessed
{
public function handle(object $job, Closure $next): void
{
$key = 'processed:' . $job->idempotencyKey;
if (cache()->has($key)) {
$job->delete();
return;
}
$next($job);
cache()->put($key, true, now()->addHours(24));
}
}
This pattern is particularly useful for webhook processors and import jobs where the same payload can arrive multiple times.
Composing Multiple Middleware
Middleware compose naturally — order matters:
public function middleware(): array
{
return [
new SkipIfAlreadyProcessed,
new RateLimited('external-api'),
(new WithoutOverlapping($this->resourceId))->releaseAfter(10),
];
}
The skip check runs first, avoiding unnecessary lock acquisition and rate-limit counter increments for duplicate jobs.
Testing Job Middleware in Isolation
Because middleware are plain classes, they're trivially testable with Pest:
it('deletes the job when user is inactive', function () {
$user = User::factory()->inactive()->make();
$job = Mockery::mock(ProcessUserReport::class);
$job->user = $user;
$job->shouldReceive('delete')->once();
$next = fn () => null;
(new EnsureUserIsActive)->handle($job, $next);
});
No queue faking, no HTTP calls — pure unit test.
Takeaways
- Job middleware is the correct abstraction for cross-cutting queue concerns — not base job classes or static state.
- Always set
expireAfteronWithoutOverlappingto prevent permanent lock starvation after worker crashes. - Use
releaseAfterBackoffwithRateLimitedto avoid thundering-herd requeues at window reset. - Skip middleware should call
$job->delete()explicitly — releasing without deleting re-queues the job. - Compose middleware in skip-first order to avoid wasting rate-limit budget on jobs that will be discarded anyway.
- Plain classes mean plain unit tests — no queue infrastructure required.