Rate Limiting
True\RateLimiter is a fixed-window throttle with pluggable storage. It supports per-IP API limits, per-user buckets, multi-window stacks (burst + sustained), and integrates with the router via RateLimitMiddleware so the check/hit/headers dance lives in one place.
Storage backends
| Store | Use when | Notes |
|---|---|---|
ApcuStore | Single-server deploys | Fastest. Requires ext-apcu. Buckets are per PHP-FPM pool — they do not cross servers. |
FileStore | Low/medium traffic, no APCu | No extension required. Atomic via flock(). Files self-expire and GC on writes. |
For multi-server deploys, implement True\RateLimiting\StoreInterface against Redis or Memcached.
Setup
Construct the limiter at the top of routes.php and stash it on $App:
// APCu (recommended on a single server)
$App->rateLimiter = new \True\RateLimiter(new \True\RateLimiting\ApcuStore);
// File-backed fallback
$App->rateLimiter = new \True\RateLimiter(
new \True\RateLimiting\FileStore(BP . '/app/data/rate-limits')
);
Defining limits
Each limit has a discriminator (what scopes the bucket — IP, user id, email, …), a max attempts, and a decay window. True\RateLimiting\Limit provides factories for common windows:
use True\RateLimiting\Limit;
Limit::perSecond(10);
Limit::perMinute(60); // 60 / 1 minute (default)
Limit::perMinute(100, 5); // 100 / 5 minutes
Limit::perHour(1000);
Limit::perDay(10000);
Limit::none(); // pass-through (never limits)
Chain ->by() to set the discriminator, and optionally attach ->after() / ->response() callbacks:
Limit::perMinute(5)
->by($_POST['email'] . '|' . $_SERVER['REMOTE_ADDR'])
->after(fn(Limit $l) => error_log("hit on {$l->key}"))
->response(fn(Limit $l, int $retryAfter) =>
$App->response(['error' => 'Slow down'], 'json', 429)
);
The middleware — quick path
For most cases, drop RateLimitMiddleware on a route group with an inline rate. The bucket keys by client IP and namespaces under the name you pass:
$App->router->group('/api/*', function () use ($App) {
$App->router->get('/api/user/*:id', function ($request) use ($App) {
echo $request->route->id;
});
}, [ new \True\Middleware\RateLimitMiddleware('api', 100) ]);
That's 100 attempts per minute per IP. The third constructor argument overrides the window in seconds — ('api', 1000, 3600) is 1000 per hour, ('upload', 5, 60) is 5 per minute.
Named limiter — full control
When you need a non-IP discriminator, per-user vs. per-guest limits, or a custom 429 response, register the limit once via for() and omit the inline rate:
$App->rateLimiter->for('login', fn($request) =>
Limit::perMinute(5)
->by(($_POST['email'] ?? '') . '|' . $_SERVER['REMOTE_ADDR'])
);
$App->router->group('/login', function () use ($App) {
$App->router->post('/login', function ($request) use ($App) {
// ...
});
}, [ new \True\Middleware\RateLimitMiddleware('login') ]);
When the limit is exceeded the middleware returns false, the router skips the group, and a 429 Too Many Requests with Retry-After + X-RateLimit-* headers goes back to the client. If the limit has a ->response() callback, that runs instead of the default body.
The attempt() shortcut
Run a callback only if the bucket is under the limit, then record the hit in one call:
$result = $App->rateLimiter->attempt(
key: 'login:' . $email,
maxAttempts: 5,
callback: fn() => authenticate($email, $password),
decaySeconds: 60,
);
if ($result === false) {
$retryAfter = $App->rateLimiter->availableIn('login:' . $email);
$App->error("Too many attempts. Try again in {$retryAfter}s.", 'warning');
$App->go('/login');
}
If the callback returns null, attempt() returns true so you can distinguish "ran successfully" from "blocked".
Common patterns
Per-user vs. per-guest
$App->rateLimiter->for('api', function () use ($App) {
if ($userId = $App->auth->userId()) {
return Limit::perMinute(1000)->by('user:' . $userId);
}
return Limit::perMinute(60)->by('ip:' . $_SERVER['REMOTE_ADDR']);
});
Bypass for trusted users
$App->rateLimiter->for('api', function () use ($App) {
if ($App->auth->isAdmin()) return Limit::none();
return Limit::perMinute(100)->by($_SERVER['REMOTE_ADDR']);
});
Burst + sustained on one endpoint
Return an array of limits to enforce more than one window simultaneously:
$App->rateLimiter->for('contact-form', fn() => [
Limit::perMinute(3)->by($_SERVER['REMOTE_ADDR']), // burst
Limit::perHour(20)->by($_SERVER['REMOTE_ADDR']), // sustained
]);
Stacking with other middleware
Middleware order matters. Put the limiter before auth to throttle anonymous traffic before doing expensive lookups; put it after to apply per-user limits using the authenticated identity:
$App->router->group('/api/*', function () use ($App) {
// ... routes
}, [
new \True\Middleware\RateLimitMiddleware('api-burst'), // cheap pre-auth throttle
new \App\AuthMiddleware, // then authenticate
new \True\Middleware\RateLimitMiddleware('api-user'), // then per-user throttle
]);
Notes & caveats
- Single-server only with
ApcuStore. APCu memory is per PHP-FPM pool; buckets do not cross servers. - Fixed window, not sliding. A user who exhausts the quota at the end of a window can refill immediately on the next tick.
- Lockout vs. counter.
tooManyAttempts()consults a separate timer key, so even if the counter expires the user stays locked out until the window timer ends. - CLI tasks with APCu. Set
apc.enable_cli=1if you call the limiter from queue workers, or those calls silently no-op. - FileStore directory. Make sure it's writable and excluded from any backup/sync that might fight
flock().