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

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)];
});
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

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);
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
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

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);
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

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);
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]);
});

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 old 60 now means one minute instead of an hour.

  • CACHE_DRIVER=file in .env became CACHE_STORE=database in Laravel 11.

  • Hand-made locks with Cache::add() are replaced by Cache::lock(), which has owners, expiry and block().

  • Caching Eloquent models and collections worked until Laravel 13’s serializable_classes default. Cache arrays, or list the classes.

  • Recomputing on expiry under load is softened by Cache::flexible() (Laravel 11.23).