When a client asked me why their integration with a payment gateway kept getting HTTP 429 responses, the answer was simple: the queue was blasting the endpoint with dozens of calls per second. The gateway only allows one request per second per API key, and our job runner was ignoring that limit. The result was a cascade of retries, a bloated failed_jobs table, and an angry client.
The fix boiled down to two ideas, throttle the specific job type so that only one request hits the API at a time, and make sure the rest of the queue keeps moving. Laravel already gives us the tools to do both; you just have to wire them together correctly.
Why a naïve queue fails with rate limits
A typical Laravel queue looks like this:
// app/Jobs/SendPayment.php
class SendPayment implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle() {
$client = new GuzzleHttp\Client();
$client->post('https://api.example.com/pay', [
'json' => $this->payload,
]);
}
}
If you dispatch 100 SendPayment jobs, the queue worker will pull them one after another as fast as the CPU allows. Unless you add a delay, each HTTP request is sent immediately. When the API enforces a 1 rps limit, the first few calls succeed, the rest get a 429, Laravel retries them, and the queue fills up.
The Laravel way: RateLimited middleware
Laravel 8 introduced the RateLimited middleware for jobs. It works exactly like route throttling, but on a per-job basis. You can attach it in the job's middleware method:
// app/Jobs/SendPayment.php
class SendPayment implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function middleware() {
return [new RateLimited('payment-api')];
}
public function handle() {
// same HTTP call as before
}
}
RateLimited('payment-api')tells Laravel to treat all jobs that use the same key as a single bucket.- By default the bucket allows 60 jobs per minute. You can customise the limit in
App\Providers\RouteServiceProvideror directly in the middleware constructor.
Customising the bucket for a strict 1 rps limit
The gateway we were dealing with only allows one request per second. To enforce that, we need a bucket of size 1 with a refill interval of one second. Laravel lets you pass a closure that returns the limit:
public function middleware() {
return [new RateLimited('payment-api', function () {
return RateLimiter::perSecond(1);
})];
}
RateLimiter::perSecond(1)creates a bucket that releases one token every second.- When the bucket is empty, the job is released back onto the queue with a delay equal to the time until the next token becomes available.
Keeping other jobs alive
A common mistake is to put all jobs on a single queue and let the throttled jobs block the worker. Laravel's queue workers are single-process by default, so a worker waiting for a token sits idle while other jobs sit in line.
The solution is to separate the throttled jobs onto their own queue and run a dedicated worker for that queue. In config/queue.php you can define a new connection:
'connections' => [
'redis' => [
'driver' => 'redis',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
],
'payment' => [
'driver' => 'redis',
'queue' => 'payment',
'retry_after' => 90,
],
],
Then dispatch the job to that queue:
SendPayment::dispatch($payload)->onQueue('payment');
Finally, spin up a worker that only listens to the payment queue:
php artisan queue:work redis --queue=payment --sleep=1 --tries=3
- The
--sleep=1flag tells the worker to pause a second between empty polls, which aligns nicely with the 1 rps limit. - Other queues (e.g.,
default,emails) keep their own workers, so they never get starved.
Handling burst limits and back-off
Some APIs allow a short burst, say 5 requests in 5 seconds, then enforce the 1 rps rule. Laravel's RateLimited can accept a custom RateLimiter instance that defines both a burst capacity and a refill rate:
use Illuminate\Support\Facades\RateLimiter;
public function middleware() {
return [new RateLimited('payment-api', function () {
return RateLimiter::for('payment-api', function (PendingRateLimit $limit) {
$limit->allow(5)->everySeconds(5); // burst of 5
$limit->perSecond(1); // steady rate
});
})];
}
allow(5)->everySeconds(5)creates a bucket with 5 tokens that refill every five seconds.- After the burst is exhausted, the
perSecond(1)rule takes over.
Practical warning, avoid infinite retries
If you let Laravel retry forever, a persistent 429 will keep the job alive and fill the failed_jobs table once it finally gives up. Always set a reasonable --tries count and consider a custom back-off strategy:
public function backoff() {
// exponential back-off: 1s, 2s, 4s, 8s …
return [1, 2, 4, 8];
}
- The array defines the delay in seconds for each subsequent retry.
- Coupled with the rate limiter, this ensures the job respects the API's cadence even when it keeps returning 429.
Monitoring the flow
When you split queues, it’s easy to lose sight of what’s happening. I add a tiny log line in the job’s handle method:
Log::info('Sending payment', ['payload_id' => $this->payload['id']]);
And I expose the queue length via Horizon or a simple Redis key:
$pending = Redis::llen('queues:payment');
Metrics::gauge('payment_queue_length', $pending);
Seeing the queue length dip after a successful call tells you the throttling works; a sudden spike signals a problem upstream.
FAQ
How do I throttle multiple API keys independently?
Create a separate RateLimited key for each API key, e.g. RateLimited('payment-api-'.$this->apiKey). Laravel will keep distinct buckets for each key.
What if I need to process 10 k items per hour but the API only allows 1 rps?
Schedule a cron job that dispatches a batch of jobs every minute, letting the queue worker spread them out. The rate limiter will still enforce the 1 rps rule, so you won’t exceed the quota.
Can I use the same approach with non-Redis queue drivers?
Yes. The RateLimited middleware works with any driver that supports job release delays (database, SQS, etc.). Just ensure your worker’s --sleep value is reasonable for the chosen limit.
Wrapping up
The key takeaway is that rate limits belong in the queue, not in the HTTP client. By attaching Laravel’s RateLimited middleware, customizing the bucket, and isolating the throttled jobs onto their own queue, you keep the rest of your system responsive while staying polite to third-party APIs. A few minutes of setup saves hours of debugging later, and the pattern scales nicely as you add more external services.