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.
Architecture fit. This is an HTTP-client-level concern, not a framework concern. The package is a single Guzzle middleware plus a two-method Store interface — there is no service provider, no config file, no facade, no publishable assets. That is a good fit for a Laravel codebase precisely because it makes no architectural demands: it composes into whatever HandlerStack you already build, and the integration seam is a singleton binding in a service provider. The one architectural commitment it forces is that outbound calls to a rate-limited provider must go through one shared, configured Guzzle client. If the codebase calls providers ad hoc via new Client(), Http::get() without a handler, or a mix of Guzzle and cURL, the limiter is decorative. Scoping this package therefore means scoping an outbound-client standardization effort — modest, but it is real work and should be planned as such.
Integration feasibility. High. The public API is two static factories (perSecond, perMinute) and one interface to implement. A single engineer can wire a working client in under an hour; a production-grade version with a shared store, logging deferrer, and retry interplay is one to two days. The genuine design work is not the package — it is the Store implementation, which the package deliberately leaves to us. The README's example store (plain array_merge, no pruning, no TTL) is a sketch and must not be merged; it leaks unbounded cache entries and creates a read-modify-write race.
Technical risk. Ranked by what would actually hurt:
| Risk | Severity | Notes |
|---|---|---|
Default InMemoryStore silently gives each PHP process a full quota |
High | Four queue workers = 4× the limit. This is the failure mode the README itself flags, and it fails open and silently — you discover it via a partner 429 or a suspended key. |
| Non-atomic store under concurrency | High | Cache::get → append → Cache::put is not atomic. Under parallel workers it over-admits, and it can clobber entries. Any store we ship needs a lock or an atomic data structure (Redis sorted set / ZADD+ZREMRANGEBYSCORE). The package gives us no primitive for this. |
Blocking usleep in the request path |
Medium–High | The limiter doesn't defer or queue — it blocks the PHP process. Acceptable in a queue worker; unacceptable in a synchronous web request with a user waiting. Must be an explicit placement decision. |
| No 429 handling | Medium | Pacing prevents the 429; it does not recover from one. Requires pairing with retry/backoff middleware. Also means retries must be counted against the budget, which forces stack ordering. |
| No observability | Medium | No event, no log line, no metric when the limiter sleeps. Silent multi-second stalls will surface as unexplained latency unless we decorate the Deferrer ourselves. |
| No per-endpoint / per-tenant granularity | Low–Medium | One limit per client. Differing quotas mean multiple clients, stacks, and store keys — fine, but plan for it rather than discovering it. |
| Low external validation | Low | 169 stars, zero known dependents in the dataset. Vendor reputation (Spatie) and MIT license offset this; the codebase is small enough to audit directly, which we should do. |
| PHP 8.3 floor | Low | Hard blocker only if any service sits on 8.1/8.2. |
Guzzle 8 support only from 2.2.0 |
Low | Pin ^2.2 explicitly or Composer can resolve an incompatible version. |
Key questions to resolve before committing:
Stack fit. Laravel + Guzzle is the native environment: composer require spatie/guzzle-rate-limiter-middleware with ^2.2 pinned, PHP 8.3+, Guzzle 6.3/7/8. It also works through the Http facade, since options are forwarded to the underlying client — that path is viable but easier to get wrong, because the handler stack must be a singleton for the store to survive across calls in a worker. Recommendation: bind one configured client per external API as a container singleton, expose it as a small typed wrapper class, and make that wrapper the only sanctioned entry point. Tests then swap it through the container. That is the seam; everything else is an implementation detail.
Migration path. Do not attempt a codebase-wide sweep. Sequence by exposure:
Client, which use Http, which bypass both. This is the real cost estimate and should precede any packaging decision.Deferrer is injectable in v2; wrap SleepDeferrer, log every block with duration and provider, and emit a counter. You want the baseline stall profile before it affects anything a customer notices.If an existing ad hoc pattern is being replaced (hand-rolled usleep, hardcoded retry-with-delay, or nothing at all), the package is a net simplification — it consolidates behaviour that is currently duplicated per call site into one configured client. That consolidation is the primary deliverable; rate limiting is the vehicle.
Compatibility. Two hard gates: PHP ≥ 8.3 across every service and CI image, and ^2.2 pinned for Guzzle 8 environments. Verify the Guzzle major in composer.lock before rollout — a version resolution surprise here surfaces as a type error at the handler, not as a clean failure. Beyond that, the package has no framework coupling, so Laravel and PHP upgrades are unaffected, and the two-method Store interface means our store implementation is portable if we ever swap the limiter out.
Sequencing dependency to call out explicitly: stack ordering. The rate limiter must sit inside the retry middleware so retries are counted against the budget. Get this wrong and a 429 storm drains a budget that was never charged for the attempts. Worth a test that asserts the ordering rather than relying on reviewer attention.
Maintenance. Low, and bounded. The package is small, single-purpose, MIT-licensed, actively released (PHP 8.4 and Guzzle 8 support both landed as versioned releases), and its public API has been stable since v2's Store::push($timestamp, $limit) signature change. It will not generate a backlog. The maintenance surface we inherit is the opposite of what the package ships: we own the Store implementation and the stack-composition wiring, and that is the code that will need care.
Support. Vendor support is community-grade — GitHub issues, Spatie's security contact for vulnerabilities. There is no SLA, no commercial backing, and the codebase is small enough to vendor or fork if abandoned, which materially de-risks the dependency. Internally, support burden concentrates on a single owner-adjacent question: "why is this worker slow?" Establish the logging deferrer up front and that question answers itself. Without it, support cost is entirely diagnostic guesswork.
Scaling. Scales along three axes that must each be addressed:
$limit value handed to push(), and set a TTL. Unbounded growth here is a slow-motion incident, not a bug report.Horizontal scaling of the API tier does not change any of this, provided the store is externalized. Vertical scaling does not help — more concurrency against a shared quota just increases contention on the store.
Failure modes — enumerate these in the runbook, because most are silent:
Pacing and backoff are complementary, not redundant. Budget for both.
Ramp-up. Roughly two days of engineering for one person, spread as: half a day to install, pin, and audit src/; half a day to build and test an atomic cache-backed store plus a logging deferrer; half a day on stack ordering and retry interaction with a covering test; half a day to wire the singleton and migrate the pilot provider's call sites. Add a day if client standardization is in scope. No new infrastructure is required beyond Redis, which is almost certainly already present. Total cost of ownership is dominated by the store we write, not by the package — which is the right way to frame the decision: this is a low-cost, low-risk, narrow-scope adoption that pays off only if the shared-store and observability work is committed alongside it. Adopting the middleware without those two pieces is worse than nothing, because it produces false confidence that a quota is being respected.
How can I help you explore Laravel packages today?