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
This package is auto-updated.
Last update: 2026-08-05 10:55:58 UTC
README
A free and easy-to-use rate limiter for PHP applications.
Table of Contents
- Context
- Installation
- Requirements
- Choosing a Backend
- Algorithms
- API Reference
- Usage Examples
- Symfony Integration
- Development
- License
- Author
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
Choosing a Backend
| Backend | Enum | External server? | Shared across app servers? | Survives a restart? | Exact sliding window? |
|---|---|---|---|---|---|
| APCu | CacheEnum::APCU |
No | No — one counter per PHP process/host | No | No (approximation only) |
| Memcached | CacheEnum::MEMCACHED |
Yes | Yes | No — cleared on Memcached restart | No (approximation only) |
| Redis via Predis | CacheEnum::REDIS |
Yes | Yes | Optional (Redis persistence) | Yes |
| Redis via PhpRedis | CacheEnum::PHP_REDIS |
Yes | Yes | Optional (Redis persistence) | Yes |
All four implement the exact same RateLimiterInterface, so switching backend is a
one-line change to the factory() call — nothing else in your code needs to change.
APCu
- ✅ Zero network latency — reads/writes are in-process shared memory, no round-trip.
- ✅ Nothing to install or operate beyond the
ext-apcuextension. - ❌ Not shared across multiple web servers or containers — each instance enforces its own, independent limit. Don't use it behind a load balancer unless per-instance limiting is actually what you want.
- ❌ Counters are lost whenever the PHP process/worker restarts (deploys, PHP-FPM reloads).
- ❌ Only the two-bucket sliding-window approximation is available (see Algorithms), never the exact log.
Memcached
- ✅ Shared across every application server pointed at the same Memcached instance — good fit if you already run Memcached for object caching.
- ✅ Low operational overhead compared to Redis for a simple counter workload.
- ❌ No exact sliding-window log — same bucket approximation as APCu.
- ❌ No persistence or replication — a Memcached restart silently clears every counter, including active bans.
Redis (CacheEnum::REDIS via Predis, or CacheEnum::PHP_REDIS via the native extension)
- ✅ The only backend offering the exact sliding-window log (Redis sorted sets) — no boundary-burst approximation at all.
- ✅ Optional persistence (RDB/AOF) and replication if counters — including active bans — need to survive a restart.
- ✅ Rich enough primitives (
MULTI/EXEC,EXPIRE ... NX) to back the atomic self-healing behaviour described in the API Reference. - ❌ Requires running and operating a Redis server (or a managed instance).
- ❌ Slightly higher latency than APCu's in-process reads, due to the network round-trip.
Predis vs PhpRedis (ext-redis)
Both CacheEnum::REDIS and CacheEnum::PHP_REDIS talk to the same Redis server and
expose identical rate-limiting behaviour through this library — the difference is purely
in the PHP client underneath (RateLimiter\Adapter\PredisAdapter vs
RateLimiter\Adapter\PhpRedisAdapter).
| Aspect | Predis (CacheEnum::REDIS) |
PhpRedis (CacheEnum::PHP_REDIS) |
|---|---|---|
| Implementation | Pure PHP, distributed as a Composer package | C extension (ext-redis), compiled into the PHP runtime |
| Installing it | composer require predis/predis — works anywhere Composer works |
pecl install redis or a distro package (e.g. php8.3-redis), plus enabling it in php.ini — needs server/root access |
| Raw performance | Slower — the RESP protocol is parsed in userland PHP | Faster — protocol parsing happens in C, less overhead per command |
| Persistent connections | Supported ('persistent' => true), reimplemented in userland |
Supported natively via pconnect(), backed by the extension's own connection pool |
| Error handling seen by your code | Throws Predis\PredisException subclasses (e.g. ServerException, CommunicationException) directly — these do not extend \RuntimeException |
This library's PhpRedisAdapter wraps every backend failure into a plain \RuntimeException (php-redis itself only returns false + getLastError(), never throws) |
| Best for | Environments where you can't install PHP extensions: shared hosting, minimal Docker base images, quick prototyping | Environments where you control the PHP build and want the lowest possible per-request latency |
Important: because of the error-handling row above, a single
catch (\RuntimeException $e)aroundisLimited()/isLimitedWithBan()reliably catches backend failures only forCacheEnum::PHP_REDISandCacheEnum::MEMCACHED. If you useCacheEnum::REDIS(Predis) and want to handle backend outages explicitly, also catch\Predis\PredisException(or\Throwablefor a catch-all).
Algorithms
| Algorithm | Enum | Description |
|---|---|---|
| Fixed window | AlgorithmEnum::FIXED_WINDOW (default) |
A single counter per key, reset when its TTL expires. Simple and cheap, but allows up to 2x $limit requests to pass across a single window boundary. |
| Sliding window | AlgorithmEnum::SLIDING_WINDOW |
Smooths that boundary-burst problem. See below for the precision trade-off between backends. |
Select the algorithm with a fourth, optional argument to factory() — every existing call site that omits it keeps compiling and behaving exactly as before:
use RateLimiter\Enum\AlgorithmEnum; $limiter = AbstractRateLimiterService::factory(CacheEnum::PHP_REDIS, $redis, AlgorithmEnum::SLIDING_WINDOW);
isLimited(), isLimitedWithBan(), clearRateLimitedKey() and clearBan() keep the exact same signatures regardless of algorithm — only the object factory() hands you changes.
Sliding window precision differs by backend
AlgorithmEnum::SLIDING_WINDOW is implemented differently depending on the backend, because each one offers different primitives:
- Redis (
REDIS/PHP_REDIS) — an exact sliding-window log: every request's timestamp is recorded in a per-key sorted set, and only requests within the trailing$ttlseconds are ever counted. No approximation. - Memcached / APCu — a sliding window counter: an O(1)-memory approximation using two counters (the current and previous
$ttl-sized bucket), combined with a linear decay weight. This smooths the fixed window's boundary burst down to a small, bounded overcount, without the unbounded memory growth (or a CAS-loop that can spin forever) that an exact log would require on these backends.
Practical consequence: the same ($key, $limit, $ttl) triple can allow a slightly different number of requests across a window boundary depending on which backend you pick. Neither backend is "wrong" — Redis just affords a data structure (sorted sets) that Memcached/APCu don't, and the counter approximation's error is small and bounded (never worse than the fixed window's own 2x-at-the-boundary behavior).
The ban-violation counter (isLimitedWithBan()'s $maxAttempts/$banTimeFrame tracking) is always fixed-window, on every backend and every algorithm — banning is a hard, deliberate action, not something that benefits from smoothing.
Known limitation: for the Memcached/APCu sliding window counter,
isLimitedWithBan()swapping between the normal$ttland$banTtlfor the same$keyswitches to a different internal bucket namespace. The old namespace is left to expire on its own rather than eagerly deleted (eager deletion would risk prematurely ending an active ban if the violation counter happens to expire before$banTtldoes — a normal, intentional configuration, see the$banTimeFrame/$banTtlexample above). In practice this means a$keyswitching between two$ttlvalues in quick succession may see a small, temporary residual count from the abandoned namespace for up to that namespace's own2 * $ttlseconds — never longer, and never a permanent leak.
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)
Note: a ban is only applied when the request key's window is next (re)created — it is not retroactive mid-window. In the timeline above, the ban threshold is reached at
t=7sbut the extended$banTtlwindow only starts att=12s, once the normal$ttlwindow naturally expires. With a long$ttl, a client that just tripped the threshold can keep operating under the old, non-banned window for up to the remainder of that period.
clearRateLimitedKey(string $key): bool
Remove a rate limit key, resetting its counter. Note: when the key was managed with
isLimitedWithBan, this does not lift an active ban — the ban violation counter
lives under a separate internal key. Use clearBan to actually unban a client.
| Parameter | Type | Description |
|---|---|---|
$key |
string | The key to clear |
Returns: true on success, false on failure.
clearBan(string $key, ?string $clientIp = null): bool
Clears both the request counter and the ban violation counter for $key, immediately
lifting an active ban. $clientIp must match the value passed to isLimitedWithBan
(or be omitted/null if a shared global counter was used), so the correct per-IP
violation counter is targeted.
| Parameter | Type | Description |
|---|---|---|
$key |
string | The key to unban |
$clientIp |
string|null | Must match the $clientIp used with isLimitedWithBan, if any |
Returns: true if either counter was actually cleared, false if there was nothing to clear.
Input Validation
Every method validates its arguments and throws \InvalidArgumentException on the first
violation, before touching the cache backend:
| Parameter | Rule |
|---|---|
$key |
Non-empty, at most 128 bytes |
$clientIp |
When not null, at most 45 bytes (covers any IPv6 literal) |
$ttl, $banTtl, $banTimeFrame |
Positive integer (> 0) |
$maxAttempts |
Positive integer (> 0) — a non-positive value would otherwise apply $banTtl unconditionally from the very first request |
The $key/$clientIp length caps exist because the internal ban-tracking key is built as
BAN_violation_count_<key>_<clientIp>, which must stay safely under Memcached's hard
250-byte key limit regardless of backend.
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!"); }
Sliding window
Same four methods, same parameters — only the factory() call changes. See
Algorithms for the precision trade-off between backends.
use RateLimiter\Enum\AlgorithmEnum; $limiter = AbstractRateLimiterService::factory(CacheEnum::PHP_REDIS, $redis, AlgorithmEnum::SLIDING_WINDOW); $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!"); }
Two Memcached-specific behaviours worth knowing:
- Long TTLs just work. Memcached's protocol treats an
exptimegreater than 30 days (2,592,000 seconds) as an absolute Unix timestamp rather than a relative offset. This backend automatically converts any$ttl/$banTtlabove that threshold into an absolute timestamp internally, so you can pass any plain "seconds from now" value — including a multi-month$banTtl— without running into that quirk yourself. - Backend errors fail closed. If a genuine Memcached error occurs (server unreachable,
timeout, etc. — as opposed to a normal cache miss), the library throws
\RuntimeExceptioninstead of silently treating the request as unlimited. A rate limiter is a security control, so an infrastructure failure should block, not bypass, the check; catch\RuntimeExceptionif you need custom fallback behaviour during an outage.
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 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, this method alone does not lift an active ban: the
ban violation counter lives under a separate internal key and drives the ban independently
of the request counter. Use clearBan instead to actually unban a client.
Manually Lifting a Ban
clearBan clears both the request counter and the violation counter for a key, so the
very next request is treated as the first in a fresh window instead of immediately
re-triggering the ban.
$limiter = AbstractRateLimiterService::factory(CacheEnum::APCU); $key = 'App\Controller\LoginController::login'; $clientIp = $_SERVER['REMOTE_ADDR']; if ($limiter->clearBan($key, $clientIp)) { // Client is unbanned immediately. }
Pass the same $clientIp (or omit it) that was used with isLimitedWithBan, so the
correct violation counter — per-IP or shared — is cleared.
Symfony Integration
This library ships no bundle and has no dependency on the Symfony framework — it's a
plain PHP factory, so wiring it into the service container is just a few lines of
services.yaml. The example below registers a Redis-backed limiter (via PhpRedis) as
the RateLimiter\Service\RateLimiterInterface service, so it can be autowired anywhere.
Not to be confused with Symfony's own
symfony/rate-limitercomponent. That component solves the same general problem with a different API and storage model; this library is an independent implementation, useful when you specifically want its APCu/Memcached/Redis backend choice or theisLimitedWithBanprogressive-ban feature.
1. Register the Redis client and the limiter
# config/services.yaml services: Redis: class: Redis calls: - pconnect: ['%env(REDIS_HOST)%', 6379, 2, 'persistent_id_rl'] RateLimiter\Service\RateLimiterInterface: factory: ['RateLimiter\Service\AbstractRateLimiterService', 'factory'] arguments: - !php/enum RateLimiter\Enum\CacheEnum::PHP_REDIS - '@Redis'
Swap CacheEnum::PHP_REDIS / @Redis for CacheEnum::REDIS / a Predis\Client service
(or CacheEnum::APCU / CacheEnum::MEMCACHED with no second argument, or a Memcached
service) to use a different backend — nothing else below changes. See
Choosing a Backend for the trade-offs.
2. Use it in a controller
<?php namespace App\Controller; use RateLimiter\Service\RateLimiterInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\Routing\Attribute\Route; final class LoginController { public function __construct( private readonly RateLimiterInterface $limiter, ) { } #[Route('/login', name: 'app_login', methods: ['POST'])] public function login(Request $request): Response { $clientIp = $request->getClientIp(); $limited = $this->limiter->isLimitedWithBan( key: __METHOD__, limit: 5, ttl: 60, maxAttempts: 3, banTimeFrame: 300, banTtl: 3600, clientIp: $clientIp, ); if ($limited) { throw new TooManyRequestsHttpException(retryAfter: 60, message: 'Too many login attempts.'); } // ... proceed with authentication ... return new Response('OK'); } }
RateLimiterInterface is autowired automatically as long as it resolves to exactly one
service in the container (the services.yaml snippet above guarantees that). If you need
more than one backend/algorithm combination at once (e.g. a strict Redis limiter for
login, a cheap APCu one for a public API), register each under a distinct, named service
id and inject them by id instead of by interface — #[Autowire(service: '...')] on the
constructor argument, or bind an explicit alias.
3. Reusable as an event subscriber
For a limit that should apply to every request instead of a single controller, wire the
same RateLimiterInterface service into a kernel.request listener:
<?php namespace App\EventSubscriber; use RateLimiter\Service\RateLimiterInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; final class GlobalRateLimitSubscriber { public function __construct( private readonly RateLimiterInterface $limiter, ) { } #[AsEventListener(event: 'kernel.request', priority: 100)] public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) { return; } $clientIp = $event->getRequest()->getClientIp() ?? 'unknown'; $limited = $this->limiter->isLimitedWithBan( key: 'global_api', limit: 100, ttl: 60, maxAttempts: 5, banTimeFrame: 600, banTtl: 1800, clientIp: $clientIp, ); if ($limited) { throw new TooManyRequestsHttpException(retryAfter: 60); } } }
Development
Dev dependencies
| Package | Version | Purpose |
|---|---|---|
| phpunit/phpunit | ^13.2 | test runner |
| phpstan/phpstan | ^2.2 | static analysis (level 8 on src/, see phpstan.dist.neon) |
| friendsofphp/php-cs-fixer | ^3.95 | code style (@Symfony + @Symfony:risky) |
| rector/rector | ^2.5.5 | automated refactoring |
Available Scripts
| Command | Description |
|---|---|
composer test |
Run the full PHPUnit suite (unit + integration) |
composer test:unit |
Run only the fast unit suite — mocked, no external services, milliseconds |
composer test:integration |
Run only the integration suite — needs live APCu/Redis/Memcached, several minutes |
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 |
Test suite layout
Tests are split into two PHPUnit testsuites (phpunit.xml):
tests/Unit/— no external services, mocked cache clients, runs in milliseconds.tests/Integration/— exercises real APCu/Redis/Memcached backends with real TTL expiry, so it takes several minutes (sleep()-driven).
Within tests/Integration/, backend behaviour that is identical across every cache is defined once in Contract/AbstractRateLimiterContractTestCase and inherited by each concrete backend class; the two Redis backends (Predis and php-redis) additionally share Contract/AbstractRedisFamilyContractTestCase, since both expose the applied TTL identically via ttl(). APCu and Memcached implement their own ban-lifecycle tests instead of sharing that second layer, because neither exposes the remaining TTL the same way Redis does. A concrete backend class stays thin — it only wires up a connection and adds tests for genuinely backend-specific behaviour (e.g. php-redis's WRONGTYPE fail-closed handling, or Memcached's 30-day TTL threshold quirk).
AlgorithmEnum::SLIDING_WINDOW has its own pair of contract base classes, mirroring the fixed-window ones above but with timing assertions specific to that algorithm's behaviour: Contract/AbstractRedisSlidingLogContractTestCase (Predis/php-redis, exact log — PredisSlidingWindowRateLimiterTest/PhpRedisSlidingWindowRateLimiterTest) and Contract/AbstractSlidingWindowCounterContractTestCase (Memcached/APCu, two-bucket approximation — MemcachedSlidingWindowRateLimiterTest/ApcuSlidingWindowRateLimiterTest). The latter aligns every timing-sensitive test to an exact bucket boundary first (alignToBucketBoundary()), so outcomes are a deterministic function of the chosen sleep()s instead of depending on wall-clock phase at the moment the test happens to run.
License
This project is licensed under the GPL-3.0-or-later License - see the LICENSE file for details.
Author
Stefano Perrini - spinfo.it