NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

A Store keeps the request timestamps each limit is measured against. Every store checks a request against all of a limit’s windows (and any adaptive penalty) and records it as one atomic step — Store::attempt() — so two workers can never both take the last free slot. Pick one based on how widely the budget must be shared:

StoreShared acrossNotes
InMemoryStoreEvery limit in one processDefault. No setup; one instance shared by every limit the app builds, but each worker or server keeps its own count.
CacheStoreEvery process using the same cacheAny cache (file, database, memcached, redis, array). Check and record under the cache’s atomic lock — every built-in Laravel cache store provides one.
RedisStoreProcesses and serversSorted set per window; check and record run as one Lua script. Uses redis_connection.
DatabaseStoreProcesses and serversTwo tables via Eloquent; each attempt is a short locking transaction. Uses database_connection. Publish and run the migration first.

InMemoryStore

The default. Keeps request timestamps in process memory. One instance is shared by every rate limit the app builds, so separate Http::rateLimit() calls accumulate into the same budget within a process. It is per process only: each queue worker, PHP-FPM child or server keeps its own count, so with several workers use the CacheStore, RedisStore or DatabaseStore instead. Atomic because nothing outside the process sees it.

CacheStore

Shares limits across processes using whatever cache the app already runs (file, database, memcached, array, …) — no Redis required. The check-and-record runs under the cache’s atomic lock (one per window, taken in a fixed order) when the cache store is a lock provider — every built-in Laravel cache store is; a custom store without locks is best-effort. A lock is held for at most lockSeconds (default 5), which is also how long a writer waits for it before throwing Laravel’s LockTimeoutException. Configure it with the cache_store / cache_prefix keys, or instantiate it directly:

use RoundlyConsulting\HttpClientRateLimits\Store\CacheStore;

$store = new CacheStore(store: 'redis', prefix: 'http-client-rate-limits');

A file cache (and its locks) is local to one server; share the budget across servers with a database, redis or memcached cache.

RedisStore

Keeps timestamps in sorted sets (each hit a unique member, so hits in the same millisecond all count), shared across processes and servers. The whole check-and-record is one Lua script, which Redis runs without interleaving any other command. Keys are hash-tagged by limit key (http-client-rate-limits:{acct-1}:second), so on Redis Cluster a limit’s windows and penalty share a slot; a compound limit whose windows use different keys needs a single-node Redis. Pass the Redis connection name (defaults to default):

use RoundlyConsulting\HttpClientRateLimits\Store\RedisStore;

$store = new RedisStore('default');

DatabaseStore

Shares limits through two database tables (Eloquent, never the DB facade) for apps that run only a file or database cache and have no Redis. Each attempt is a short transaction that first writes the limit key’s row in http_client_rate_limit_owners — a row lock on MySQL/MariaDB and Postgres, the write lock on SQLite — so a second worker’s attempt on the same key waits until the first has checked and recorded. Select it with 'store' => DatabaseStore::class and publish and run its migration (see Installation):

use RoundlyConsulting\HttpClientRateLimits\Store\DatabaseStore;

$store = new DatabaseStore;                       // the default connection
$store = new DatabaseStore(connection: 'limits'); // or `database_connection` in config

Give it its own connection (database_connection) when rate-limited calls can run inside a transaction of yours: on the shared connection the store’s transaction nests in yours, so its lock is held — and its hit stays invisible to other workers — until yours commits.

Retention and penalties

Every built-in store self-trims hits older than the largest supported window (one day, plus an hour’s margin), so long-lived keys — and the DatabaseStore tables — stay bounded. Every store also records server-imposed penalties for adaptive limiting via penalizeUntil() / penalizedUntil().

Writing your own

Implement the Store contract and select your class in config, with RateLimits::usingStore() or with setStore(). Its attempt() is “take your lock, ask RoundlyConsulting\HttpClientRateLimits\Support\Windows::evaluate(), record if allowed”:

namespace RoundlyConsulting\HttpClientRateLimits\Store;

use RoundlyConsulting\HttpClientRateLimits\DataTransferObjects\AttemptResult;
use RoundlyConsulting\HttpClientRateLimits\Limit;

interface Store
{
    // Check every window and, only when none is full and no penalty is active,
    // record the hit — as ONE atomic step. Otherwise record nothing and say how long to wait.
    /** @param list<Limit> $limits */
    public function attempt(array $limits, int $timestamp): AttemptResult;

    // Record a hit unconditionally (no check).
    public function hit(string $owner, int $timestamp): void;

    /** @return list<int> */
    public function hits(string $owner): array;

    /** @return list<int> */
    public function hitsSince(string $owner, int $timestamp): array;

    public function clear(string $owner, int $timestamp): void;

    // Server-imposed "do not send again before" timestamp (ms) for adaptive limiting.
    public function penalizeUntil(string $owner, int $timestamp): void;

    public function penalizedUntil(string $owner): ?int;
}

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.