Advanced Rate Limiting in Laravel: Sliding Window, Token Bucket and Distributed Limiting
·
6 min read
Introduction
Rate limiting is a critical technique to protect APIs from abuse, DDoS attacks, and ensure fair usage. Laravel provides basic rate limiting, but for complex production systems, you need to understand and implement advanced algorithms.
Why Advanced Rate Limiting?
| Fixed Window (Basic) | Advanced Algorithms |
|---|---|
| Can burst at window boundaries | Smooth traffic distribution |
| Not fair to users | Fair queueing |
| Easy to exploit | Resistant to gaming |
| Single server only | Distributed support |
Rate Limiting Algorithms
1. Sliding Window Counter
// app/Services/RateLimiting/SlidingWindowLimiter.php
<?php
namespace App\Services\RateLimiting;
use Illuminate\Support\Facades\Redis;
class SlidingWindowLimiter
{
public function attempt(
string $key,
int $maxRequests,
int $windowSeconds
): RateLimitResult {
$now = microtime(true);
$currentWindow = floor($now / $windowSeconds);
$previousWindow = $currentWindow - 1;
$currentKey = "rate_limit:{$key}:{$currentWindow}";
$previousKey = "rate_limit:{$key}:{$previousWindow}";
$pipe = Redis::pipeline();
$pipe->get($currentKey);
$pipe->get($previousKey);
$results = $pipe->execute();
$currentCount = (int) ($results[0] ?? 0);
$previousCount = (int) ($results[1] ?? 0);
// Calculate weighted count
$windowPosition = $now - ($currentWindow * $windowSeconds);
$previousWeight = 1 - ($windowPosition / $windowSeconds);
$effectiveCount = ($previousCount * $previousWeight) + $currentCount;
if ($effectiveCount >= $maxRequests) {
return new RateLimitResult(
allowed: false,
remaining: 0,
retryAfter: (int) ceil($windowSeconds - $windowPosition),
limit: $maxRequests
);
}
Redis::pipeline(function ($pipe) use ($currentKey, $windowSeconds) {
$pipe->incr($currentKey);
$pipe->expire($currentKey, $windowSeconds * 2);
});
return new RateLimitResult(
allowed: true,
remaining: (int) floor($maxRequests - $effectiveCount - 1),
retryAfter: 0,
limit: $maxRequests
);
}
}
2. Token Bucket Algorithm
// app/Services/RateLimiting/TokenBucketLimiter.php
<?php
namespace App\Services\RateLimiting;
use Illuminate\Support\Facades\Redis;
class TokenBucketLimiter
{
public function attempt(
string $key,
int $bucketSize,
int $refillRate,
int $tokensToConsume = 1
): RateLimitResult {
$script = <<<'LUA'
local key = KEYS[1]
local bucket_size = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_to_consume = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('hmget', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or bucket_size
local last_refill = tonumber(bucket[2]) or now
local time_passed = now - last_refill
local tokens_to_add = time_passed * refill_rate
tokens = math.min(bucket_size, tokens + tokens_to_add)
if tokens < tokens_to_consume then
local wait_time = (tokens_to_consume - tokens) / refill_rate
return {0, tokens, wait_time}
end
tokens = tokens - tokens_to_consume
redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
redis.call('expire', key, bucket_size / refill_rate * 2)
return {1, tokens, 0}
LUA;
$result = Redis::eval($script, 1, "rate_limit:bucket:{$key}",
$bucketSize, $refillRate, $tokensToConsume, microtime(true));
return new RateLimitResult(
allowed: (bool) $result[0],
remaining: (int) floor($result[1]),
retryAfter: (int) ceil($result[2]),
limit: $bucketSize
);
}
}
3. Rate Limit Result DTO
// app/Services/RateLimiting/RateLimitResult.php
<?php
namespace App\Services\RateLimiting;
readonly class RateLimitResult
{
public function __construct(
public bool $allowed,
public int $remaining,
public int $retryAfter,
public int $limit
) {}
public function headers(): array
{
$headers = [
'X-RateLimit-Limit' => $this->limit,
'X-RateLimit-Remaining' => $this->remaining,
];
if (!$this->allowed) {
$headers['Retry-After'] = $this->retryAfter;
}
return $headers;
}
}
Middleware Integration
// app/Http/Middleware/AdvancedRateLimit.php
<?php
namespace App\Http\Middleware;
use App\Services\RateLimiting\SlidingWindowLimiter;
use Closure;
use Illuminate\Http\Request;
class AdvancedRateLimit
{
public function __construct(
private SlidingWindowLimiter $limiter
) {}
public function handle(Request $request, Closure $next, string $config): Response
{
[$maxRequests, $windowSeconds] = explode(',', $config);
$key = $this->resolveKey($request);
$result = $this->limiter->attempt($key, (int) $maxRequests, (int) $windowSeconds);
if (!$result->allowed) {
return response()->json([
'error' => 'Too Many Requests',
'retry_after' => $result->retryAfter,
], 429, $result->headers());
}
$response = $next($request);
foreach ($result->headers() as $header => $value) {
$response->headers->set($header, $value);
}
return $response;
}
private function resolveKey(Request $request): string
{
$prefix = $request->route()->getName() ?? $request->path();
return "{$prefix}:user:" . ($request->user()?->id ?? $request->ip());
}
}
Tiered Rate Limiting
// app/Services/RateLimiting/TieredRateLimiter.php
<?php
namespace App\Services\RateLimiting;
use App\Models\User;
class TieredRateLimiter
{
private array $tiers = [
'free' => ['per_minute' => 20, 'per_hour' => 100],
'basic' => ['per_minute' => 60, 'per_hour' => 1000],
'pro' => ['per_minute' => 300, 'per_hour' => 5000],
'enterprise' => ['per_minute' => 1000, 'per_hour' => 30000],
];
public function __construct(
private SlidingWindowLimiter $limiter
) {}
public function attempt(User $user, string $endpoint): RateLimitResult
{
$tier = $this->tiers[$user->subscription_tier] ?? $this->tiers['free'];
$key = "user:{$user->id}:{$endpoint}";
// Check per-minute limit
$minuteResult = $this->limiter->attempt(
"{$key}:minute",
$tier['per_minute'],
60
);
if (!$minuteResult->allowed) {
return $minuteResult;
}
// Check per-hour limit
return $this->limiter->attempt(
"{$key}:hour",
$tier['per_hour'],
3600
);
}
}
Distributed Rate Limiting
// app/Services/RateLimiting/DistributedRateLimiter.php
<?php
namespace App\Services\RateLimiting;
use Illuminate\Support\Facades\Redis;
class DistributedRateLimiter
{
public function attempt(string $key, int $maxRequests, int $windowSeconds): RateLimitResult
{
$script = <<<'LUA'
local key = KEYS[1]
local max_requests = tonumber(ARGV[1])
local window_seconds = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local window_start = now - window_seconds
redis.call('zremrangebyscore', key, '-inf', window_start)
local count = redis.call('zcard', key)
if count >= max_requests then
local oldest = redis.call('zrange', key, 0, 0, 'WITHSCORES')
local retry_after = oldest[2] + window_seconds - now
return {0, max_requests - count, retry_after}
end
redis.call('zadd', key, now, now .. ':' .. math.random())
redis.call('expire', key, window_seconds)
return {1, max_requests - count - 1, 0}
LUA;
$result = Redis::eval($script, 1, "rate_limit:distributed:{$key}",
$maxRequests, $windowSeconds, microtime(true));
return new RateLimitResult(
allowed: (bool) $result[0],
remaining: max(0, (int) $result[1]),
retryAfter: max(0, (int) ceil($result[2])),
limit: $maxRequests
);
}
}
Cost-Based Rate Limiting
// app/Services/RateLimiting/CostBasedLimiter.php
<?php
namespace App\Services\RateLimiting;
class CostBasedLimiter
{
private array $costs = [
'GET /api/users' => 1,
'POST /api/users' => 5,
'GET /api/reports' => 10,
'POST /api/exports' => 100,
];
public function __construct(
private TokenBucketLimiter $tokenBucket
) {}
public function attempt(string $method, string $endpoint, string $userKey): RateLimitResult
{
$cost = $this->costs["{$method} {$endpoint}"] ?? 1;
return $this->tokenBucket->attempt(
"cost:{$userKey}",
bucketSize: 1000,
refillRate: 1000 / 3600,
tokensToConsume: $cost
);
}
}
Route Configuration
// routes/api.php
// Sliding window: 100 requests per 60 seconds
Route::middleware('advanced.rate:100,60')->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
// Stricter limits for auth endpoints
Route::middleware('advanced.rate:5,60')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
Best Practices
Choose the Right Algorithm
| Use Case | Recommended |
|---|---|
| General API | Sliding Window |
| Burst protection | Token Bucket |
| Queue processing | Leaky Bucket |
| Billing | Cost-based Token Bucket |
Multiple Layers
Route::middleware([
'advanced.rate:1000,3600', // 1000/hour
'advanced.rate:100,60', // 100/minute
])->group(function () {
// routes
});
Conclusion
Advanced rate limiting provides:
- Smooth traffic with sliding window
- Burst handling with token bucket
- Fair queueing with leaky bucket
- Horizontal scaling with distributed limiting
- Flexible pricing with cost-based limiting