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:

  1. Smooth traffic with sliding window
  2. Burst handling with token bucket
  3. Fair queueing with leaky bucket
  4. Horizontal scaling with distributed limiting
  5. Flexible pricing with cost-based limiting

References

Comments