spatie/guzzle-rate-limiter-middleware
A Guzzle middleware that enforces rate limits by specifying max requests per minute or second. When the limit is reached, requests sleep until allowed. Includes an InMemoryStore and supports custom persistent stores for cross-process rate limiting.
Install. composer require spatie/guzzle-rate-limiter-middleware. Requires PHP 8.3+ and Guzzle 6.3, 7 or 8 (Guzzle 8 landed in 2.2.0, so pin ^2.2 if you're on Guzzle 8). There's no service provider, no config file, no publishable assets, no facade — nothing auto-discovered. It is a plain middleware library you wire up by hand.
Read four files in src/, in this order:
RateLimiterMiddleware — the static factories perSecond($limit, $store = null, $deferrer = null) and perMinute(...). That is the entire public surface.Store — two methods: get(): array and push(int $timestamp, int $limit): void. This is the only interface you will implement.InMemoryStore — the default, and the cause of most confusion in production.SleepDeferrer — what happens when a limit is hit: usleep, blocking the current PHP process.Minimal client:
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware;
$stack = HandlerStack::create();
$stack->push(RateLimiterMiddleware::perMinute(60));
$client = new Client([
'handler' => $stack,
'base_uri' => 'https://api.partner.com/',
'timeout' => 15,
]);
First use case to wire up: a queued job or artisan command pulling from a partner API with a documented "N per minute" quota. Blocking a queue worker for a few seconds is cheap; blocking a user-facing request is not.
Laravel: bind one configured client per API as a singleton. Never call HandlerStack::create() inline. Every fresh stack builds a fresh InMemoryStore, so two clients get two independent quotas — i.e. no limit at all.
// app/Providers/PartnerServiceProvider.php
public function register(): void
{
$this->app->singleton(HandlerStack::class, function ($app) {
$stack = HandlerStack::create();
$stack->push(RateLimiterMiddleware::perMinute(
(int) config('services.partner.per_minute'),
$app->make(\Spatie\GuzzleRateLimiterMiddleware\Store::class),
));
return $stack;
});
$this->app->singleton(PartnerClient::class, fn ($app) => new PartnerClient(
new Client([
'handler' => $app->make(HandlerStack::class),
'base_uri' => config('services.partner.base_url'),
'timeout' => 10,
])
));
}
app(PartnerClient::class) is then your seam for tests. Through the Http facade, pass the stack as an option — it is forwarded to the underlying Guzzle client:
Http::withOptions(['handler' => app(HandlerStack::class)])->get('orders');
1. Limits live in config, next to credentials. They're contractual data from the provider's docs; they belong in config/services.php so they show up in a PR diff instead of being buried at a call site.
'partner' => [
'base_url' => env('PARTNER_URL'),
'per_minute' => env('PARTNER_RATE_LIMIT', 60),
],
2. Replace InMemoryStore the moment you have more than one worker. Four queue workers = four full quotas. Back the store with Redis or the DB, and prune inside push() using the $limit argument you're handed:
namespace App\Support\RateLimiting;
use Illuminate\Support\Facades\Cache;
use Spatie\GuzzleRateLimiterMiddleware\Store;
class CacheRateLimiterStore implements Store
{
public function __construct(private string $key = 'rate-limiter:partner') {}
public function get(): array
{
return Cache::get($this->key, []);
}
public function push(int $timestamp, int $limit): void
{
$timestamps = $this->get();
$timestamps[] = $timestamp;
$timestamps = array_slice($timestamps, -max($limit, 1));
Cache::put($this->key, $timestamps, now()->addMinutes(5));
}
}
The README's Cache::put(..., array_merge(...)) example has no pruning and no TTL — it's a sketch, not production code. Trim it, or the cache key grows unbounded.
3. Bind the interface, not the class. $this->app->bind(Store::class, CacheRateLimiterStore::class); — tests bind an in-memory store, staging can bind a spy that asserts pacing.
4. Stack ordering matters when retries are involved. Retries must consume budget, so the limiter belongs inside the retry middleware. With push(), the last thing pushed wraps everything below it:
$stack->push(RateLimiterMiddleware::perMinute(60, $store)); // inner: counts every attempt
$stack->push(Middleware::retry($decider, $delay)); // outer: re-enters the limiter
The same holds for Laravel's ->retry(): it re-sends through the same client, so every attempt is counted. That's correct behaviour — but it also means a 429 storm burns through your pacing budget, so size the limit with retries in mind.
5. Pacing and backoff are complementary. This package prevents you from sending too fast; it does nothing about a 429 you receive anyway (clock skew, another consumer sharing the key). Pair it with Middleware::retry() on 429/503 or ->retry().
6. One client per quota. There is no per-endpoint or per-user differentiation. If /search allows 5/min and /bulk allows 1/min, that's two stacks and two store keys. Don't try to model both with one stack.
7. Instrument via a custom Deferrer. Nothing is logged or emitted when the limiter sleeps — and silent multi-second stalls are exactly what you want visibility into. v2 accepts a Deferrer; decorate SleepDeferrer, log or emit a metric, then delegate. Check src/Deferrer.php for the exact defer() signature; it isn't documented in the README.
InMemoryStore is per-process, per-client, per-request. In a typical Laravel HTTP request it resets every time. It limits sequential calls within one worker only. Treat it as a local-development default, nothing more.
The middleware blocks with usleep. Never put a limited client on a user-facing code path with a tight quota — a 60/min limit means a burst of 61 requests stalls the request for a full second, and worse. Do the work in a job.
Http::pool() doesn't buy you much here. Guzzle's concurrency is still filtered through the limiter, which sleeps synchronously as each request is dispatched — so the pool effectively serialises its start. Use Bus::batch() with paced jobs instead of a pool if throughput matters.
Store::push() signature changed in v2 (int $timestamp, int $limit). Copy-pasted stores from older blog posts will fatal on the missing parameter — and you lose the pruning handle.
Custom stores aren't atomic. get() then push() is a read-modify-write race: two workers can both see room and both fire. For strict enforcement across processes, back the store with an atomic primitive (Cache::lock, a Redis Lua script, or RateLimiter::attempt) rather than two separate calls.
Key your store per API and per environment. rate-limiter:partner:production — otherwise staging traffic eats production quota, or two services sharing one Redis namespace fight over one key.
No per-request opt-out. Every request through a stack counts. If some calls must bypass pacing (health checks, webhooks), wrap your own middleware that inspects a request header/option and short-circuits before the limiter rather than building a second client.
Debugging checklist when the limit "isn't working": (a) are there multiple stacks or new Client() calls? (b) is the store bound as a singleton, or re-resolved per job? (c) is the queue worker forked in a way that re-bootstraps the container? (d) is the count higher than you think because retries are being counted? Log::info() in push() with the timestamp and count($timestamps) will answer all four in one run.
Nothing here reads Retry-After. The limiter is purely time-window based. On a 429, respect the header yourself — Middleware::retry() with a delay callback that reads $response->getHeaderLine('Retry-After') — otherwise you'll sleep for your own interval and immediately get throttled again.
How can I help you explore Laravel packages today?