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_ADDR behind a proxy without trusting X-Forwarded-For correctly (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