Introduction
Rate limiting is one of the most critical defenses you can build into a modern PHP application. Without it, a single misconfigured script, a poorly written scraper, or a malicious actor can overwhelm your API, exhaust database connections, and degrade the experience for every legitimate user. In a distributed environment where multiple web servers handle requests behind a load balancer, in-memory rate limiting simply does not work. You need a shared, centralized store that every instance can query atomically. That is where Redis enters the picture.
Redis is an in-memory data structure store that excels at high-throughput, low-latency operations. Its atomic increment commands, built-in expiration, and support for Lua scripts make it an ideal backend for distributed rate limiting. When combined with PHP, one of the most widely used server-side languages for web development, you gain a practical, production-ready solution for throttling API requests, protecting login endpoints, and managing resource quotas.
This article walks you through building a complete distributed rate limiter from scratch using PHP and Redis. You will learn the sliding window counter algorithm, why atomicity matters under concurrency, how to implement rate limiting with Lua scripts to prevent race conditions, and how to integrate the limiter into a real PHP application. Every code example is written for PHP 8.2+ and uses the Predis client library, though the concepts apply equally to the phpredis extension. By the end, you will have a drop-in rate limiter class, middleware integration patterns, and a clear understanding of how to tune and monitor your solution in production.
Table of Contents
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
Before writing code, it is important to understand the fundamental concepts behind distributed rate limiting. A rate limiter restricts how many requests a client can make within a defined time window. In a distributed system, this state must be shared across all application servers, which is why Redis serves as the central authority.
Fixed Window Counter
The fixed window counter is the simplest rate limiting algorithm. You divide time into fixed intervals, such as one-minute buckets. Each request increments a counter for the current bucket. When the counter exceeds the threshold, additional requests are rejected until the next bucket begins.
The problem with fixed windows is the boundary issue. At the edge of a window, a client can make twice the allowed requests in a short span: once at the end of the current window and immediately again at the start of the next. This burst can overwhelm downstream services even though the average rate appears compliant.
Sliding Window Counter
The sliding window counter smooths out the boundary problem by tracking individual request timestamps within a moving time window. Instead of a single bucket counter, you store a sorted set of timestamps for each client. When a new request arrives, you remove timestamps older than the window, count the remaining entries, and decide whether to allow the request.
This approach provides more accurate rate limiting because it reflects the true request rate over any rolling period. The tradeoff is increased memory usage and slightly more complex logic, since you must manage timestamp entries rather than a single integer counter.
Token Bucket
The token bucket algorithm allows for bursty traffic while enforcing a long-term average rate. Tokens are added to a bucket at a fixed rate. Each request consumes a token. If the bucket is empty, the request is rejected or delayed. This is useful for APIs where occasional bursts are acceptable but sustained abuse is not.
Leaky Bucket
The leaky bucket enforces a strict, constant output rate. Requests are added to a queue and processed at a fixed interval. If the queue overflows, new requests are dropped. This guarantees smooth traffic but can introduce latency, making it less suitable for interactive APIs where immediate responses are expected.
Atomicity and Lua Scripts
In a distributed system, multiple PHP processes may attempt to increment the same counter simultaneously. Without atomicity, two processes could read the same value, both decide the request is allowed, and both increment, leading to over-counting or under-counting. Redis Lua scripts execute atomically on the server, ensuring that read-check-write operations happen without interference from other clients.
Rate Limit Headers
HTTP standards such as RFC 6585 define rate limit response headers. The RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers inform clients about their quota, how many requests remain, and when the window resets. Returning these headers helps API consumers handle throttling gracefully and implement exponential backoff.
Architecture Overview
The distributed rate limiter architecture consists of four logical layers: the client, the PHP application, the Redis store, and the configuration layer.
Client Layer
Clients identify themselves through API keys, authenticated user IDs, or IP addresses. For authenticated APIs, the user ID is the most reliable identifier. For public endpoints, IP addresses or API keys work well, though IP-based limiting can be problematic behind NAT gateways where many users share one address.
Application Layer
The PHP application intercepts incoming requests, extracts the client identifier, queries Redis to check the current rate, and either allows the request to proceed or returns a 429 Too Many Requests response. This logic is typically implemented as middleware so it can be applied globally or to specific routes.
Redis Store
Redis stores rate limit state using keys structured by client identifier and endpoint. For sliding window implementations, each key maps to a sorted set of request timestamps. Redis expires keys automatically after the window period, preventing unbounded memory growth.
Configuration Layer
Rate limits vary by endpoint and client tier. A public registration endpoint might allow five requests per minute, while an authenticated premium user might allow 600 requests per minute. Configuration should be externalized so limits can be adjusted without code changes.
The following diagram shows the request flow:
Client Request | vPHP Middleware | +--> Extract Client ID | +--> Query Redis (Lua Script) | +--> If allowed: Proceed to Controller | +--> If denied: Return 429 with RateLimit headersStep-by-Step Guide
This section walks through implementing a sliding window rate limiter in PHP with Redis. We will use Predis as the Redis client, though the logic translates directly to phpredis.
Step 1: Install Dependencies
Install Predis via Composer:
composer require predis/predisStep 2: Configure Redis Connection
Create a configuration class that holds Redis connection details and rate limit rules.
<?phpdeclare(strict_types=1);namespace App\RateLimiting;use Predis\Client;class RateLimiterConfig{ public function __construct( public readonly string $host = '127.0.0.1', public readonly int $port = 6379, public readonly int $database = 0, public readonly ?string $password = null, public readonly int $defaultLimit = 100, public readonly int $defaultWindowSeconds = 60, ) {} public function createRedisClient(): Client { return new Client([ 'scheme' => 'tcp', 'host' => $this->host, 'port' => $this->port, 'database' => $this->database, 'password' => $this->password, ]); }}Step 3: Implement the Rate Limiter Class
The core rate limiter uses a Redis sorted set to track request timestamps for each client within the sliding window.
<?phpdeclare(strict_types=1);namespace App\RateLimiting;use Predis\Client;class SlidingWindowRateLimiter{ public function __construct( private Client $redis, private RateLimiterConfig $config, ) {} /** * Check if a request is allowed under the rate limit. * * @return array{allowed: bool, remaining: int, reset: int, retryAfter: int|null} */ public function check(string $key, int $limit, int $windowSeconds): array { $now = microtime(true) * 1000; $windowStart = $now - ($windowSeconds * 1000); $redisKey = "rate_limit:{$key}"; $luaScript = <<<'LUA'local key = KEYS[1]local now = tonumber(ARGV[1])local windowStart = tonumber(ARGV[2])local limit = tonumber(ARGV[3])redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)local current = redis.call('ZCARD', key)if current < limit then redis.call('ZADD', key, now, now .. '.' .. math.random(1000000, 9999999)) redis.call('EXPIRE', key, tonumber(ARGV[4])) return {1, limit - current - 1, math.ceil((windowStart + tonumber(ARGV[4]) * 1000) / 1000)}else local ttl = redis.call('TTL', key) return {0, 0, math.ceil((now + (ttl > 0 ? ttl : 60) * 1000) / 1000), ttl}endLUA; $result = $redis->eval( $luaScript, [$redisKey], [ (string) $now, (string) $windowStart, (string) $limit, (string) $windowSeconds, ] ); $allowed = (int) $result[0] === 1; $remaining = (int) $result[1]; $reset = (int) $result[2]; $retryAfter = null; if (!$allowed && isset($result[3])) { $retryAfter = (int) $result[3]; } return [ 'allowed' => $allowed, 'remaining' => max(0, $remaining), 'reset' => $reset, 'retryAfter' => $retryAfter, ]; } public function increment(string $key, int $windowSeconds): void { $now = microtime(true) * 1000; $redisKey = "rate_limit:{$key}"; $score = $now . '.' . mt_rand(1000000, 9999999); $this->redis->multi(); $this->redis->zadd($redisKey, $score, $score); $this->redis->expire($redisKey, $windowSeconds); $this->redis->exec(); }}Step 4: Create Middleware Integration
Integrate the rate limiter into your PHP application as middleware. This example uses a PSR-15 style middleware pattern that works with frameworks like Laravel, Symfony, or Slim.
<?phpdeclare(strict_types=1);namespace App\Middleware;use App\RateLimiting\RateLimiterConfig;use App\RateLimiting\SlidingWindowRateLimiter;use Predis\Client;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use Psr\Http\Server\MiddlewareInterface;use Psr\Http\Server\RequestHandlerInterface;use Nyholm\Psr7\Response;class RateLimitMiddleware implements MiddlewareInterface{ public function __construct( private SlidingWindowRateLimiter $rateLimiter, private array $limits = [], ) {} public function process( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { $clientId = $this->getClientId($request); $uri = $request->getUri()->getPath(); $limitKey = $this->limits[$uri] ?? 'default'; $config = $this->getConfigForKey($limitKey); $result = $this->rateLimiter->check( $clientId . ':' . $uri, $config['limit'], $config['windowSeconds'] ); if (!$result['allowed']) { return new Response( 429, [ 'RateLimit-Limit' => (string) $config['limit'], 'RateLimit-Remaining' => '0', 'RateLimit-Reset' => (string) $result['reset'], 'Retry-After' => $result['retryAfter'] ? (string) $result['retryAfter'] : '60', 'Content-Type' => 'application/json', ], json_encode([ 'error' => 'Too many requests', 'message' => 'Rate limit exceeded. Try again later.', 'retry_after' => $result['retryAfter'], ]) ); } $response = $handler->handle($request); return $response->withHeader('RateLimit-Limit', (string) $config['limit']) ->withHeader('RateLimit-Remaining', (string) $result['remaining']) ->withHeader('RateLimit-Reset', (string) $result['reset']); } private function getClientId(ServerRequestInterface $request): string { $user = $request->getAttribute('user'); if ($user && isset($user->id)) { return 'user:' . $user->id; } $apiKey = $request->getHeaderLine('X-API-Key'); if ($apiKey) { return 'api:' . hash('sha256', $apiKey); } $ip = $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown'; return 'ip:' . $ip; } private function getConfigForKey(string $key): array { return [ 'limit' => $this->limits[$key]['limit'] ?? 100, 'windowSeconds' => $this->limits[$key]['windowSeconds'] ?? 60, ]; }}Step 5: Apply to Routes
Register the middleware in your application bootstrap. Define different limits for different endpoints based on sensitivity.
<?phpuse App\Middleware\RateLimitMiddleware;use App\RateLimiting\RateLimiterConfig;use App\RateLimiting\SlidingWindowRateLimiter;use Predis\Client;$config = new RateLimiterConfig( host: getenv('REDIS_HOST') ?: '127.0.0.1', port: (int) (getenv('REDIS_PORT') ?: 6379), password: getenv('REDIS_PASSWORD') ?: null, defaultLimit: 100, defaultWindowSeconds: 60,);$redis = $config->createRedisClient();$rateLimiter = new SlidingWindowRateLimiter($redis, $config);$middleware = new RateLimitMiddleware($rateLimiter, [ '/api/v1/auth/login' => [ 'limit' => 5, 'windowSeconds' => 60, ], '/api/v1/auth/register' => [ 'limit' => 3, 'windowSeconds' => 60, ], '/api/v1/users' => [ 'limit' => 60, 'windowSeconds' => 60, ], 'default' => [ 'limit' => 100, 'windowSeconds' => 60, ],]);Real-World Examples
Rate limiting is not a one-size-fits-all feature. Different endpoints and user tiers require different strategies. Here are three common scenarios.
Example 1: Authentication Endpoints
Login and registration endpoints are prime targets for brute force attacks. Apply strict limits based on IP address or email address rather than user ID, since the user may not be authenticated yet.
$middleware = new RateLimitMiddleware($rateLimiter, [ '/api/v1/auth/login' => [ 'limit' => 5, 'windowSeconds' => 300, ],]);This allows five login attempts per five minutes per identifier, which is sufficient for legitimate users while deterring automated attacks.
Example 2: Premium API Users
Authenticated users with paid plans should receive higher limits. Identify them by user ID and configure tier-based rules.
$userTier = $request->getAttribute('user')->subscription_tier;$limit = match ($userTier) { 'free' => 100, 'pro' => 1000, 'enterprise' => 10000, default => 100,};$result = $rateLimiter->check( 'user:' . $userId . ':/api/v1/data', $limit, 60);Example 3: File Upload Endpoints
File uploads consume significant bandwidth and storage. Limit by payload size or request count, and consider combining rate limiting with quota checks.
$result = $rateLimiter->check( 'user:' . $userId . ':/api/v1/upload', 10, 3600);if (!$result['allowed']) { // Return 429 with retry after}Production Code Examples
The following examples are ready for production use with minor configuration adjustments.
Complete Rate Limiter Service
<?phpdeclare(strict_types=1);namespace App\Service;use App\RateLimiting\RateLimiterConfig;use App\RateLimiting\SlidingWindowRateLimiter;use Predis\Client;class RateLimiterService{ public function __construct( private SlidingWindowRateLimiter $limiter, private array $endpointLimits = [], ) {} public function checkLimit( string $identifier, string $endpoint, ?int $customLimit = null, ?int $customWindow = null ): array { $config = $this->endpointLimits[$endpoint] ?? [ 'limit' => 100, 'windowSeconds' => 60, ]; $limit = $customLimit ?? $config['limit']; $window = $customWindow ?? $config['windowSeconds']; return $this->limiter->check( $identifier . ':' . $endpoint, $limit, $window ); }}// Usage example:// $service = new RateLimiterService($rateLimiter, $limits);// $result = $service->checkLimit('user:42', '/api/v1/reports');// if (!$result['allowed']) {// throw new RateLimitException('Rate limit exceeded', $result['retryAfter']);// }Laravel Middleware Example
<?phpnamespace App\Http\Middleware;use Closure;use Illuminate\Http\Request;use Illuminate\Http\JsonResponse;use App\Service\RateLimiterService;class RateLimitMiddleware{ public function __construct( private RateLimiterService $rateLimiter, ) {} public function handle(Request $request, Closure $next): mixed { $user = $request->user(); $identifier = $user ? 'user:' . $user->id : 'ip:' . $request->ip(); $endpoint = $request->path(); $result = $this->rateLimiter->checkLimit($identifier, $endpoint); if (!$result['allowed']) { return new JsonResponse([ 'message' => 'Too many requests.', 'retry_after' => $result['retryAfter'], ], 429, [ 'RateLimit-Limit' => '100', 'RateLimit-Remaining' => '0', 'RateLimit-Reset' => (string) $result['reset'], 'Retry-After' => (string) ($result['retryAfter'] ?? 60), ]); } $response = $next($request); return $response->withHeaders([ 'RateLimit-Limit' => '100', 'RateLimit-Remaining' => (string) $result['remaining'], 'RateLimit-Reset' => (string) $result['reset'], ]); }}Redis CLI Monitoring Commands
# Check all rate limit keysredis-cli keys 'rate_limit:*'# Check TTL of a specific rate limit keyredis-cli ttl rate_limit:user:42:/api/v1/data# View sorted set members for debuggingredis-cli zrange rate_limit:user:42:/api/v1/data 0 -1 withscores# Monitor Redis commands in real timeredis-cli monitorComparison Table
| Algorithm | Accuracy | Memory Usage | Complexity | Best For |
|---|---|---|---|---|
| Fixed Window Counter | Low | Very Low | Simple | Non-critical endpoints, high-traffic public APIs |
| Sliding Window Counter | High | Medium | Moderate | Authenticated APIs, login endpoints, payment APIs |
| Token Bucket | Medium | Low | Moderate | Streaming APIs, bursty workloads |
| Leaky Bucket | Medium | Low | Moderate | Webhooks, message queues, smooth output |
Best Practices
- Use Lua scripts for atomic operations. Never perform separate Redis calls to check and increment without atomicity, as race conditions will corrupt your counters under load.
- Choose identifiers carefully. Use authenticated user IDs when available. For unauthenticated clients, use API keys or IP addresses, but be aware of NAT and proxy issues.
- Return standard HTTP headers. Include RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After so clients can adapt their behavior.
- Set appropriate TTLs. Always expire Redis keys after the window period to prevent memory leaks from inactive clients.
- Use separate Redis instances or databases for rate limiting if your primary Redis is under heavy load, to avoid contention.
- Log rate limit events. Track which clients are being throttled and which endpoints are most affected to tune your limits over time.
- Test under concurrent load. Use tools like Apache Bench or k6 to verify that your rate limiter behaves correctly with simultaneous requests.
Common Mistakes
- Using non-atomic Redis operations. Performing ZCARD then ZADD as separate commands allows race conditions where multiple requests slip through.
- Forgetting to set TTLs. Without expiration, Redis keys persist forever and consume memory indefinitely.
- Using IP addresses as the sole identifier. Users behind corporate NATs share IPs, so one abusive user can block everyone on that network.
- Applying the same limit to all endpoints. Login endpoints need stricter limits than read-only resource endpoints.
- Ignoring distributed clock drift. While Redis is the source of time in our Lua script, ensure your application servers have synchronized time if you generate timestamps in PHP.
- Not handling Redis failures gracefully. If Redis is unavailable, decide whether to fail open (allow requests) or fail closed (reject requests) based on your security requirements.
Performance Tips
- Pipeline Redis commands when performing bulk operations to reduce network round trips.
- Use connection pooling with Predis or phpredis to avoid the overhead of establishing new connections for every request.
- Consider Redis Cluster if your rate limit dataset exceeds single-node memory capacity, though be aware that multi-key operations require hash tags.
- Cache rate limit results briefly in local memory (APCu or shared memory) for extremely high-traffic endpoints, accepting a small delay in limit enforcement.
- Monitor Redis latency with the INFO stats command and set alerts if command latency exceeds your threshold.
Security Considerations
- Do not expose internal rate limit keys. The key structure should not reveal implementation details to attackers.
- Combine rate limiting with other protections. Rate limiting is not a substitute for authentication, input validation, or WAF rules.
- Use HTTPS to protect API keys and authentication tokens transmitted in headers.
- Be careful with fail-open behavior. If Redis is down and you allow all requests, you risk abuse. If you reject all requests, you risk a denial of service for legitimate users. Choose based on your threat model.
- Hash API keys before using them as Redis keys to prevent key enumeration if Redis data is exposed.
Deployment Notes
- Deploy Redis with persistence disabled for rate limiting if you can tolerate losing counters on restart, as this improves write performance.
- Use Redis Sentinel or Redis Cluster for high availability if rate limiting is critical to your application.
- Set maxmemory policy to allkeys-lru so Redis evicts old rate limit keys when memory is full.
- Run Redis on the same network segment as your PHP application to minimize latency.
- Monitor Redis memory usage and eviction rates to detect when your rate limit dataset is growing too large.
Debugging Tips
- Use Redis MONITOR to observe the exact commands sent by your PHP application during development.
- Check TTL values to ensure keys are expiring as expected.
- Inspect sorted sets with ZRANGE to verify that timestamps are being added and removed correctly.
- Log the client identifier and endpoint on every rate limit decision to identify which clients are hitting limits.
- Test edge cases such as requests arriving exactly at window boundaries and simultaneous requests from the same client.
FAQ
Q1: What happens if Redis goes down?
If Redis is unavailable, your rate limiter cannot check or update counters. You must decide whether to fail open and allow all requests, or fail closed and reject all requests. For public APIs, fail open is often preferred to avoid denying service to legitimate users. For high-security endpoints, fail closed may be safer. Implement a circuit breaker around your Redis client to handle this gracefully.
Q2: Can I use this without Predis?
Yes. The phpredis extension provides the same Redis commands. Replace Predis Client calls with phpredis equivalents. The Lua script logic remains identical regardless of which PHP Redis client you use.
Q3: How do I test rate limiting locally?
Run Redis locally via Docker or your package manager. Use a tool like curl in a loop to send rapid requests to your endpoint and verify that you receive 429 responses after exceeding the limit. You can also write PHPUnit tests that mock the Redis client to simulate allowed and denied responses.
Q4: Should I limit by IP or user ID?
Use user ID for authenticated endpoints because it uniquely identifies the person making the request. Use IP address for unauthenticated endpoints, but understand that multiple users may share an IP behind NAT. For the best results, combine identifiers: use user ID when authenticated, fall back to IP or API key otherwise.
Q5: What is the difference between fixed and sliding window?
Fixed window divides time into static buckets and resets at bucket boundaries, which can allow bursts at edges. Sliding window tracks individual request timestamps within a rolling period, providing more accurate rate enforcement at the cost of higher memory usage.
Q6: Can I use rate limiting with WebSockets?
Yes, but WebSocket connections are long-lived, so traditional request-based rate limiting does not apply directly. Limit the rate of messages sent over the connection, or limit the rate of new connection establishment using the same Redis-backed approach.
Q7: How often should I review rate limits?
Review rate limits quarterly or whenever you observe significant changes in traffic patterns. Monitor your logs for clients that frequently approach or exceed limits, and adjust thresholds to balance protection with user experience.
Q8: Does rate limiting protect against DDoS?
Rate limiting protects against application-layer abuse from individual clients, but it is not a complete DDoS solution. Distributed attacks with thousands of unique IP addresses can bypass per-client rate limits. Combine rate limiting with CDN protection, WAF rules, and infrastructure-level DDoS mitigation.
Conclusion
Building a distributed rate limiter with Redis and PHP gives you a reliable, scalable defense against API abuse. By using sliding window counters with atomic Lua scripts, you ensure accurate rate enforcement even under concurrent load. Returning standard rate limit headers helps clients respond gracefully, while proper TTL management keeps your Redis memory usage under control.
Start by applying strict limits to sensitive endpoints such as login and registration, then expand to general API traffic based on user tiers. Monitor your logs, tune your thresholds, and treat rate limiting as an evolving part of your security strategy rather than a set-it-and-forget-it feature.
Ready to protect your API? Implement the rate limiter class from this article in your staging environment, run load tests to verify behavior under concurrency, and roll it out to production with monitoring in place. Your users and your infrastructure will thank you.