Where does Laravel store rate-limiter counters, and when would you set the cache.limiter key or call throttleWithRedis() in bootstrap/app.php?
answer
- counters live in a cache store
- cache.limiter, else the default store
- skeleton default: CACHE_STORE=database
- throttleWithRedis maps throttle to the Redis class
- shared store across servers
basics
~10 sRateLimiter keeps counters in the cache store named by cache.limiter, or the default store when that key is absent. throttleWithRedis() in bootstrap/app.php maps the throttle alias to ThrottleRequestsWithRedis, which counts directly in Redis.
solid answer
~40 sThe `RateLimiter` singleton is built on `cache()->driver(config('cache.limiter'))`; the key is absent from the default config, so counters land in the **default cache store** — `database` in a fresh Laravel 13 skeleton. Adding `'limiter' => 'redis'` to `config/cache.php` moves only rate-limit counters, used by the default `throttle` middleware and by `RateLimiter::attempt()`, to Redis while the rest of the cache stays put. Separately, `$middleware->throttleWithRedis()` in `bootstrap/app.php` remaps the `throttle` alias to `ThrottleRequestsWithRedis`, which bypasses the cache store and talks to the default Redis connection through a `DurationLimiter` script. Whatever you pick, every app server must see the **same** store, or each server enforces its own copy of the limit.
code
php · 11 lines<?php
// config/cache.php (excerpt)
return [
'default' => env('CACHE_STORE', 'database'),
// Rate-limit counters go to Redis; everything else stays on the default store.
'limiter' => 'redis',
// 'stores' => [...],
];go deeper
Recall that rate-limit counters live in the cache and that the cache must be shared between servers for limits to hold.
Explain cache.limiter falling back to the default store, the database default in a fresh app, and what throttleWithRedis() remaps.
Pick a shared store for multi-server deploys, know the Redis middleware's per-limit counting order, and avoid resets from cache flushes.
Decide whether rate limiting belongs in the app, a gateway or both, and what store availability and cost that commits the platform to.
## Where the numbers live Laravel's limiter is stateless code over a shared counter. For each key it keeps two cache entries: - the **attempt count**, created with the decay period as its lifetime and incremented per hit; - a **timer** entry holding the Unix time the window ends, used for `Retry-After`. Those entries go into a cache store chosen once, when the `Illuminate\Cache\RateLimiter` singleton is built: ```php new RateLimiter($app->make('cache')->driver($app['config']->get('cache.limiter'))); ``` `cache.limiter` is **not** in the default `config/cache.php`, so the lookup returns null and the limiter uses the **default store**. In a new Laravel 13 app that is `CACHE_STORE=database`: every throttled request reads and writes rows in the `cache` table. ## Why the store matters | Store | Effect on limiting | |---|---| | `array` | Under PHP-FPM, counters vanish at the end of each request, so nothing is ever limited | | `file` on several servers | Each server counts separately; the effective limit is multiplied by the server count | | `database` | Shared and correct, but adds database writes to every limited request | | `redis` | Shared, fast, built for counters | For a weather-data API behind a load balancer the requirement is simple: **one shared, fast store** for counters. ## Option 1: `cache.limiter` ```php // config/cache.php 'default' => env('CACHE_STORE', 'database'), 'limiter' => 'redis', ``` - Only the rate limiter moves; `Cache::get()` elsewhere keeps using the default store. - It affects everything built on the `RateLimiter` class: the default `ThrottleRequests` middleware and your own `RateLimiter::attempt()`, `tooManyAttempts()` and `hit()` calls. - Configure the `redis` store itself as for any cache store. ## Option 2: `throttleWithRedis()` ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->throttleWithRedis(); }) ``` - The `throttle` alias now points at `Illuminate\Routing\Middleware\ThrottleRequestsWithRedis`. - That class ignores the cache store and uses the **default Redis connection** with `Illuminate\Redis\Limiters\DurationLimiter`, whose check and increment each run as a Lua script inside Redis. - Named limiters, `by()`, `after()` and `response()` work the same way; only the counting engine changes. - One behavioural difference: for a limit array, the Redis class checks and counts each limit in turn, so a request rejected by a later limit has already been counted by the earlier ones. The default class checks all limits before counting any. - It does not affect direct `RateLimiter::attempt()` calls, which still use the cache store. ## Verifying the setup A quick production check tells you whether counters are really shared: 1. Send a few requests to a throttled route and watch `X-RateLimit-Remaining` fall by one each time. 2. If the value jumps around or falls by less than one per request, the requests are landing on servers that count separately. 3. Inspect the store: with the `database` store the counters appear as rows in the `cache` table; with Redis they appear as keys under the configured prefix. ## Choosing 1. If Redis is already your default cache store, the default middleware is already shared; `throttleWithRedis()` is an optimisation. 2. If the default store is `database` or `file` but Redis is available, set `cache.limiter` to move all limiter traffic, or `throttleWithRedis()` to move only route throttling. 3. Without Redis, make sure the default store is shared (`database` works) and never `array` outside tests. ## Pitfalls - Forgetting that the limiter now depends on Redis: if the store is unreachable, requests to throttled routes fail with errors instead of skipping the limit. - Flushing the cache during a deploy resets every client's counters. - Pointing `cache.limiter` at a store name that is not configured fails when the limiter is first resolved. - Two apps sharing one Redis without distinct key prefixes can share counters for identical keys.
- Does throttleWithRedis() change where RateLimiter::attempt() stores its counters?No. `throttleWithRedis()` only remaps the `throttle` middleware alias to `ThrottleRequestsWithRedis`, which talks to Redis directly. `RateLimiter::attempt()` still uses the `RateLimiter` class and therefore the store named by `cache.limiter`, or the default store.
- Why does a limit of 60 per minute let a client make about 180 requests per minute on three servers?The counters live in a store each server keeps separately, such as `file`, so each server counts only the requests it received. Move the counters to a store all servers share — Redis via `cache.limiter` or `throttleWithRedis()`, or the database store.
saying these in an interview costs you the question
- Rate-limit counters are kept in PHP memory per worker
- cache.limiter changes the default cache store for the whole app
- throttleWithRedis() also moves RateLimiter::attempt() counters to Redis
- The array cache store is fine for rate limiting in production
- Laravel defaults the limiter to Redis in a fresh install