Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Guzzle Rate Limiter Middleware Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. RateLimiterMiddleware — the static factories perSecond($limit, $store = null, $deferrer = null) and perMinute(...). That is the entire public surface.
  2. Store — two methods: get(): array and push(int $timestamp, int $limit): void. This is the only interface you will implement.
  3. InMemoryStore — the default, and the cause of most confusion in production.
  4. 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');

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core