saloonphp/rate-limit-plugin
A Saloon plugin for respectful API clients: it tracks requests and handles rate-limit responses, retrying or delaying calls when limits are reached. Drop it into Saloon's middleware pipeline for simple, reliable rate-limit management.
The rate-limit plugin is a Saloon add-on: it adds client-side throttling to a Saloon connector so you stop hammering an upstream API before it starts returning 429s (or, worse, banning you). It is not a Laravel rate limiter replacement — it complements RateLimiter/throttle middleware by tracking outbound calls.
Minimal setup:
composer install saloonphp/rate-limit-plugin
use Saloon\Http\Connector;
use Saloon\RateLimitPlugin\Traits\HasRateLimits;
use Saloon\RateLimitPlugin\Limit;
use Saloon\RateLimitPlugin\Contracts\RateLimitStore;
use Saloon\RateLimitPlugin\Stores\LaravelCacheStore;
class GitHubConnector extends Connector
{
use HasRateLimits;
public function resolveBaseUrl(): string
{
return 'https://api.github.com';
}
protected function rateLimits(): array
{
return [
Limit::allow(5000)->everyHour(),
Limit::allow(60)->everyMinute()->for('search'),
];
}
protected function resolveRateLimitStore(): RateLimitStore
{
return new LaravelCacheStore(); // pass a store name for a dedicated driver
}
}
Nothing else in your codebase changes: $connector->send($request) behaves as before, except that when a limit is exhausted a RateLimitReachedException is thrown before the request leaves your app.
Where to look first:
Saloon\RateLimitPlugin\Limit — the fluent builder (allow(), everySecond/Minute/Hour/Day(), scoping).Saloon\RateLimitPlugin\Contracts\RateLimitStore — the small interface you implement to back limits with anything (Redis, DB, DynamoDB).Saloon\RateLimitPlugin\Stores\LaravelCacheStore — the one you'll actually use in Laravel.Saloon\RateLimitPlugin\Exceptions\RateLimitReachedException — what you catch in jobs/controllers.First use case: a third-party API with a documented quota (GitHub 5000/hr, Stripe ~100 reads/sec, HubSpot burst limits) where you already have a Saloon connector. Add the trait, declare the documented numbers, point the store at Redis, done — that's a 10-line change that removes an entire class of production incidents.
Layered limits on one connector. Declare every documented window at once; all limits are evaluated and the most restrictive wins. Typical: a burst limit (60/second) plus a daily cap (10000/day).
Scoped/named limits for endpoint-specific quotas. Search endpoints, bulk endpoints, and write endpoints usually have separate budgets. Give each its own scope name (e.g. ->for('search')) so a search-heavy job doesn't starve your normal reads. Keep the names in constants or an enum — stringly-typed scopes rot fast.
Shared store across queue workers. This is the single most important pattern. With a per-process store, four queue workers each think they have the full budget. Wire LaravelCacheStore to Redis/Memcached (or database/file if you accept weaker atomicity) and the limit becomes cluster-wide.
Base connector + inheritance. Put the trait and store resolution on an abstract VendorConnector, then let per-service connectors override rateLimits() and merge parent limits. Keeps the store config in one place.
Catch and reschedule in jobs. The exception is thrown before dispatch, so nothing is consumed upstream — ideal for a delayed retry:
try {
$connector->send(new SyncOrders($account));
} catch (RateLimitReachedException $e) {
$limit = $e->getLimit();
// seconds remaining in the current window
$this->release($secondsUntilReset + 1);
}
Use release() rather than delete() so you don't burn queue attempts, and make the job idempotent.
Pre-flight checks for user-facing flows. If a user clicks a button and you'd rather show "try again in 40s" than throw, inspect the remaining budget on the connector before sending and short-circuit with a friendly response.
Sync from upstream headers. For APIs that return X-RateLimit-Remaining/Retry-After, you can reconcile your local counter with reality (via a custom store, or by reading the response in a Saloon response middleware/Response hook) so limits stay accurate even when other systems hit the same account.
Testing. Swap in ArrayStore in your test suite so state is per-test and never pollutes Redis; assert that the Nth+1 call throws rather than asserting on the number of faked HTTP calls.
resolveRateLimitStore(), limits live in process memory and reset on every request — in Octane/FPM that means the limiter effectively does nothing. Configure it explicitly and assert it in a test.HasRateLimits. Previously a cache hit skipped the limiter entirely, letting a mixed cached/live workload drift past the real quota. Behaviourally: if you cache aggressively and rate limit on the same connector, cache hits will now consume budget. If that's not what you want, either use a separate connector without the trait for cached reads, or scope the cached endpoints to their own named limit.RequestException. It's thrown before a pending request is sent, so catch (RequestException $e) won't catch it. Catch RateLimitReachedException explicitly (or SaloonException) — otherwise it bubbles out of a job as an unhandled failure.file and database (without locking) can race under concurrency. Don't rely on tight windows on weak drivers.resolveRateLimitStore() for storage, a custom RateLimitStore implementation for non-cache backends (DB table with a unique constraint, DynamoDB, a centralised limiter service), and scoped Limit names for per-endpoint policy. Those three cover almost every customisation you'll need.How can I help you explore Laravel packages today?