Design a rate limiter — token bucket in PHP
Advanced5 min read·eng-11-003
interviewsecurity
Concept
Rate limiter design — limit how many requests a user or IP can make in a time window.
Token bucket algorithm:
- Each user has a "bucket" that holds tokens.
- Bucket capacity = max burst (e.g., 100 tokens).
- Tokens are added at a refill rate (e.g., 10 per second).
- Each request consumes 1 token.
- If the bucket is empty: reject the request (429 Too Many Requests).
- Advantage: allows bursts up to capacity, smoothly limits sustained rate.
Sliding window counter (simpler):
- Count requests in a rolling time window (e.g., last 60 seconds).
- If count >= limit: reject.
- In Redis:
INCR key + EXPIRE key. - Problem with fixed windows: burst at window boundary (100 requests at 59s, 100 at 61s = 200 in 2s).
- Sliding window fix: use a sorted set (
ZADD) with timestamps, count members in the last 60s.
Redis INCR + EXPIRE (fixed window, simplest implementation):
text
INCR rate:user:42:2024-01-01-14:00
EXPIRE rate:user:42:2024-01-01-14:00 60Laravel's built-in: RateLimiter facade. ThrottleRequests middleware. RateLimiter::for('api', fn($req) => Limit::perMinute(60)) in RouteServiceProvider.
Response headers (best practice): Return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After in responses. Clients know their current limit status.
Different limits for different users: Authenticated users get higher limits than guests. Admins may have no limit. Laravel RateLimiter::for() with dynamic limits based on request/user.
Code Example
php
<?php
// Token bucket in Redis (PHP implementation)
class TokenBucketRateLimiter
{
public function __construct(
private readonly \Illuminate\Contracts\Cache\Repository $redis,
private readonly int $capacity, // max burst
private readonly int $refillRate, // tokens per second
) {}
public function attempt(string $key): bool
{
$now = microtime(true);
$bucketKey = "rate_bucket:{$key}";
$lastKey = "rate_last:{$key}";
$tokens = (float) ($this->redis->get($bucketKey) ?? $this->capacity);
$lastTime = (float) ($this->redis->get($lastKey) ?? $now);
// Refill tokens based on elapsed time
$elapsed = $now - $lastTime;
$tokens = min($this->capacity, $tokens + $elapsed * $this->refillRate);
if ($tokens < 1) {
return false; // bucket empty — reject
}
$tokens--;
$this->redis->put($bucketKey, $tokens, 3600);
$this->redis->put($lastKey, $now, 3600);
return true;
}
public function remaining(string $key): int
{
return (int) floor($this->redis->get("rate_bucket:{$key}") ?? $this->capacity);
}
}
// Sliding window with Redis sorted set
class SlidingWindowRateLimiter
{
public function __construct(
private readonly \Illuminate\Contracts\Redis\Connection $redis,
private readonly int $limit,
private readonly int $windowSeconds,
) {}
public function attempt(string $key): bool
{
$now = microtime(true);
$windowKey = "rate_sw:{$key}";
$this->redis->pipeline(function ($pipe) use ($now, $windowKey) {
$pipe->zremrangebyscore($windowKey, '-inf', $now - $this->windowSeconds); // remove old entries
$pipe->zadd($windowKey, $now, $now); // add current request
$pipe->zcard($windowKey); // count in window
$pipe->expire($windowKey, $this->windowSeconds + 1);
});
$count = $this->redis->zcard($windowKey);
return $count <= $this->limit;
}
}
// Laravel RateLimiter — what you use in practice
// In RouteServiceProvider::boot():
\RateLimiter::for('api', function (\Illuminate\Http\Request $request) {
return $request->user()
? \Illuminate\Cache\RateLimiting\Limit::perMinute(60)->by($request->user()->id)
: \Illuminate\Cache\RateLimiting\Limit::perMinute(10)->by($request->ip());
});
\RateLimiter::for('login', function (\Illuminate\Http\Request $request) {
return [
\Illuminate\Cache\RateLimiting\Limit::perMinute(5)->by($request->ip()),
\Illuminate\Cache\RateLimiting\Limit::perMinute(10)->by($request->input('email')),
];
});
// In routes:
// Route::middleware(['throttle:api'])->group(fn() => ...);
// Route::middleware(['throttle:login'])->post('/login', ...);
// Response headers (Laravel adds these automatically):
// X-RateLimit-Limit: 60
// X-RateLimit-Remaining: 45
// X-RateLimit-Reset: 1704067200 (Unix timestamp when window resets)
// Retry-After: 30 (seconds until retry, only on 429)