snipershady / ratelimiter
A free and easy-to-use rate limiter
Requires
- php: ^8.3
- predis/predis: ^3.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.2
- rector/rector: ^2.4.5
README
A free and easy-to-use rate limiter for PHP applications.
Context
You need to limit network traffic access to a specific function in a specific timeframe. Rate limiting may help to stop some kinds of malicious activity such as brute force attacks, DDoS, and API abuse.
Installation
composer require snipershady/ratelimiter
Requirements
Composer packages
| Package | Version | Notes |
|---|---|---|
| PHP | ^8.3 | minimum version |
| predis/predis | ^3.2 | required only for CacheEnum::REDIS |
System extensions
Native PHP extensions are not managed by Composer. Install only the ones needed by the backends you use.
| Extension | Required by |
|---|---|
| ext-apcu | CacheEnum::APCU |
| ext-redis | CacheEnum::PHP_REDIS |
| ext-memcached | CacheEnum::MEMCACHED |
Debian / Ubuntu
PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') # APCu apt-get install php${PHP_VER}-apcu # Redis (php-redis native extension) apt-get install php${PHP_VER}-redis # Memcached (php-memcached native extension — note the 'd') apt-get install php${PHP_VER}-memcached
CLI Usage
For CLI usage, remember to enable APCu in your php.ini:
apc.enable_cli=1
Available Cache Backends
| Backend | Enum | Description |
|---|---|---|
| APCu | CacheEnum::APCU |
Local in-memory cache, no external server required |
| Predis | CacheEnum::REDIS |
Redis via Predis library (pure PHP) |
| PhpRedis | CacheEnum::PHP_REDIS |
Redis via php-redis native extension (better performance) |
| Memcached | CacheEnum::MEMCACHED |
Memcached via php-memcached native extension |
API Reference
isLimited(string $key, int $limit, int $ttl): bool
Check if a key has exceeded the rate limit.
| Parameter | Type | Description |
|---|---|---|
$key |
string | Unique identifier for the rate limit (e.g., __METHOD__) |
$limit |
int | Maximum number of attempts allowed |
$ttl |
int | Time window in seconds |
Returns: true if the limit has been exceeded, false otherwise.
isLimitedWithBan(string $key, int $limit, int $ttl, int $maxAttempts, int $banTimeFrame, int $banTtl, ?string $clientIp): bool
Check if a key has exceeded the rate limit, with progressive ban support for repeat offenders.
Each violation (a request that exceeds $limit within $ttl) increments a per-client counter.
When that counter reaches $maxAttempts within the $banTimeFrame observation window, the client
is banned: its next time window is extended to $banTtl instead of the normal $ttl.
| Parameter | Type | Description |
|---|---|---|
$key |
string | Unique identifier for the rate limit |
$limit |
int | Maximum number of requests allowed in $ttl seconds |
$ttl |
int | Normal time window in seconds |
$maxAttempts |
int | Number of violations allowed before a ban is applied |
$banTimeFrame |
int | Observation window in seconds during which violations are counted. The violation counter resets after $banTimeFrame seconds from the first violation, regardless of subsequent activity (fixed window). |
$banTtl |
int | Extended time window in seconds applied when the client is banned ($banTtl replaces $ttl for the duration of the ban) |
$clientIp |
string|null | When provided, each IP address maintains its own independent violation counter. Pass null to apply a shared global counter for the key. |
Returns: true if the limit has been exceeded, false otherwise.
How the three time parameters interact
$ttl Normal window: max $limit requests every $ttl seconds
$banTimeFrame Observation window: counts how many times the limit was
exceeded. Resets $banTimeFrame seconds after the first violation.
$banTtl Punishment window: replaces $ttl when the client has exceeded
the limit $maxAttempts times within $banTimeFrame seconds.
Concrete timeline — $limit=1, $ttl=5s, $maxAttempts=2, $banTimeFrame=30s, $banTtl=120s:
t=0s Request 1: allowed (counter=1, within limit)
t=1s Request 2: BLOCKED → violation #1 recorded, violation TTL=30s starts
t=6s Normal window ($ttl=5s) expired
t=6s Request 3: allowed (new window, violation_count=1 < maxAttempts=2)
t=7s Request 4: BLOCKED → violation #2 recorded ← ban threshold reached!
violation_count=2 expires at t≈30s (banTimeFrame from t≈1s)
t=12s Normal window expired
t=12s Request 5: allowed (new window; but violation_count=2 ≥ maxAttempts
→ window is extended: this key now lives 120s)
t=13s Request 6: BLOCKED (inside the 120s ban window)
... All requests blocked until t≈132s (t=12 + banTtl=120)
t=31s Violation counter expired (banTimeFrame=30s from t≈1s)
t=132s Ban window ($banTtl=120s) expired
t=132s Request N: allowed (violation_count=0, normal $ttl=5s applies again)
clearRateLimitedKey(string $key): bool
Remove a rate limit key, resetting its counter.
| Parameter | Type | Description |
|---|---|---|
$key |
string | The key to clear |
Returns: true on success, false on failure.
Usage Examples
Common imports
use Predis\Client; use RateLimiter\Enum\CacheEnum; use RateLimiter\Service\AbstractRateLimiterService;
APCu
No external server required. Ideal for single-server deployments or CLI tools.
$limiter = AbstractRateLimiterService::factory(CacheEnum::APCU); $key = __METHOD__; $limit = 2; $ttl = 3; if ($limiter->isLimited($key, $limit, $ttl)) { throw new \Exception("LIMIT REACHED: YOU SHALL NOT PASS!"); }
Redis — Predis
Pure-PHP Redis client; no native extension required.
$redis = new Client([ 'scheme' => 'tcp', 'host' => '192.168.0.100', 'port' => 6379, 'persistent' => true, ]); $limiter = AbstractRateLimiterService::factory(CacheEnum::REDIS, $redis); $key = __METHOD__; $limit = 2; $ttl = 3; if ($limiter->isLimited($key, $limit, $ttl)) { throw new \Exception("LIMIT REACHED: YOU SHALL NOT PASS!"); }
Redis — PhpRedis
Native ext-redis extension; better raw performance than Predis.
$redis = new \Redis(); $redis->pconnect( '192.168.0.100', 6379, 2, 'persistent_id_rl' ); $limiter = AbstractRateLimiterService::factory(CacheEnum::PHP_REDIS, $redis); $key = __METHOD__; $limit = 2; $ttl = 3; if ($limiter->isLimited($key, $limit, $ttl)) { throw new \Exception("LIMIT REACHED: YOU SHALL NOT PASS!"); }
Memcached
Requires ext-memcached. Passing a persistent_id reuses the connection pool across
requests; the getServerList() guard prevents registering the same server twice.
$memcached = new \Memcached('persistent_id_rl'); if (!$memcached->getServerList()) { $memcached->addServer('192.168.0.100', 11211); } $limiter = AbstractRateLimiterService::factory(CacheEnum::MEMCACHED, $memcached); $key = __METHOD__; $limit = 2; $ttl = 3; if ($limiter->isLimited($key, $limit, $ttl)) { throw new \Exception("LIMIT REACHED: YOU SHALL NOT PASS!"); }
Rate Limit with Ban
Use isLimitedWithBan when you want to progressively punish repeat offenders with longer
block windows. The only difference between backends is the factory call — the parameters
and behaviour are identical.
With APCu
$limiter = AbstractRateLimiterService::factory(CacheEnum::APCU); $key = __METHOD__; $limit = 5; $ttl = 60; $maxAttempts = 3; $banTimeFrame = 300; $banTtl = 3600; $clientIp = $_SERVER['REMOTE_ADDR'] ?? null; if ($limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, $clientIp)) { throw new \RuntimeException("Too many login attempts. Please try again later."); }
With Predis
$redis = new Client([ 'scheme' => 'tcp', 'host' => '192.168.0.100', 'port' => 6379, 'persistent' => true, ]); $limiter = AbstractRateLimiterService::factory(CacheEnum::REDIS, $redis); $key = __METHOD__; $limit = 5; $ttl = 60; $maxAttempts = 3; $banTimeFrame = 300; $banTtl = 3600; $clientIp = $_SERVER['REMOTE_ADDR'] ?? null; if ($limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, $clientIp)) { throw new \RuntimeException("Too many login attempts. Please try again later."); }
With PhpRedis
$redis = new \Redis(); $redis->pconnect('192.168.0.100', 6379, 2, 'persistent_id_rl'); $limiter = AbstractRateLimiterService::factory(CacheEnum::PHP_REDIS, $redis); $key = __METHOD__; $limit = 5; $ttl = 60; $maxAttempts = 3; $banTimeFrame = 300; $banTtl = 3600; $clientIp = $_SERVER['REMOTE_ADDR'] ?? null; if ($limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, $clientIp)) { throw new \RuntimeException("Too many login attempts. Please try again later."); }
With Memcached
$memcached = new \Memcached('persistent_id_rl'); if (!$memcached->getServerList()) { $memcached->addServer('192.168.0.100', 11211); } $limiter = AbstractRateLimiterService::factory(CacheEnum::MEMCACHED, $memcached); $key = __METHOD__; $limit = 5; $ttl = 60; $maxAttempts = 3; $banTimeFrame = 300; $banTtl = 3600; $clientIp = $_SERVER['REMOTE_ADDR'] ?? null; if ($limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, $clientIp)) { throw new \RuntimeException("Too many login attempts. Please try again later."); }
Understanding $banTimeFrame
$banTimeFrame is the observation window that determines how long a violation is
"remembered". It answers the question: "How many times has this client exceeded the limit
in the last N seconds?".
$ttl → How long each rate-limit window lasts (normal behaviour)
$banTimeFrame → How long violations are tracked (observation window)
$banTtl → How long a ban lasts once the client is flagged
The violation counter is a fixed window starting at the first violation:
- It does not reset on each new violation (no sliding window).
- After
$banTimeFrameseconds it expires and the client is "forgiven".
Visual example — $limit=5, $ttl=60s, $maxAttempts=3, $banTimeFrame=300s, $banTtl=3600s:
t=0s 6 rapid requests → 5 allowed, 1 BLOCKED → violation #1 (counter TTL = 300s)
t=60s Window resets. 6 requests again → violation #2
t=120s Window resets. 6 requests again → violation #3 ← ban threshold!
violation_count = 3 >= maxAttempts=3
t=180s Window resets. Client tries again:
violation_count still alive (expires at t≈300s)
→ ban applied: new window is 3600s instead of 60s
→ client blocked for 1 hour
t=300s Violation counter expires (banTimeFrame elapsed from t=0)
t=3780s Ban window expires (t=180 + banTtl=3600)
t=3780s Client can try again with a fresh violation counter
Per-client isolation with $clientIp
When $clientIp is provided, each IP address has its own independent violation counter.
Banning 192.168.1.1 has no effect on 192.168.1.2:
// Client A: banned after 3 violations $limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, '192.168.1.1'); // Client B: unaffected, starts from zero violations $limiter->isLimitedWithBan($key, $limit, $ttl, $maxAttempts, $banTimeFrame, $banTtl, '192.168.1.2');
Pass null to use a shared global counter for the key (all clients contribute to
the same violation count — useful when you want to protect a resource globally regardless
of origin).
Clearing a Rate Limit Key
clearRateLimitedKey resets the counter for a given key immediately. Useful after a
successful authentication, a manual unban, or during testing.
// Works identically for every backend — swap the factory call as needed. $limiter = AbstractRateLimiterService::factory(CacheEnum::APCU); $key = 'App\Controller\LoginController::login'; if ($limiter->clearRateLimitedKey($key)) { // Counter reset; the next request will be treated as the first in a new window. }
When using isLimitedWithBan, only the request counter is cleared by this method. The
violation counter (used to track bans) lives under a separate internal key and is managed
automatically by the library.
Development
Dev dependencies
| Package | Version | Purpose |
|---|---|---|
| phpunit/phpunit | ^12.1 | test runner |
| phpstan/phpstan | ^2.1 | static analysis |
| friendsofphp/php-cs-fixer | ^3.90 | code style |
| rector/rector | ^2.1 | automated refactoring |
Available Scripts
| Command | Description |
|---|---|
composer test |
Run PHPUnit tests |
composer phpstan |
Run PHPStan static analysis |
composer cs-fix |
Fix code style with PHP-CS-Fixer |
composer cs-check |
Check code style (dry-run) |
composer rector |
Run Rector refactoring |
composer rector-dry |
Preview Rector changes |
composer quality |
Run all quality tools (Rector + CS-Fixer) |
composer quality-check |
Check quality without changes |
License
This project is licensed under the GPL-3.0-or-later License - see the LICENSE file for details.
Author
Stefano Perrini - spinfo.it