Cache and Rate Limiting
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. A cache keeps the result of slow work, such as a heavy query, an API call or a rendered fragment, so the next request reads it instead of redoing it. The same stores hold Laravel’s locks, rate-limit counters, unique-job and overlap locks. This page measures the stores and runs each API against Bookshelf. Redis itself, including eviction policies and client-side caching, is covered on Redis.
Stores
config/cache.php defines the stores, and CACHE_STORE picks the default. A new application uses database
(a cache table created by the first migration). The others are redis, memcached, file, dynamodb, mongodb,
array (memory, for one request or one test) and null. A thousand writes and reads of a small array on each store,
on this machine with SQLite as the database:
<?php
foreach (['array', 'file', 'database', 'redis'] as $store) {
$cache = Cache::store($store);
for ($i = 0; $i < 1000; $i++) $cache->put("k$i", ['n' => $i], 60);
for ($i = 0; $i < 1000; $i++) $cache->get("k$i");
}
array 1000 put 4.4 ms, 1000 get 3.7 ms
file 1000 put 108.5 ms, 1000 get 19.9 ms
database 1000 put 1018.6 ms, 1000 get 53.1 ms
redis 1000 put 50.5 ms, 1000 get 49.1 ms
Source: Laravel — Cache Configuration
The database store’s writes cost about a millisecond each, because every put is an upsert that SQLite commits to
disk. Reads are close to Redis. On MySQL or PostgreSQL over the network, both take a round trip. The file
store is fast on one server and useless on several, since each has its own disk. Redis is the usual production
store, because every server and worker shares it and it also supports tags and fast locks.
remember, flexible and touch
Cache::remember($key, $ttl, $callback) returns the cached value or runs the callback and stores its result. When
the entry expires, the next request pays the full cost, and under load many requests pay it at once (a cache
stampede). Cache::flexible($key, [$fresh, $stale], $callback) adds a grace period. After $fresh seconds, the
stale value is still served, and it is recomputed after the response has been sent. Only after $stale seconds
does a request wait. A route that takes 800 ms to compute its value:
<?php
// routes/web.php
Route::get('/stats/popular', function () {
$started = microtime(true);
$value = Cache::store('redis')->flexible('popular-books', [5, 20], function () {
usleep(800_000); // a slow aggregate query
return ['computed_at' => now()->format('H:i:s'), 'top' => Book::orderBy('id')->limit(3)->pluck('title')->all()]; // an array: cached objects come back broken
});
return $value + ['took_ms' => (int) ((microtime(true) - $started) * 1000)];
});
Source: Laravel — Stale While Revalidate
22:29:14 {"computed_at":"22:29:15","top":["Kindred","Parable of the Sower","The Dispossessed"],"took_ms":803} first request computes
22:29:15 {"computed_at":"22:29:15",…,"took_ms":0} fresh hit
22:29:21 {"computed_at":"22:29:15",…,"took_ms":0} stale, served at once, refreshed after the response
22:29:23 {"computed_at":"22:29:22",…,"took_ms":0} the refreshed value
22:29:46 {"computed_at":"22:29:46",…,"took_ms":802} past 20 s: computes again
Source: Laravel — Stale While Revalidate
The first version of this route used ->pluck('title') without ->all(). The first request was correct, and every
cached response after it contained "top":{"__PHP_Incomplete_Class_Name":"Illuminate\\Support\\Collection"}. Laravel
13’s serializable_classes => false refuses to unserialize objects from the cache. The reason and the fix are on
Security: CSRF, Encryption and Hashing. Cache
arrays and scalars.
Cache::touch($key, $seconds), new in Laravel 13, extends the TTL of an existing entry without reading and
rewriting it. It returned true for an existing key and false for a missing one, and Redis then reported a TTL of
3600 for the touched key.
Tags
Tags group entries so they can be flushed together, for example everything about one author:
<?php
Cache::store('redis')->tags(['books', 'author:1'])->put('book:1', 'Kindred', 600);
Cache::store('redis')->tags(['books'])->put('book:2', 'Parable', 600);
Cache::store('redis')->tags(['author:1'])->flush();
Cache::store('database')->tags(['books'])->put('x', 1, 60);
Source: Laravel — Cache Tags
after flushing author:1 -> book:1: NULL, book:2: 'Parable'
untagged get of book:2: NULL
database tags: BadMethodCallException: This cache store does not support tagging.
A tagged entry is stored under a key derived from its tags, so it must be read with the same tags. A plain get
did not find book:2. Only Redis and Memcached support tags. The database, file and dynamodb stores throw, so
code that uses tags ties the application to those stores. Key prefixes with a version number (books:v7:…) work on
any store.
Atomic locks
Cache::lock($name, $seconds) is a mutex shared by every process that uses the store. Four processes against Redis:
A takes the lock and holds it for three seconds, B tries once, D waits one second, and C waits up to five:
<?php
$lock = Cache::store('redis')->lock('catalogue-import', 10); // expires after 10 s even if never released
$lock->get(); // A: true, then release() after 3 s
$lock->get(); // B: false, it does not wait
$lock->block(1); // D: LockTimeoutException after 1 s
$lock->block(5, fn () => importCatalogue()); // C: waits, runs, releases
Source: Laravel — Managing Locks
A 0.2s got the lock, owner H65yvHIB…
B 1.0s get(): false
D 1.7s block(1): Illuminate\Contracts\Cache\LockTimeoutException
A 3.2s released
C 3.4s got it after waiting
Source: Laravel — Atomic Locks
The expiry protects against a process that dies holding the lock, but it is also a limit. If the work takes longer
than the expiry, a second process gets the lock while the first is still working. Each lock has an owner token, so
release() from another process fails. Cache::restoreLock($name, $owner) lets a queued job release a lock that
the request took. The withoutOverlapping and onOneServer scheduler options and the unique-job feature are built
on these locks (Task Scheduling,
Queues and Jobs). Locks across separate services, fencing tokens and
their limits are on Resilience, Idempotency and Locks.
Failover
The failover driver tries its stores in order. With a Redis store pointed at a closed port first and the database
second:
<?php
config([
'database.redis.down' => ['host' => '127.0.0.1', 'port' => 6390, 'timeout' => 0.5, 'database' => 0],
'cache.stores.redis-down' => ['driver' => 'redis', 'connection' => 'down'],
'cache.stores.resilient' => ['driver' => 'failover', 'stores' => ['redis-down', 'database']],
]);
Event::listen(CacheFailedOver::class, fn ($e) => Log::warning('cache failed over', ['store' => $e->storeName]));
Cache::store('resilient')->put('fee-table', ['late_per_day' => 25], 60);
Source: Laravel — Cache Failover
redis-down alone: RedisException: Connection refused
CacheFailedOver: ["storeName","exception"]
resilient put: 8 ms, read back from database store: {"late_per_day":25}
second call: 0 ms
Source: Laravel — Cache Failover
The application kept working on the database store, and the event fired once, which is where an alert belongs. The failover is per operation and per process. Values written to the fallback are not copied back when Redis returns, and locks taken on the fallback do not exclude processes still using Redis. Failover suits plain caching. Locks and rate limits that must be exact need one store that everyone agrees on.
Rate limiting
The throttle middleware and the RateLimiter::for() limiters on
Security: CSRF, Encryption and Hashing are built on the
RateLimiter API. The API can also be used directly, for things that are not HTTP routes, such as ISBN lookups per
member:
<?php
$key = 'isbn-lookup:user:2';
foreach (range(1, 4) as $i) {
$ran = RateLimiter::attempt($key, maxAttempts: 3, callback: fn () => "lookup $i", decaySeconds: 60);
}
RateLimiter::remaining($key, 3);
RateLimiter::availableIn($key);
RateLimiter::tooManyAttempts($key, 3);
RateLimiter::clear($key);
Source: Laravel — Rate Limiting
attempt 1: lookup 1, remaining 2
attempt 2: lookup 2, remaining 1
attempt 3: lookup 3, remaining 0
attempt 4: refused, remaining 0
available in: 60 s
tooManyAttempts: true
after clear, remaining: 3
The counters live in the default cache store, or in the store set as cache.limiter. The window is fixed: the
counter starts at the first hit and resets decaySeconds later, so a client can make 3 requests at the end of one
window and 3 more at the start of the next. Token buckets, sliding windows and distributed limits are compared on
Rate Limiter and
Token Bucket and Bandwidths.
Testing
Tests use the array store (CACHE_STORE=array in phpunit.xml), so every test starts empty. Time travel moves
the TTL clock:
<?php
// tests/Feature/CacheTest.php
use Illuminate\Support\Facades\{Cache, RateLimiter};
test('remember computes once until the ttl passes', function () {
$calls = 0;
$compute = function () use (&$calls) { return ++$calls; };
Cache::remember('fees', 60, $compute);
Cache::remember('fees', 60, $compute);
$this->travel(61)->seconds();
Cache::remember('fees', 60, $compute);
expect($calls)->toBe(2);
});
test('a lock is exclusive until released', function () {
$first = Cache::lock('catalogue-import', 10);
expect($first->get())->toBeTrue()
->and(Cache::lock('catalogue-import', 10)->get())->toBeFalse();
$first->release();
expect(Cache::lock('catalogue-import', 10)->get())->toBeTrue();
});
test('the lookup limiter refuses the fourth attempt', function () {
$results = collect(range(1, 4))->map(fn ($i) => RateLimiter::attempt('isbn-lookup:user:2', 3, fn () => $i));
expect($results->all())->toBe([1, 2, 3, false]);
});
Source: Laravel — Interacting With Time
All three pass. The array store does not serialize values by default, so a test never sees the
__PHP_Incomplete_Class problem above. A smoke test against the real store does.
Where older books and tutorials are outdated
-
Cache::remember($key, $minutes, …)(Stauffer ch. 14) took minutes until Laravel 5.8. The TTL has been in seconds since then, so an old60now means one minute instead of an hour. -
CACHE_DRIVER=filein.envbecameCACHE_STORE=databasein Laravel 11. -
Hand-made locks with
Cache::add()are replaced byCache::lock(), which has owners, expiry andblock(). -
Caching Eloquent models and collections worked until Laravel 13’s
serializable_classesdefault. Cache arrays, or list the classes. -
Recomputing on expiry under load is softened by
Cache::flexible()(Laravel 11.23).