snipershady/ratelimiter

A free and easy-to-use rate limiter

Maintainers

Package info

github.com/snipershady/ratelimiter

Homepage

pkg:composer/snipershady/ratelimiter

Transparency log

Statistics

Installs: 3 815

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

v1.0.9 2026-07-10 08:52 UTC

README

PHP Version License Packagist

A free and easy-to-use rate limiter for PHP applications.

Table of Contents

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-apcu extension.
  • ❌ 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) around isLimited()/isLimitedWithBan() reliably catches backend failures only for CacheEnum::PHP_REDIS and CacheEnum::MEMCACHED. If you use CacheEnum::REDIS (Predis) and want to handle backend outages explicitly, also catch \Predis\PredisException (or \Throwable for 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 $ttl seconds 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 $ttl and $banTtl for the same $key switches 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 $banTtl does — a normal, intentional configuration, see the $banTimeFrame/$banTtl example above). In practice this means a $key switching between two $ttl values in quick succession may see a small, temporary residual count from the abandoned namespace for up to that namespace's own 2 * $ttl seconds — 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=7s but the extended $banTtl window only starts at t=12s, once the normal $ttl window 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 exptime greater than 30 days (2,592,000 seconds) as an absolute Unix timestamp rather than a relative offset. This backend automatically converts any $ttl/$banTtl above 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 \RuntimeException instead 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 \RuntimeException if 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 $banTimeFrame seconds 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-limiter component. 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 the isLimitedWithBan progressive-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