Search by

ez-php / rate-limiter

AU9500

Rate limiter module for the ez-php framework — array, Redis, and cache-backed drivers with ThrottleMiddleware

Package info

github.com/ez-php/rate-limiter

pkg:composer/ez-php/rate-limiter

Statistics

Installs: 1 042

Dependents: 3

Suggesters: 2

Stars: 0

Open Issues: 0

2.5.6 2026-09-30 18:37 UTC

README

Request throttling for ez-php applications — three backends, a unified interface, and a plug-in ThrottleMiddleware.

Installation

composer require ez-php/rate-limiter

Drivers

Driver Persistence External requirement Concurrency-safe
ArrayDriver In-process (lost on restart) None No — single-process/test use only
FileDriver Files on disk None Yes — flock(LOCK_EX), single host
RedisDriver Redis ext-redis Yes — one Lua script (check + INCR + EXPIRE), fixed window
SlidingWindowRedisDriver Redis ext-redis Yes — one Lua script over a sorted set, true sliding window
CacheDriver Delegates to ez-php/cache Any configured cache driver Yes — per-key cache lock(); cross-process with the File/Redis/Memcached cache drivers, in-process only with the Array cache driver

Warning: ArrayDriver uses a plain PHP array without atomic operations. Concurrent requests (e.g. PHP-FPM workers) can race and both be allowed through simultaneously. Use FileDriver, RedisDriver or CacheDriver in production.

FileDriver

For single-host deployments that have no Redis. Counters persist across requests and process restarts, and the whole read-modify-write in attempt() runs under an exclusive flock(), so concurrent PHP-FPM workers cannot both slip past the limit.

use EzPhp\RateLimiter\FileDriver;

$limiter = new FileDriver('/var/www/storage/rate-limiter');
$limiter->attempt('login:1.2.3.4', 5, 60);

Or via config:

// config/rate_limiter.php
return [
    'driver' => 'file',
    'file'   => ['path' => __DIR__ . '/../storage/rate-limiter'],
];
  • One file per key; the key is sha1()-hashed, so a key containing / or .. is safe.

  • Single host only. Locking is filesystem-level — a shared network mount across hosts is not supported. Use RedisDriver for multi-host deployments.

  • Counter files are not swept automatically. A key is reclaimed when it is next read, but keys that stop being used (e.g. one per client IP) leave files behind. Call prune() from cron or a scheduled command if the endpoint is exposed to untrusted traffic:

    $deleted = $limiter->prune(); // removes expired counters, returns how many

    Live counters are left untouched. prune() is only on FileDriver, not on RateLimiterInterface — the other drivers expire their own keys.

SlidingWindowRedisDriver

RedisDriver's fixed window resets in one block: a limit of 5/60s allows 5 requests at t=0.9s and another 5 at t=1.0s (two different windows), i.e. 10 requests in ~0.1s at a window boundary. SlidingWindowRedisDriver avoids that by tracking every hit's timestamp in a Redis sorted set and pruning anything older than the trailing decaySeconds window on every call — "no more than N requests in any trailing 60 seconds", not "N requests per calendar-aligned 60-second bucket". The cost: one sorted-set member per hit instead of a single counter, and every attempt() does a range-delete before the count check.

use EzPhp\RateLimiter\SlidingWindowRedisDriver;
use Redis;

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$limiter = new SlidingWindowRedisDriver($redis);
$limiter->attempt('login:1.2.3.4', 5, 60); // no more than 5 hits in any trailing 60s

Same RateLimiterInterface contract as every other driver — drop-in replacement for RedisDriver wherever the fixed-window/sliding-window distinction matters.

Basic usage

use EzPhp\RateLimiter\ArrayDriver;

$limiter = new ArrayDriver();

if (!$limiter->attempt('login:' . $ip, maxAttempts: 5, decaySeconds: 60)) {
    // Too many attempts — respond with 429
}

$limiter->remainingAttempts('login:' . $ip, 5); // how many hits are still allowed
$limiter->resetAttempts('login:' . $ip);        // clear the counter (e.g. on success)

Using the facade

RateLimiter mirrors RateLimiterInterface as static methods, backed by a managed singleton set during RateLimiterServiceProvider::boot(). Calling it before the provider boots throws a RuntimeException — there is intentionally no in-memory fallback, because a per-process ArrayDriver would silently let every PHP-FPM worker count from zero. In tests, set one explicitly: RateLimiter::setInstance(new RateLimiter(new ArrayDriver())).

use EzPhp\RateLimiter\RateLimiter;

if (!RateLimiter::attempt('login:' . $ip, maxAttempts: 5, decaySeconds: 60)) {
    $retryIn = RateLimiter::availableIn('login:' . $ip);
    // respond 429, e.g. with a Retry-After: $retryIn header
}

RateLimiter::tooManyAttempts('login:' . $ip, 5);
RateLimiter::remainingAttempts('login:' . $ip, 5);
RateLimiter::resetAttempts('login:' . $ip);

In tests, call RateLimiter::resetInstance() in tearDown() to clear the static singleton between test cases.

ThrottleMiddleware

Plug into the framework middleware pipeline for per-IP global or per-route throttling:

Middleware is registered by class name and resolved from the container. RateLimiterServiceProvider binds ThrottleMiddleware with the default limits (60 per 60 s) and the proxies from rate_limiter.trusted_proxies (a list, or a comma-separated string such as RATE_LIMITER_TRUSTED_PROXIES) — enough for per-route parameters below. For a different global limit, bind your own configured instance in a provider's register() that runs after it:

// AppServiceProvider::register()
$this->app->bind(ThrottleMiddleware::class, fn (): ThrottleMiddleware => new ThrottleMiddleware(
    $this->app->make(RateLimiterInterface::class),
    maxAttempts: 60,
    decaySeconds: 60,
    trustedProxies: ['10.0.0.1'], // behind a reverse proxy / load balancer: list its address(es)
));

// Global — before bootstrap (e.g. public/index.php)
$app->middleware(ThrottleMiddleware::class);

For per-route limits, pass them as middleware parameters — maxAttempts,decaySeconds[,bucket] (ThrottleMiddleware implements ParameterizedMiddlewareInterface). The same container-built instance serves every route; only the parameters differ:

$app->middlewareAlias('throttle', ThrottleMiddleware::class); // before bootstrap

$router->post('/login', [LoginController::class, 'store'])->middleware('throttle:5,60');
$router->post('/register', [RegisterController::class, 'store'])->middleware('throttle:3,60');
$router->post('/password/reset', [ResetController::class, 'store'])->middleware('throttle:3,60,password-reset');

Each distinct max,decay pair gets its own counter, separate from the global limit's; routes that should share one counter name the same third parameter (bucket). Malformed parameters (non-numeric, zero, or more than three) throw LogicException — a configuration error, not a 429.

The middleware:

  • Keys the limit on the client IP: REMOTE_ADDR, or — only when REMOTE_ADDR is one of the trustedProxies you pass — the first untrusted X-Forwarded-For hop (walking from the right). Without trusted proxies the header is ignored, so clients cannot dodge the limit with a forged header.
  • Returns HTTP 429 with body Too Many Requests when the limit is exceeded — or, when the request wantsJson(), {"error":{"code":429,"message":"Too Many Requests"}} (Content-Type: application/json, the framework's error envelope). Both carry Retry-After.
  • Adds X-RateLimit-Limit and X-RateLimit-Remaining headers on every passing response.

Service provider

Register RateLimiterServiceProvider in provider/modules.php:

\EzPhp\RateLimiter\RateLimiterServiceProvider::class,

Create config/rate_limiter.php:

<?php
return [
    'driver' => getenv('RATE_LIMITER_DRIVER') ?: 'array', // array | redis | cache

    'redis' => [
        'host'     => getenv('REDIS_HOST') ?: '127.0.0.1',
        'port'     => (int) (getenv('REDIS_PORT') ?: 6379),
        'database' => (int) (getenv('REDIS_RATE_LIMITER_DB') ?: 0),
    ],

    // Reverse proxies whose X-Forwarded-For ThrottleMiddleware honours.
    'trusted_proxies' => getenv('RATE_LIMITER_TRUSTED_PROXIES') ?: '',
];

Interface

interface RateLimiterInterface
{
    public function attempt(string $key, int $maxAttempts, int $decaySeconds): bool;
    public function tooManyAttempts(string $key, int $maxAttempts): bool;
    public function remainingAttempts(string $key, int $maxAttempts): int;
    public function resetAttempts(string $key): void;
    public function availableIn(string $key): int; // seconds until the window resets; 0 if expired/absent
}

License

MIT