Skyline

Rate-Limited APIs and Laravel Queues: One Request at a Time, Without Starving the Rest

· 16 min read · Boring Observability

Verified against Laravel 13 · Horizon 5.x

You are integrating with an API that allows sixty calls a minute and — the part that ruins everything — no parallel requests at all. Send two at once and it returns a 429, or worse, silently corrupts the record you were updating. Your queue, meanwhile, is a machine built to do exactly one thing: run as many jobs at once as it possibly can.

Every Laravel answer to this problem is really an answer to one of two different questions, and most teams reach for the wrong one. This article works through every option the framework and Horizon give you — the middleware, the limiters, the process counts — with an honest account of what each one costs, and then lands on the shape that actually holds: a single serial slot, shared by a fast lane and a slow lane, that urgent work can still jump into.

Key takeaways#

  • "Sixty a minute" and "one at a time" are different limits. A rate limit bounds how often a job may start; a concurrency limit bounds how many may be in flight. RateLimited solves the first and does nothing about the second — sixty workers can still fire simultaneously and stay perfectly within sixty-a-minute.
  • Laravel has no "don't pull" primitive. Every built-in throttle pops the job, discovers it can't run it, and pushes it back. BullMQ leaves rate-limited jobs at rest in the queue; Laravel converts them into churn.
  • That churn has three prices nobody budgets for: a released job goes to the back of the queue, it consumes an attempt, and it lands in the delayed set where Horizon largely stops showing it to you.
  • The best answer is the one the docs never mention: a dedicated supervisor with maxProcesses => 1. Concurrency of one falls out of the process count with no locks, no releases and no burned attempts.
  • Queue-jumping is where it gets subtle. Put api-high and api-default on that one worker and they share the limiter for free — but strict priority order means a flood on api-high starves api-default to zero. Weighting the queues is what turns that switch into a dial.

Two limits that look like one#

Read the vendor's docs again. "60 requests per minute" and "no concurrent requests" are not two ways of saying the same thing, and no single knob satisfies both:

  • A rate limit bounds how often a call may start — 60 per minute, 5 per second. It says nothing about how many are in flight at once.
  • A concurrency limit bounds how many calls may be running simultaneously — in our case, exactly one. It says nothing about how often they may start.

They are genuinely independent. Sixty calls per minute, each taking ten seconds, is ten requests in flight at any moment — fully compliant with the rate limit and a flagrant violation of the concurrency limit. Conversely, one call at a time, each taking ten milliseconds, is a hundred requests a second — perfectly serial and wildly over the rate limit.

A rate limit does not bound concurrency, and a concurrency limit does not bound rate. You need both, and Laravel gives you them in different places.

Laravel keeps this distinction in its API, if you know where to look. Redis::throttle() is a rate limiter and Redis::funnel() is a concurrency limiter; RateLimited middleware is the former and WithoutOverlapping middleware is the latter. Almost every "how do I rate-limit a Laravel job" answer online reaches for the rate half and quietly leaves the concurrency half unsolved. Here is each option, and what it really costs.

Option 1: the RateLimited middleware#

The documented answer, and the right tool for the "60 per minute" half. Define a named limiter, then attach the middleware to the job:

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

// In AppServiceProvider::boot()
RateLimiter::for('vendor-api', function (object $job) {
    return Limit::perMinute(60);
});
use Illuminate\Queue\Middleware\RateLimitedWithRedis;

public function middleware(): array
{
    return [new RateLimitedWithRedis('vendor-api')];
}

When the limit is hit, the middleware calls $job->release() with a delay of "time until the window resets, plus three seconds." You can override that with releaseAfter(), or use dontRelease() — but read the next sentence carefully, because it is the sharpest edge in the whole area. dontRelease() does not fail the job and does not defer it. It deletes it: no exception, no failed_jobs row, no failed() hook. It is a silent drop.

Prefer RateLimitedWithRedis over plain RateLimited whenever the limit is a real external contract. The generic version checks the counter and increments it as two separate cache operations — a textbook time-of-check-to-time-of-use race, so several workers can all pass the check before any of them increments and sail straight past your vendor's limit. The Redis variant does both in a single atomic Lua script. The docs undersell this as merely "more efficient."

Pros: documented, declarative, per-job, and the limiter key is shared across every job that names it — so two different job classes can draw on one budget.
Cons: it is only a rate limit. Sixty workers can still hit the API in the same instant and remain within sixty-per-minute. And every throttled job pays the release tax described below.

Option 2: WithoutOverlapping — a mutex, and only a mutex#

This is the concurrency half, and it gives you exactly the number you want: one.

use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        (new WithoutOverlapping('vendor-api'))
            ->shared()
            ->releaseAfter(5)
            ->expireAfter(120),
    ];
}

Three details in that snippet are doing load-bearing work, and all three defaults are wrong for this use case:

  • shared() is mandatory here. By default the lock key includes a hash of the job's class name, so SyncContact and PushInvoice get separate mutexes and happily call your no-parallel-requests API in parallel. shared() drops the class from the key, which is the only reason both jobs contend for the same lock.
  • releaseAfter() defaults to 0. A blocked job is re-queued with no delay at all: pop, fail the lock, re-enqueue, pop, fail the lock. That is a hot loop that burns a worker core and an attempt on every spin.
  • expireAfter() defaults to never. The lock is released in a finally, so an exception is fine — but a worker killed by --timeout or SIGKILL never runs it. The lock is then orphaned permanently, and that key is dead until someone force-releases it by hand. Always set it above your job timeout.

Pros: true mutual exclusion, works on any lock-capable cache driver, and it composes with RateLimited — stack both and you have covered both limits.
Cons: the concurrency is hard-coded to one; there is no ->limit(n). It is release-based, so it pays the full churn tax. And a lock is a coordination mechanism you have to operate: expiries to tune, orphans to clean up.

Option 3: funnels and throttles inside the job#

Below the middleware sit the primitives they are built on. Redis::funnel() is the one that matters here, because it is the only thing in Laravel that expresses "at most N of these at once" for N greater than one:

use Illuminate\Support\Facades\Redis;

public function handle(): void
{
    Redis::funnel('vendor-api')
        ->limit(1)
        ->releaseAfter(120)
        ->block(0)
        ->then(
            fn () => $this->callTheVendor(),
            fn () => $this->release(5),
        );
}

funnel caps how many run at once; its sibling throttle()->allow(60)->every(60) caps how often they start. Both are Lua-backed and atomic. Both have a quirk worth knowing: they are undocumented. They were first-class in the queue docs up to Laravel 8, then quietly dropped when RateLimited landed. The classes still ship and still work, but every tutorial teaching them is citing documentation that no longer exists.

Two traps. First, block() defaults to three seconds, and that is three seconds of the worker sleeping inside your job's runtime — pushing it toward retry_after, at which point a second worker picks the job up while the first is still holding it, and you have made the duplicate API call you were trying to prevent. Pass block(0) to fail fast. Second, omitting the failure closure throws a LimiterTimeoutException instead of releasing, which counts as a job failure.

Also note releaseAfter(120) above: that is the slot's safety TTL. If a worker is hard-killed mid-job the slot is not returned until it expires, so it must exceed your job timeout — otherwise a second worker is handed the slot while the first is still running, and the limit(1) guarantee quietly evaporates. Set it too long, though, and one stuck job stalls your entire serial pipeline for its full duration.

Laravel 12 lifted this concept onto the cache layer as Cache::funnel(), which works on any lock-capable driver and is properly documented — the same semantics without the Redis dependency.

Pros: atomic, and the only way to express a concurrency limit above one.
Cons: undocumented (the Redis variants), blocking by default, no middleware wrapper, and it lives inside handle() — so it is coordination logic smeared through your business logic.

Option 4: one queue, one worker — the answer nobody documents#

Step back. Every option so far achieves concurrency of one by having many workers fight over a lock, with all the releasing, re-queueing and attempt-burning that implies. But the queue already has a concurrency control, and it is the most reliable one there is: the number of worker processes.

Give the API its own queue and give that queue exactly one worker, and concurrency of one is not enforced — it is structural. There is nothing to contend for. No lock, no release, no attempt burned, and FIFO order is preserved because nothing ever loses its place.

'supervisor-vendor-api' => [
    'connection'   => 'redis',
    'queue'        => ['api-high', 'api-default'],
    'balance'      => false,
    'minProcesses' => 1,
    'maxProcesses' => 1,
    'rest'         => 1,
    'timeout'      => 30,
],

maxProcesses => 1 is the whole trick, and it is easy to get wrong. It is tempting to assume balance => false already pins the process count, but it does not: even unbalanced, Horizon scales the pool toward the number of ready jobs, clamped between minProcesses and maxProcesses. Pinning both ends to 1 is what actually guarantees a single serial slot.

And rest — one of Horizon's more obscure options — is the quiet hero. It sleeps the worker for N seconds after each job. On a single-process supervisor that is a rate limit: one job per (duration + 1s) is comfortably under sixty a minute. No middleware, no limiter key, no released jobs, no consumed attempts, nothing pushed to the back of the queue. It is a rate limit that costs nothing, and it appears in no version of the Laravel documentation.

Concurrency by process count needs no locks, because there is no one to lock against.

Pros: the cheapest correct answer. Zero release churn, zero lock administration, FIFO preserved, and it solves both limits at once.
Cons: two, and the second one is a genuine trap. It is coarse — rest paces every job on that worker identically and cannot burst up to a limit the way a token bucket can. And maxProcesses is a per-host cap, not a cluster-wide one.

That second point deserves its own paragraph, because it silently destroys the guarantee. Every machine running php artisan horizon builds its own provisioning plan and deploys every supervisor matching its environment. Nothing in Horizon coordinates process counts across hosts — there is no cluster-wide cap, no lock, no leader election. So three app servers running Horizon against the same Redis give you three of these "single" workers, and three parallel calls to an API that permits none.

The fix is to make exactly one host provision this supervisor, by giving it an environment of its own. Horizon resolves its environment as --environment, then config('horizon.env'), then APP_ENV — so the cleanest lever is the flag, on that host's Supervisor or systemd unit:

# On the one host that owns the vendor API. Everywhere else: php artisan horizon
php artisan horizon --environment=serial
'environments' => [
    'production' => [
        // ...your normal supervisors, on every app server
    ],

    'serial' => [
        'supervisor-vendor-api' => [
            'connection'   => 'redis',
            'queue'        => ['api-high', 'api-default'],
            'balance'      => false,
            'minProcesses' => 1,
            'maxProcesses' => 1,
            'rest'         => 1,
        ],
    ],
],

Mind the sharp edge: environment matching is a first-match wildcard lookup, and a host whose environment matches nothing starts zero supervisors, silently. If you cannot guarantee single-host ownership — or you would rather not bet your vendor contract on a deployment detail — keep a funnel(1) or a WithoutOverlapping as a backstop. It will almost never fire, and that is precisely what makes it worth having.

Option 5: chains and drip-feeds#

Two more patterns worth naming, mostly so you can rule them out deliberately rather than by accident.

Job chains (Bus::chain()) serialize by construction: job N+1 is not even enqueued until job N succeeds. For a known, finite, ordered batch — "sync these 200 records, in order" — this is genuinely good, and it needs no locks. But a chain is a closed linked list. Producers elsewhere in your app cannot append to it, two chains happily run in parallel with no mutual exclusion between them, and one failure abandons the entire remainder. It serializes a batch, not the API.

Drip-feeding — a scheduled command that dispatches N jobs a minute, or a self-redispatching "pacemaker" job that pulls work off a list on an interval — is the pattern teams reinvent when the built-ins disappoint them. It does work, and it has the same virtue as the single worker: nothing is ever popped that cannot be run. But you are now hand-rolling a scheduler on top of a queue, the scheduler's one-minute floor is your resolution limit, and you own every edge case yourself. Reach for it only if the single-worker supervisor genuinely cannot express your pacing.

Option 6: the packages#

The ecosystem has partially filled the gap, and the two mature options solve visibly different problems:

  • spatie/laravel-rate-limited-job-middleware wraps Redis::throttle in a fluent middleware, and adds the things core forgot: exponential backoff on release, time-based attempts, and events when the limit is hit. It is alive and widely used. It is still a rate limiter, and still release-based.
  • mxl/laravel-queue-rate-limit is the interesting one, because it does the thing core cannot: it throttles at the worker level. Configure 'allows' => 1, 'every' => 5 for a queue and it makes the worker sleep rather than releasing the job. No attempts consumed, no re-queueing, no reordering. The cost is coarseness — it throttles the whole queue regardless of job class — which, on a dedicated API queue, is exactly what you wanted anyway.

Concurrency limiting above one remains a genuine hole. Core gives you exactly one (WithoutOverlapping) or an undocumented funnel, and no maintained package fills the middle convincingly.

The options, side by side#

Approach Limits rate? Limits concurrency? Release churn? Main cost
RateLimited Yes No Yes Non-atomic; can overshoot a hard limit
RateLimitedWithRedis Yes No Yes Solves only half the problem
WithoutOverlapping No Yes — exactly 1 Yes Lock orphans; three wrong defaults
Redis::funnel() No Yes — N Yes Undocumented; blocks the worker by default
Redis::throttle() Yes No Yes Undocumented; blocks the worker by default
Chains No Within a chain No Closed set; can't serialize the API globally
One worker + rest Yes Yes — exactly 1 No Coarse pacing; per-host, not cluster-wide

The tax on nearly every option: Laravel pops before it checks#

Look down the "release churn" column. Every lock- and middleware-based option pays it, and it is worth understanding precisely, because it explains why teams who "just add the RateLimited middleware" end up with a queue that is somehow slower, lossier and less visible than before.

Here is the architectural choice at the root of it. When a job is rate-limited, Laravel has already popped it: reserved it, unserialized it, re-fetched its Eloquent models from the database. Only then does the middleware run, discover the budget is spent, and push the job back. Compare BullMQ, whose rate-limited jobs simply stay in the waiting state — the worker declines to pull, and nothing else happens. Laravel has no "don't pull" primitive. It can only pull, regret it, and put the job back.

Rate limiting in Laravel does not reduce work. It converts work into churn — and the churn grows with how far over the limit you are.

That single decision generates three consequences, none of which appear in the documentation:

  • Released jobs go to the back of the queue. On Redis, release() puts the job in the delayed set; when it matures it is RPUSHed onto the tail. On the database driver it is literally a new row with a new auto-increment id, sorted last. So the job that was next in line becomes the job that is last in line — every single time it is throttled. Under sustained load, an unlucky job can be starved indefinitely while newer work overtakes it. FIFO is not merely relaxed; it is inverted for exactly the jobs you are throttling.
  • Released jobs consume attempts. This one is documented, and it is still the most common way this blows up: your retry budget — a mechanism designed to protect you from failures — is spent on successful backpressure. With the default tries of 1, a job fails permanently the first time it is throttled. The fix is to stop counting attempts and start counting time, with retryUntil().
  • Released jobs re-collide as a herd. Every job blocked on the same limiter gets the same release delay — there is no jitter and no exponential backoff on middleware releases. They therefore all mature at the same instant, are migrated back in one batch, and are all popped and re-throttled together on the next tick. The herd re-synchronizes itself forever.
use DateTime;

// Stop counting attempts. Start counting time.
public function retryUntil(): DateTime
{
    return now()->plus(hours: 6);
}

None of this is hypothetical. Teams have followed this path to its conclusion — through custom throttling middleware, then dynamic per-user queues, then a forked Horizon autoscaler — and written the post-mortems. That is a remarkable amount of engineering to spend on "call an API slowly," and it is the strongest argument for choosing an approach that never releases a job in the first place.

Put the pieces together. The API needs serial access and a paced rate; you want urgent work to overtake routine work; and you want to pay as little churn as possible. That gives you one supervisor, two queues, one worker:

'supervisor-vendor-api' => [
    'connection'   => 'redis',
    'queue'        => ['api-high', 'api-default'],
    'balance'      => false,
    'minProcesses' => 1,
    'maxProcesses' => 1,   // the serial slot
    'rest'         => 1,   // the rate limit
    'timeout'      => 30,
],

The elegant part is what you don't need. Because both queues are drained by the same single process, they cannot produce a parallel request — not because a lock forbids it, but because there is no second worker to make one. The two queues share the concurrency budget structurally, by sharing the worker. And if you do add a RateLimited('vendor-api') middleware for a limit rest can't express, both queues share that budget too, because the limiter is keyed by its name, not by the queue.

Keep the rest of the queue — the other twenty supervisors doing your real work — entirely out of this. This supervisor is a deliberate bottleneck. Its whole job is to be slow.

Letting urgent jobs jump the line#

Now the interesting half. A customer clicks "sync now" and expects their record pushed within seconds — but there are 4,000 nightly-reconciliation jobs on the same queue, and the vendor lets you make one call a second. That is an hour of waiting behind work nobody is watching.

With balance => false, the queue list is a strict left-to-right priority order: a worker only looks at api-default when api-high is completely empty. So dispatching to api-high does exactly what you want — the urgent job is picked up next, and it inherits the same single slot and the same limiter, so jumping the line cannot breach the API contract.

SyncContact::dispatch($contact)->onQueue('api-high');

Strict priority is a blunt instrument, though. It is not a preference; it is an absolute. Push a few thousand jobs onto api-high and api-default does not get slow — it gets nothing, for as long as api-high stays non-empty. With one worker and a rate limit deliberately throttling you, "as long as it stays non-empty" can be hours. The low-priority queue is not deprioritized; it is switched off. This is the same trap covered in Horizon's balancing trade-offs, and a rate-limited single worker is the sharpest possible version of it, because your throughput is capped by contract.

Nor can you escape by turning balancing on: with balance => 'auto', Horizon ignores queue order entirely and gives each queue its own process pool — which would hand you two workers and two parallel API calls, the exact thing the vendor forbids. Auto-balancing and serial access are mutually exclusive.

Skyline's weighted queues are what turn that switch into a dial. They keep the single shared worker of balance => false — so the serial slot survives — but have it check the queues proportionally rather than in strict order:

'supervisor-vendor-api' => [
    'connection'   => 'redis',
    'queue'        => ['api-high', 'api-default'],
    'balance'      => false,
    'minProcesses' => 1,
    'maxProcesses' => 1,
    'rest'         => 1,
    'queueWeights' => [
        'api-high' => 5,
        // 'api-default' omitted => default weight of 1
    ],
],

Five to one: roughly five in every six pickups favour api-high, and the sixth still reaches api-default. Urgent work gets the overwhelming share of a scarce, contractually-capped resource — but the nightly reconciliation keeps draining instead of stalling until morning. One worker, one API call at a time, and neither lane starved.

For genuinely ad-hoc urgency — one job, right now, without a second queue — front-of-queue dispatching pushes a single job onto the head of the Redis list rather than the tail:

use Laravel\Horizon\InteractsWithFrontOfQueue;

class SyncContact implements ShouldQueue
{
    use Dispatchable, InteractsWithFrontOfQueue, Queueable;
}

SyncContact::dispatch($contact)->onFront();

One caveat, and it matters more here than anywhere else: onFront() acts on the ready list. A job sitting in the delayed set — because a rate limiter released it — is not on that list yet, so it cannot be moved to the front until it matures. Which is a neat illustration of the point this whole article keeps arriving at: once you let jobs be released, you lose the ability to reason about their order at all.

The part that goes dark#

There is one last cost, and it is the one that hurts at 3am. This architecture makes your queue harder to see.

Every job that a rate limiter releases lands in the delayed set. It is not running, not failed, and not really waiting — it is in a fourth state that Horizon barely acknowledges. A backlog of throttled work can be almost entirely absent from the dashboard you are staring at to work out why nothing is happening. Jobs delayed beyond your retention window can drop off the dashboard altogether while still, in fact, pending — a long-standing Horizon issue, closed without a fix.

Then there is a subtler one, specific to the single-slot pattern. With balance => false, Horizon registers the pool under the comma-joined name of its queues. Your two lanes stop being two rows and fuse into one pseudo-queue called redis:api-high,api-default. You cannot set a per-queue wait threshold for them, and the metrics dashboard shows them as a single entry. The pattern that bought you a serial slot has quietly cost you per-queue visibility — which is exactly the wrong trade, because a deliberately-throttled queue is the one you most need to watch.

This is the seam Skyline is built for. Delayed and retrying jobs are first-class: you can see what is waiting, when each one is due, and trigger any of them immediately rather than waiting out a backoff window you now regret configuring. You can open api-default and read the jobs actually inside it, in order. And when the vendor goes down, you can pause that one queue and let the work pile up safely, instead of watching a rate limiter grind 4,000 jobs through their retry budgets against an API that is returning 503s.

The core takeaway#

Start by naming your two limits separately, because the fix is different for each. Then resist the instinct to solve concurrency with a lock. Laravel's release-based throttles all share one flaw — they pop the job before they check whether they can run it — and everything unpleasant downstream (lost ordering, burned attempts, synchronized herds, jobs that vanish from the dashboard) follows from that. A dedicated supervisor pinned to maxProcesses => 1 gives you serial access for free, and rest paces it without releasing a single job.

Share that one slot between an api-high and an api-default queue and priority costs you nothing extra: same worker, same limiter, same contract with the vendor. Just don't let strict list order be the last word, or the fast lane will starve the slow one to a standstill — weight the queues so the urgent work gets the lion's share and the routine work still moves.

If you are choosing which queue a job belongs on in the first place, that is practice #2 in 12 best practices for Laravel background jobs. And if the jobs piling up behind your rate limiter are duplicates of each other rather than distinct work, Laravel job uniqueness controls is the one to read next.

Frequently asked questions

How do I limit a Laravel job to one at a time?

Two ways. The lock-based way is the WithoutOverlapping middleware with ->shared() — without shared(), the lock key includes the job class, so two different job classes still run in parallel. The cheaper way is structural: give the job its own queue and a Horizon supervisor with maxProcesses => 1, so a single worker process drains it serially. That needs no locks, releases no jobs and consumes no attempts.

Does the RateLimited middleware prevent parallel requests?

No. A rate limit bounds how often a job may start; it says nothing about how many run at once. Sixty workers can call an API simultaneously and still be within a limit of sixty per minute. To bound concurrency you need WithoutOverlapping, Redis::funnel(), or a supervisor pinned to one worker process.

What is the difference between Redis::throttle() and Redis::funnel()?

throttle() is a rate limiter — at most N starts per time window. funnel() is a concurrency limiter — at most N running at once, with slots held for the job's actual duration and returned when it finishes. Both are atomic and Lua-backed, and both were dropped from the Laravel documentation after version 8 despite still shipping and working.

Why do my rate-limited jobs fail after a few minutes?

Because releasing a rate-limited job back onto the queue still increments its attempt count. With the default tries of 1, a job fails permanently the first time it is throttled — the retry budget meant to protect you from failures is spent on successful backpressure instead. Stop counting attempts and start counting time by defining retryUntil() on the job.

How do I let an urgent job jump ahead of a rate-limited queue?

Run an api-high and an api-default queue on the same single-worker supervisor: they share the worker, so they share the concurrency budget, and they share the rate limiter because it is keyed by name rather than by queue. With balance => false the queue list is a strict priority order, so a flood on api-high starves api-default entirely — Skyline's queueWeights make the two proportional (5:1) instead, so urgent work dominates without the slow lane stalling.

Queue control, not just queue monitoring.

Skyline is a drop-in replacement for Laravel Horizon that lets you act on what you see — pause a queue, jump a job to the front, drain a backlog.

Sign up for early access