monkeyscloud / monkeyslegion-rate-limit
Production-grade rate limiting with token bucket and sliding window algorithms, Redis backend, per-route + per-user + per-IP composite buckets, and PSR-15 middleware for MonkeysLegion.
Package info
github.com/MonkeysCloud/MonkeysLegion-Rate-Limit
pkg:composer/monkeyscloud/monkeyslegion-rate-limit
Requires
- php: ^8.4
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- monkeyscloud/monkeyslegion-http: ^2.1
- monkeyscloud/monkeyslegion-telemetry: ^2.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-redis: Required for RedisStorage backend (production use)
- monkeyscloud/monkeyslegion-cache: For shared Redis connection access
- monkeyscloud/monkeyslegion-telemetry: For automatic rate limit metrics (counters, histograms)
This package is auto-updated.
Last update: 2026-05-23 00:22:02 UTC
README
Production-grade rate limiting for MonkeysLegion Framework with token bucket and sliding window algorithms, Redis backend, and PSR-15 middleware.
Features
- Two algorithms: Token Bucket (burst-friendly) and Sliding Window (precise counting)
- Redis Lua scripts: Atomic operations — no race conditions at scale
- Composite key resolvers: Per-IP, per-user, per-route, or any combination (
ip+route,user+route) #[RateLimit]attribute: Repeatable, class + method targets, named limiter references- PSR-15 middleware: RFC-compliant rate limit headers
- Resilient storage: Fail-open/fail-closed modes with circuit breaker — no 500 errors when Redis is down
- MLC configuration: Full config file with env var overrides and named limiter definitions
- Telemetry integration: Optional metrics for monitoring (counters, gauges)
- GDPR compliance: Optional IP hashing, trusted proxy support
- PHP 8.4: Property hooks, backed enums, asymmetric visibility
Installation
composer require monkeyscloud/monkeyslegion-rate-limit
For production use with Redis:
# Ensure ext-redis is installed
pecl install redis
MLC Configuration
Copy the config file to your project's config/ directory:
cp vendor/monkeyscloud/monkeyslegion-rate-limit/config/rate-limit.mlc config/rate-limit.mlc
Full Configuration Reference
rate_limit {
#
#--------------------------------------------------------------------------
# Global Defaults
#--------------------------------------------------------------------------
#
max_attempts = ${RATE_LIMIT_MAX_ATTEMPTS:-60}
window_seconds = ${RATE_LIMIT_WINDOW_SECONDS:-60}
algorithm = ${RATE_LIMIT_ALGORITHM:-token_bucket}
by = ${RATE_LIMIT_BY:-ip}
cost = ${RATE_LIMIT_COST:-1}
#
#--------------------------------------------------------------------------
# Storage Backend
#--------------------------------------------------------------------------
#
# Supported drivers: "redis", "memory"
#
storage {
driver = ${RATE_LIMIT_STORAGE:-redis}
redis {
host = ${REDIS_HOST:-127.0.0.1}
port = ${REDIS_PORT:-6379}
username = ${REDIS_USERNAME:-null}
password = ${REDIS_PASSWORD:-null}
database = ${REDIS_RATE_LIMIT_DB:-1}
timeout = ${REDIS_TIMEOUT:-2.0}
prefix = ${RATE_LIMIT_KEY_PREFIX:-ml_rl:}
}
}
#
#--------------------------------------------------------------------------
# Resilience — Fail-Open / Circuit Breaker
#--------------------------------------------------------------------------
#
resilience {
enabled = ${RATE_LIMIT_RESILIENCE:-true}
fail_open = ${RATE_LIMIT_FAIL_OPEN:-true}
fallback_driver = ${RATE_LIMIT_FALLBACK:-memory}
circuit_threshold = ${RATE_LIMIT_CIRCUIT_THRESHOLD:-3}
circuit_recovery_seconds = ${RATE_LIMIT_CIRCUIT_RECOVERY:-30}
}
#
#--------------------------------------------------------------------------
# IP Resolution
#--------------------------------------------------------------------------
#
ip {
trusted_proxies = ${RATE_LIMIT_TRUSTED_PROXIES:-null}
hash_ips = ${RATE_LIMIT_HASH_IPS:-false}
}
#
#--------------------------------------------------------------------------
# Named Limiters
#--------------------------------------------------------------------------
#
limiters {
api {
max_attempts = ${RATE_LIMIT_API_MAX:-100}
window_seconds = ${RATE_LIMIT_API_WINDOW:-60}
algorithm = token_bucket
by = ip
}
auth {
max_attempts = ${RATE_LIMIT_AUTH_MAX:-5}
window_seconds = ${RATE_LIMIT_AUTH_WINDOW:-300}
algorithm = sliding_window
by = ip
}
uploads {
max_attempts = ${RATE_LIMIT_UPLOADS_MAX:-10}
window_seconds = ${RATE_LIMIT_UPLOADS_WINDOW:-3600}
algorithm = token_bucket
by = user+route
cost = 5
}
}
#
#--------------------------------------------------------------------------
# Telemetry
#--------------------------------------------------------------------------
#
telemetry {
enabled = ${RATE_LIMIT_TELEMETRY:-true}
}
#
#--------------------------------------------------------------------------
# Response Customization
#--------------------------------------------------------------------------
#
response {
message = ${RATE_LIMIT_RESPONSE_MESSAGE:-Too many requests. Please try again later.}
include_headers = ${RATE_LIMIT_INCLUDE_HEADERS:-true}
}
}
Environment Variable Quick Reference
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_STORAGE |
redis |
Storage driver: redis or memory |
RATE_LIMIT_MAX_ATTEMPTS |
60 |
Default requests per window |
RATE_LIMIT_WINDOW_SECONDS |
60 |
Default window in seconds |
RATE_LIMIT_ALGORITHM |
token_bucket |
Default algorithm |
RATE_LIMIT_BY |
ip |
Default key strategy |
RATE_LIMIT_FAIL_OPEN |
true |
Allow traffic when Redis is down |
RATE_LIMIT_CIRCUIT_THRESHOLD |
3 |
Failures before circuit opens |
RATE_LIMIT_CIRCUIT_RECOVERY |
30 |
Seconds before retrying Redis |
REDIS_HOST |
127.0.0.1 |
Redis server host |
REDIS_PORT |
6379 |
Redis server port |
REDIS_RATE_LIMIT_DB |
1 |
Redis database for rate limit keys |
RATE_LIMIT_HASH_IPS |
false |
Hash IPs for GDPR compliance |
RATE_LIMIT_TELEMETRY |
true |
Enable telemetry counters |
Bootstrapping with the Provider
Automatic (Recommended)
The RateLimitProvider wires everything from MLC config in a single call:
use MonkeysLegion\Mlc\Loader; use MonkeysLegion\RateLimit\Provider\RateLimitProvider; // 1. Load the MLC config $config = $loader->loadOne('rate-limit'); // 2. Bootstrap the entire rate limit stack $services = RateLimitProvider::register( config: $config->get('rate_limit', []), logger: $logger, // PSR-3 logger ); // 3. Register the middleware in the router $router->registerMiddleware( 'rate-limit', $services['middleware'], priority: 100, ); $router->addGlobalMiddleware('rate-limit');
The Provider creates:
$services['storage']— The storage backend (Redis → ResilientStorage wrapper)$services['manager']—RateLimiterManagerwith named limiters from config$services['middleware']—RateLimitMiddlewareready for the router
DI Container Registration
// In your container definitions (e.g., di/definitions.php) use MonkeysLegion\RateLimit\Provider\RateLimitProvider; use MonkeysLegion\RateLimit\RateLimiterManager; use MonkeysLegion\RateLimit\Middleware\RateLimitMiddleware; return [ // Load config 'rate_limit.config' => fn(Config $config) => $config->get('rate_limit', []), // Bootstrap services 'rate_limit.services' => fn(array $config, LoggerInterface $logger) => RateLimitProvider::register(config: $config, logger: $logger), // Individual service bindings RateLimiterManager::class => fn(array $services) => $services['manager'], RateLimitMiddleware::class => fn(array $services) => $services['middleware'], ];
With Pre-existing Redis Connection
If your app already has a shared Redis connection (e.g., from the queue or cache package):
$services = RateLimitProvider::register( config: $config->get('rate_limit', []), logger: $logger, redis: $existingRedis, // Reuse connection — config host/port are ignored );
Manual Bootstrap (Without Provider)
use MonkeysLegion\RateLimit\RateLimiterManager; use MonkeysLegion\RateLimit\Middleware\RateLimitMiddleware; use MonkeysLegion\RateLimit\Storage\RedisStorage; use MonkeysLegion\RateLimit\Storage\ResilientStorage; use MonkeysLegion\RateLimit\Storage\InMemoryStorage; use MonkeysLegion\RateLimit\RateLimiter; // 1. Create Redis connection $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); $redis->select(1); // 2. Wrap with resilience $storage = new ResilientStorage( primary: new RedisStorage($redis, prefix: 'ml_rl:'), failOpen: true, fallback: new InMemoryStorage(), logger: $logger, ); // 3. Create manager and register named limiters $manager = new RateLimiterManager($storage); $manager->define('api', fn() => [ 'maxAttempts' => 100, 'windowSeconds' => 60, 'by' => 'ip', 'algorithm' => 'token_bucket', ]); $manager->define('auth', fn() => [ 'maxAttempts' => 5, 'windowSeconds' => 300, 'by' => 'ip', 'algorithm' => 'sliding_window', ]); // 4. Create middleware and register $middleware = new RateLimitMiddleware($manager); $router->registerMiddleware('rate-limit', $middleware, priority: 100); $router->addGlobalMiddleware('rate-limit'); // 5. Initialize facade for programmatic use RateLimiter::init($manager);
Quick Start — Using Rate Limits
Attribute-Based (Recommended)
use MonkeysLegion\RateLimit\Attributes\RateLimit; use MonkeysLegion\RateLimit\Algorithm; #[RoutePrefix('/api/v2/users')] final class UserController { // 60 requests per minute, per IP (token bucket) #[Route('GET', '/', name: 'users.index')] #[RateLimit(maxAttempts: 60, windowSeconds: 60)] public function index(): Response { /* ... */ } // 5 login attempts per 5 minutes, per IP (sliding window) #[Route('POST', '/login', name: 'users.login')] #[RateLimit(maxAttempts: 5, windowSeconds: 300, algorithm: Algorithm::SlidingWindow)] public function login(): Response { /* ... */ } // Stacked: global IP limit + per-user per-route limit #[Route('POST', '/', name: 'users.create')] #[RateLimit(maxAttempts: 1000, windowSeconds: 3600, by: 'ip')] #[RateLimit(maxAttempts: 10, windowSeconds: 60, by: 'user+route')] public function create(): Response { /* ... */ } // Reference a named limiter from config #[Route('GET', '/search', name: 'users.search')] #[RateLimit(limiter: 'api')] public function search(): Response { /* ... */ } }
Programmatic Usage (Facade)
use MonkeysLegion\RateLimit\RateLimiter; // After bootstrap (RateLimiter::init() was called by the Provider) $result = RateLimiter::attempt('login:192.168.1.1', maxAttempts: 5, windowSeconds: 300); if ($result->exceeded) { echo "Try again in {$result->retryAfter} seconds"; } // Check without consuming if (RateLimiter::tooManyAttempts('api:10.0.0.1', 100, 60)) { // Already at limit } // Clear all state for a key (e.g., after password reset) RateLimiter::clear('login:192.168.1.1');
From Attribute (Standalone)
Use fromAttribute() to evaluate rate limits from a #[RateLimit] attribute without the middleware:
use MonkeysLegion\RateLimit\Attributes\RateLimit; use MonkeysLegion\RateLimit\Algorithm; $attr = new RateLimit(maxAttempts: 100, windowSeconds: 60, by: 'ip+route'); $result = $manager->fromAttribute($attr, $request); if (!$result->allowed) { // Handle rate limit exceeded } // Works with named limiters too $attr = new RateLimit(limiter: 'api'); $result = $manager->fromAttribute($attr, $request);
Redis Unavailability — Resilient Storage
Neither Laravel nor Symfony handle this. Both throw 500 errors when Redis goes down. MonkeysLegion is the first PHP framework to ship a built-in resilient rate limiter.
The Problem
| Framework | When Redis is Down |
|---|---|
| Laravel | ThrottleRequests throws ConnectionException → 500 error |
| Symfony | Rate Limiter throws exception → 500 error |
| MonkeysLegion | Configurable: fail-open, fail-closed, or fallback — your API keeps working |
Three Modes of Operation
| Mode | MLC Config | Behavior When Redis Is Down |
|---|---|---|
| Fail-Open | fail_open = true |
All requests allowed — no rate limiting |
| Fail-Open + Fallback | fail_open = true, fallback_driver = memory |
Per-process rate limiting (no cross-worker consistency) |
| Fail-Closed | fail_open = false |
All requests denied with 429 — maximum security |
Circuit Breaker Pattern
CLOSED (normal) → 3 consecutive failures → OPEN (skip Redis)
↓
30 seconds elapse
↓
HALF-OPEN (probe Redis)
↓
Success → CLOSED | Failure → OPEN
Development Without Redis
# .env RATE_LIMIT_STORAGE=memory
Or in the MLC file:
storage {
driver = memory
}
Note:
memorydriver is single-process. Rate limits are not shared across workers.
Router Integration
The rate limiter hooks into the router via the #[RateLimit] attribute system:
Controller → ControllerScanner → Route Meta → Router Dispatch → Request Attribute → Middleware
How It Works
ControllerScannerscans for#[RateLimit]attributes on controller classes and methods- Rate limit configs are stored in
meta['rate_limits']on eachRouteDefinition Router::dispatch()attaches_rate_limitsas a PSR-7 request attributeRateLimitMiddlewarereads_rate_limits, resolves keys, evaluates limits, returns 429 or adds headers
Backwards Compatibility
The existing #[Throttle] attribute continues to work — the scanner bridges it automatically:
// Old way — still works #[Throttle(max: 60, per: 60, by: 'ip')] // New way — more features #[RateLimit(maxAttempts: 60, windowSeconds: 60, by: 'ip', algorithm: Algorithm::TokenBucket)]
Competitive Comparison
| Feature | MonkeysLegion | Laravel 13 | Symfony 7 |
|---|---|---|---|
| Token Bucket | ✅ | ❌ (fixed window) | ✅ |
| Sliding Window | ✅ | ❌ (fixed window) | ✅ |
| Redis Lua Atomic | ✅ | ✅ (Redis driver) | ❌ (uses locks) |
| Fail-Open Mode | ✅ | ❌ (500 error) | ❌ (500 error) |
| Circuit Breaker | ✅ | ❌ | ❌ |
| Fallback Storage | ✅ | ❌ | ❌ |
| MLC Config | ✅ | YAML/PHP | YAML |
| Composite Keys | ✅ (ip+route) |
❌ (manual) | ❌ |
| Stacked Limits | ✅ (repeatable attr) | ✅ | ✅ |
| PHP 8.4 Hooks | ✅ | ❌ | ❌ |
| PSR-15 Standard | ✅ | ❌ (custom) | ❌ (custom) |
| Named Limiters | ✅ | ✅ | ✅ |
| IP Hashing (GDPR) | ✅ | ❌ | ❌ |
Algorithms
Token Bucket
Best for APIs that allow burst traffic while maintaining a steady average rate.
- Capacity =
maxAttempts(burst size) - Refill rate =
maxAttempts / windowSecondstokens per second - Each request consumes
costtokens (default: 1) - Tokens refill continuously based on elapsed time
Sliding Window
Best for strict rate limiting with precise request counting.
- Tracks each request timestamp in a rolling window
- No burst allowance — exactly
maxAttemptsperwindowSeconds - Uses Redis sorted sets for O(log n) operations
Key Resolvers
| Resolver | Description |
|---|---|
ip |
Client IP (with trusted proxy support) |
user |
Authenticated user ID (falls back to IP) |
route |
Route pattern (e.g., /api/users/{id}) |
ip+route |
Per-IP per-route composite |
user+route |
Per-user per-route composite |
Response Headers
Follows RFC 6585 + draft-ietf-httpapi-ratelimit-headers.
On successful requests:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1716350400
X-RateLimit-Policy: 60;w=60
On rate-limited requests (429):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716350400
X-RateLimit-Policy: 60;w=60
Retry-After: 30
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the window |
X-RateLimit-Remaining |
Requests remaining in the current window |
X-RateLimit-Reset |
Unix timestamp when the window resets |
X-RateLimit-Policy |
Limit and window in {limit};w={seconds} format (draft spec) |
Retry-After |
Seconds until next request is allowed (only on 429) |
Telemetry Metrics
When telemetry is enabled, the middleware emits the following metrics via the callback:
| Metric | Type | Labels |
|---|---|---|
ml_rate_limit_allowed_total |
Counter | resolver, algorithm |
ml_rate_limit_denied_total |
Counter | resolver, algorithm |
ml_rate_limit_remaining |
Gauge | resolver, algorithm, remaining, limit |
// Custom telemetry callback example $middleware = new RateLimitMiddleware($manager, metricsCallback: function ( string $metricName, array $labels, ): void { // Emit to Prometheus, StatsD, or any monitoring backend Telemetry::counter($metricName, $labels); });
Requirements
- PHP ^8.4
- ext-redis (recommended for production)
- PSR-7 HTTP Message ^2.0
- PSR-15 HTTP Server Middleware ^1.0
License
MIT License — see LICENSE for details.