Cache & Rate Limiting

Two of the most common things you build on a key-value store are a cache and a rate limiter, so Fast ships both as thin, friendly wrappers over the KV store. Because they ride on KV, they need use_kv() enabled first.

The cache

cache() is a small veneer over the store. Its entries live under a cache: prefix, so they never collide with keys you set directly through kv(). The star of the show is remember(), which returns a cached value or computes and stores it on a miss:

$report = cache()->remember('report', ttl: 30, callback: fn () => build_report());

The first call runs build_report() and caches the result for 30 seconds; subsequent calls within that window return the cached value without recomputing. The rest of the API is what you would expect:

cache()->set('k', $value, ttl: 60);
cache()->get('k', $default);
cache()->has('k');
cache()->forget('k');
cache()->increment('views');

A couple of sharp edges worth knowing:

A literal null is not cacheable through remember(); it reads as a miss and your callback runs again. And there is deliberately no cache()->flush(): since the store is shared, a global flush would wipe unrelated keys. Clear entries individually with forget().

Rate limiting

The rate limiter uses fixed-window counters over KV. The window and its expiry are created together atomically, so a counter is never left stranded without a TTL. You can apply it two ways.

As global middleware, throttle() returns a middleware that enforces a limit and sets the right headers (a 429 with Retry-After and X-RateLimit-*) when exceeded:

use_middleware(throttle(max: 60, decay: 60));   // 60 requests per 60s, keyed by client IP

By default it keys on the client's IP. To limit by something else, pass a by resolver that returns the key for a request:

use_middleware(throttle(10, 60, by: fn ($r) => 'path:' . $r->path));

Inline limiting

Sometimes you want to rate-limit one specific action rather than a whole route, like login attempts. Use limiter() directly inside a handler:

if (!limiter()->attempt("login:$ip", max: 5, decay: 60)) {
	throw new Fast\RateLimit\TooManyRequestsException(
		limiter()->retryAfter("login:$ip")
	);
}

attempt() returns false once the limit is hit, and retryAfter() tells you how many seconds until the window resets, which the exception turns into a proper Retry-After header.

A note on proxies

The default rate-limit key is the direct-peer client IP, with the port stripped. If your app sits behind a proxy or load balancer, that direct peer is the proxy, not the real client, so every request would share one bucket.

Behind a proxy, key the limiter on a trusted forwarded header instead of the direct peer, using the by resolver. Only trust a forwarded header when you control the proxy setting it, or a client could spoof it to dodge limits.

Because Fast's router has no per-route middleware, the by resolver is also how you scope a throttle to specific paths or user identities.

The last pure utility is input validation, up next in Validation.