Fixed-window rate limiting for the GenAI stack — a small RateLimiter over a
pluggable store, plus a drop-in RateLimitInterceptor that returns 429 when a
client IP exceeds the limit on a path. Brute-force and flood protection without a
line of glue in your controllers. PHP 5.3.29-safe at runtime.
A fixed window: time is sliced into window-second buckets; each (key, bucket)
pair has a counter. The interceptor keys on "<ip>|<path>", so every endpoint is
throttled independently — POST /login floods don't burn POST /forgot's budget.
Only state-changing methods (POST/PUT/PATCH/DELETE) are counted, so normal page
browsing (GET) is never throttled.
-
Configure (
app.ini) — optional; defaults are 20 requests / 60s:[ratelimit] limit = 20 window = 60 path = cache/ratelimit
-
Expose the limiter as a bean:
#[Configuration] class RateLimitConfig { #[Bean(RateLimiter::class)] public function rateLimiter(RateLimitProperty $cfg) { return RateLimitFactory::build($cfg); } }
-
Enable the interceptor with a thin subclass (it stays opt-in, like CsrfInterceptor):
#[Intercept] // all requests; only POST-likes are counted class Throttle extends \GenAI\RateLimit\Interceptor\RateLimitInterceptor {}
Scope it to specific paths with
#[Intercept(path: '/login')], or run several subclasses for different endpoints.
RateLimiter is fixed-window flood control. For "lock the account after N wrong
passwords," use AttemptLimiter — it counts failures per key (e.g. an email) and
locks for a fixed duration once the threshold is hit; a success clears it.
#[Bean(AttemptLimiter::class)]
public function loginLockout(RateLimitProperty $cfg) {
return RateLimitFactory::lockout($cfg); // [ratelimit] login_max_fails / login_lock
}if (($wait = $lockout->lockedFor($email)) > 0) {
return "locked — try again in ~" . ceil($wait / 60) . " min";
}
if ($passwordOk) { $lockout->clear($email); } // reset on success
else { $lockout->fail($email); } // count the failurelockedFor() returns seconds remaining (0 = open); remaining() gives attempts
left before a lock, for "N tries left" messaging.
Tradeoff: locking by email lets an attacker lock a victim out on purpose by failing 3× against their address. That's inherent to account lockout. If that matters, key on
email|ipinstead — at the cost of letting IP-rotation retry.
- Client IP comes from
REMOTE_ADDR(not client-spoofable). Behind a trusted reverse proxy, overrideclientIp()to read a vetted forwarded header — never trustX-Forwarded-Forblindly. - FileStore is single-server. Counters are per-
(key, window)files swept opportunistically. For a cluster, implementRateStoreagainst a shared backend (DB / Redis / APCu) and pass it toRateLimiterdirectly. - Fail-open: if the store can't be read/written, requests are allowed — a broken counter never locks users out.
RateLimiter+RateStore/FileStore— standalone; no web stack required.RateLimitInterceptor— needsgenai/web(theInterceptorcontract) andgenai/http(the 429Response); both aresuggest, loaded only if you use it.