PHP cookbook · Intermediate
Implement rate limiting in a PHP API
Limit each client to N requests per minute across multiple PHP-FPM servers and return 429 with Retry-After.
Approach
Use a shared Redis counter per client and time window so every server sees the same count. A fixed-window counter with INCR + EXPIRE is simple and good enough for most APIs; run both commands atomically in a Lua script or MULTI. Identify clients by API key or user ID (fall back to IP), set standard rate-limit headers and return 429 with Retry-After when exceeded.
Solution
<?php
declare(strict_types=1);
final class RateLimiter
{
private const SCRIPT = <<<'LUA'
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return {current, redis.call('TTL', KEYS[1])}
LUA;
public function __construct(private \Redis $redis) {}
/** @return array{allowed: bool, remaining: int, reset: int} */
public function hit(string $key, int $limit, int $windowSeconds): array
{
$bucket = sprintf('rl:%s:%d', $key, intdiv(time(), $windowSeconds));
[$count, $ttl] = $this->redis->eval(self::SCRIPT, [$bucket, $windowSeconds], 1);
return ['allowed' => $count <= $limit, 'remaining' => max(0, $limit - $count), 'reset' => max(1, $ttl)];
}
}
// Middleware-style usage in a front controller
$clientKey = $apiKeyId ?? ('ip:' . $_SERVER['REMOTE_ADDR']);
$result = (new RateLimiter($redis))->hit($clientKey, limit: 100, windowSeconds: 60);
header('RateLimit-Limit: 100');
header('RateLimit-Remaining: ' . $result['remaining']);
header('RateLimit-Reset: ' . $result['reset']);
if (!$result['allowed']) {
http_response_code(429);
header('Retry-After: ' . $result['reset']);
header('Content-Type: application/problem+json');
echo json_encode(['title' => 'Too Many Requests', 'status' => 429]);
exit;
}
Pitfalls
- Per-server counters (APCu, files) — each server allows the full limit.
- Using
REMOTE_ADDRbehind a proxy without trustingX-Forwarded-Forcorrectly (everyone shares the proxy IP). - Non-atomic INCR then EXPIRE — a crash between them leaves keys without TTL.
Variations
- Sliding window: use a sorted set of timestamps (
ZADD,ZREMRANGEBYSCORE,ZCARD). - Laravel:
RateLimiter::for('api', fn ($r) => Limit::perMinute(60)->by($r->user()?->id ?: $r->ip())). - Symfony: the RateLimiter component with token bucket or sliding window policies.
Further reading: Redis: Lua scripting
Reference: https://redis.io/docs/latest/develop/interact/programmability/eval-intro/
More PHP recipes
- Add CSRF protection to PHP forms
- Add health check endpoints to a PHP app
- Add structured logging with request IDs in PHP
- Build a secure password reset flow in PHP
- Cache expensive queries with Redis in PHP
- Call external APIs with timeouts and retries in PHP
- Handle file uploads securely in PHP
- Make a PHP payment endpoint idempotent