You have a job that calls Stripe, OpenAI, or Mailgun, and the provider rate-limits you. The usual fix is RateLimiter::attempt() and $this->release() calls scattered through handle() — ugly, untestable, and impossible to reuse on the next job. Laravel job middleware is the native answer: wrap the rate limiting around the job instead of inside it.
The contract is one method — handle(object $job, Closure $next) — exactly like HTTP middleware, but wrapping a queued job's execution instead of a request. Here's the full pattern: the built-in RateLimited middleware, releaseAfter() back-off, a custom circuit breaker, and a Pest test to prove it works.
Define a named rate limiter#
Register a named limiter in the boot method of AppServiceProvider using RateLimiter::for() — the same facade you already use for throttling API routes, which means one limiter definition can govern both your HTTP endpoints and your queue.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// Stripe allows ~100 requests/second; stay well under it
RateLimiter::for('stripe-api', function (object $job) {
return Limit::perMinute(100);
});
}
The closure receives the job instance, so you can segment limits per tenant or per user with ->by($job->team->id) — useful when one customer's invoice run shouldn't starve everyone else's.
Attach the RateLimited job middleware#
Add a middleware() method to the job and return Illuminate\Queue\Middleware\RateLimited pointed at your named limiter. The make:job scaffold doesn't include this method — you add it manually (or generate custom middleware classes with php artisan make:job-middleware).
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimited;
class SendInvoiceToStripe implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [new RateLimited('stripe-api')];
}
public function handle(): void
{
// Pure business logic — no throttling noise
$this->invoice->pushToStripe();
}
}
When the limit is exceeded, the middleware releases the job back onto the queue with a delay derived from the limiter's window. handle() never runs, and never knows.
If you're on a Redis queue, swap in RateLimitedWithRedis — same API, but it uses Redis-native throttling and is measurably more efficient under load.
Tune releaseAfter for back-pressure#
Control the retry delay explicitly with releaseAfter(), which takes the number of seconds to wait before the released job is attempted again. Without it, Laravel infers a delay from the rate limit window; with it, you decide the back-pressure.
public function middleware(): array
{
// When throttled, wait a full minute before retrying
return [(new RateLimited('stripe-api'))->releaseAfter(60)];
}
The alternative is dontRelease() — the job is simply dropped for that attempt rather than re-queued. That suits periodic jobs where the next scheduled run will pick up the work anyway, but for anything user-triggered you almost always want the release behaviour.
One thing the docs flag and most tutorials skip: every release increments the job's attempts count. A default $tries = 1 job that gets throttled once is dead. Bump $tries, or better, define retryUntil() so the job retries on a time budget instead of a counter:
public function retryUntil(): \DateTime
{
return now()->addMinutes(30);
}
Write a custom Laravel job middleware circuit breaker#
Create a plain class with a handle($job, Closure $next) method — no interface, no base class. This one stops hammering a third-party API that's already down: after a threshold of failures, it short-circuits and releases jobs until a cool-off period passes.
<?php
namespace App\Jobs\Middleware;
use Closure;
use Illuminate\Support\Facades\Cache;
class CircuitBreaker
{
public function __construct(
private string $service,
private int $threshold = 10,
private int $coolOffSeconds = 120,
) {}
public function handle(object $job, Closure $next): void
{
$key = "circuit:{$this->service}:failures";
if (Cache::get($key, 0) >= $this->threshold) {
// Circuit open — don't even attempt the call
$job->release($this->coolOffSeconds);
return;
}
try {
$next($job);
Cache::forget($key); // healthy again, reset the count
} catch (\Throwable $e) {
Cache::add($key, 0, $this->coolOffSeconds);
Cache::increment($key);
throw $e;
}
}
}
Before you ship that, check whether Illuminate\Queue\Middleware\ThrottlesExceptions already covers you — it's Laravel's built-in circuit breaker. new ThrottlesExceptions(10, 5 * 60) delays all further attempts for five minutes once the job has thrown ten exceptions. I reach for the custom class only when I need a shared breaker across multiple job classes keyed by service, which ThrottlesExceptions doesn't give you out of the box.
Compose multiple job middleware in order#
Return multiple middleware from middleware() and they wrap the job in array order — first entry is the outermost layer, exactly like onion-style HTTP middleware.
public function middleware(): array
{
return [
new CircuitBreaker('stripe'), // outermost: fail fast if Stripe is down
(new RateLimited('stripe-api'))->releaseAfter(60), // then throttle
(new WithoutOverlapping($this->invoice->id))->expireAfter(180), // innermost: one job per invoice
];
}
Order matters. Put the circuit breaker outside the rate limiter — when the service is down you want to skip the limiter's bookkeeping entirely, not burn rate-limit slots on requests that will fail. WithoutOverlapping sits innermost so its lock is only taken when the job will actually run. Note expireAfter(180): without it, a job that dies mid-flight can leave the lock stuck until it expires on its own terms.
Middleware handles how jobs execute; if you're also wrangling where they run, Laravel 13's Queue::route() centralises job-to-queue routing in the same service provider and pairs nicely with this pattern.
Test Laravel job middleware in Pest#
Use withFakeQueueInteractions() to fake the job's queue interactions, run the middleware directly, and assert the release — no queue worker, no real Redis. Exhaust a one-per-minute limiter, then prove the second job gets released with the configured delay:
use App\Jobs\SendInvoiceToStripe;
use App\Models\Invoice;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;
it('releases the job with a delay when the limiter is exhausted', function () {
RateLimiter::for('stripe-api', fn () => Limit::perMinute(1));
$middleware = (new RateLimited('stripe-api'))->releaseAfter(60);
// First job consumes the only slot in the window
$first = (new SendInvoiceToStripe(Invoice::factory()->create()))
->withFakeQueueInteractions();
$middleware->handle($first, fn () => null);
// Second job must be released, not executed
$second = (new SendInvoiceToStripe(Invoice::factory()->create()))
->withFakeQueueInteractions();
$middleware->handle($second, fn () => $this->fail('Job should have been released.'));
$second->assertReleased(delay: 60);
});
assertReleased(delay: 60) checks both that the job was released and that the delay matches. The same pattern tests the circuit breaker: prime the failure counter in cache, run the middleware, assert the release.
Gotchas and Edge Cases#
Releases eat your attempts. Worth repeating because it bites in production: RateLimited and WithoutOverlapping both increment attempts on every release. A heavily-throttled job with $tries = 3 fails silently after three releases without ever running. Use retryUntil() for throttled jobs.
WithoutOverlapping needs an atomic-lock cache driver. Redis, memcached, database, file, dynamodb, and array all qualify — but if your cache is something exotic, the lock silently does nothing. It also only locks against the same job class by default; use ->shared() to lock across different job classes that touch the same resource.
Middleware doesn't run on dispatchSync(). Synchronous dispatch skips the queue worker pipeline in older versions and behaves differently around releases — a released job in a sync context throws. Keep middleware-guarded jobs on a real queue connection.
Worker memory and middleware state. Middleware classes are instantiated fresh per job, so don't stash state on properties expecting it to survive. Anything that must persist between jobs belongs in cache or Redis — and if your workers run long, pair this with queue worker max-jobs and max-time tuning so recycled workers don't carry stale connections into your rate-limit windows.
Wrapping Up#
Job middleware is the difference between a job class that reads like business logic and one that reads like infrastructure plumbing. Start with RateLimited plus releaseAfter() for any job that talks to a metered API, add ThrottlesExceptions or a custom breaker when that API is flaky, and write the release assertion test before you trust any of it. From here, the natural next steps are scaling your queue workers for production load and deciding when chains beat batches for multi-step workflows.
FAQ#
What is job middleware in Laravel?
Job middleware is custom logic that wraps the execution of a queued job, exactly like HTTP middleware wraps a request. A middleware class defines a single handle($job, Closure $next) method and is attached by returning instances from the job's middleware() method. Laravel ships built-in middleware for rate limiting, overlap prevention, exception throttling, and conditional skipping.
How is job middleware different from HTTP middleware?
HTTP middleware wraps an incoming web request and is registered in bootstrap/app.php, while job middleware wraps a queued job's handle() method and is returned from the job class itself. The contract differs too: job middleware receives the job instance rather than a request, and there's no interface to implement — any class with the right handle() signature works. Job middleware can also be attached to queued listeners, mailables, and notifications.
Can I rate-limit a Laravel queued job?
Yes. Define a named limiter with RateLimiter::for() in a service provider, then return new RateLimited('limiter-name') from the job's middleware() method. When the limit is exceeded, the job is released back onto the queue with a delay instead of executing. On Redis queues, RateLimitedWithRedis does the same job more efficiently.
What does releaseAfter do on RateLimited middleware?
releaseAfter(seconds) sets how long a throttled job waits on the queue before its next attempt. Without it, Laravel derives the delay from the rate limiter's window. Be aware that every release still increments the job's attempt count, so pair releaseAfter() with a higher $tries value or a retryUntil() method to stop throttled jobs from failing prematurely.
Can I write my own job middleware for circuit breakers?
Yes — a job middleware is a plain PHP class with a handle($job, Closure $next) method, so a circuit breaker is a natural fit: count failures in cache, and release jobs without calling $next($job) once a threshold is breached. Check Laravel's built-in ThrottlesExceptions middleware first though — it delays all attempts after a set number of exceptions and covers most circuit-breaking needs without custom code.
Does WithoutOverlapping work across multiple queue workers?
Yes. WithoutOverlapping is built on Laravel's atomic locks, which are stored in your cache backend — so the lock is shared by every worker pointing at the same Redis, memcached, or database cache, across as many servers as you run. The caveats: your cache driver must support atomic locks, and by default the lock only applies within one job class unless you call ->shared().