---
title: "Stores — HTTP Client Rate Limits for Laravel | Roundly"
description: "Where request timestamps live — memory, any Laravel cache, Redis or the database — how each stays atomic, and how to write your own."
url: https://roundly-consulting.com/open-source/docs/http-client-rate-limits-for-laravel/stores
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[HTTP Client Rate Limits for Laravel](https://roundly-consulting.com/open-source/docs/http-client-rate-limits-for-laravel.md)

# Stores

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:

| Store | Shared across | Notes |
| --- | --- | --- |
| `InMemoryStore` | Every limit in one process | Default. No setup; one instance shared by every limit the app builds, but each worker or server keeps its own count. |
| `CacheStore` | Every process using the same cache | Any cache (file, database, memcached, redis, array). Check and record under the cache’s atomic lock — every built-in Laravel cache store provides one. |
| `RedisStore` | Processes and servers | Sorted set per window; check and record run as one Lua script. Uses redis\_connection. |
| `DatabaseStore` | Processes and servers | Two 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. At most once a minute a hit sweeps out owners whose newest hit — and penalties whose end — is older than a day plus an hour, so a long-running worker keyed per user stays bounded.

## 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:

```php
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):

```php
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. On MySQL/MariaDB the penalty and hit reads inside an attempt are locking reads, so they see the latest committed hits rather than an older REPEATABLE READ snapshot. It is tested on SQLite, PostgreSQL and MySQL. Select it with 'store' => DatabaseStore::class and publish and run its migration (see Installation):

```php
use RoundlyConsulting\HttpClientRateLimits\Store\DatabaseStore;

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

Both owner columns are 255 characters. A key longer than 255 bytes — or one starting sha256: — is stored as sha256: plus its SHA-256 digest (crypto-for-laravel’s Digest), the same for hits and owner rows, so long by keys never overflow the column and two keys never share a row.

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”:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/http-client-rate-limits-for-laravel.md)
