A small PHP class for Redis-based token bucket rate limiting and concurrency limiting.
SimpleRateLimiter protects PHP endpoints with two Redis-backed controls:
- Rate limit: limits accepted requests with token buckets.
- Concurrency limit: limits how many requests can run at the same time.
Both controls support one global rule for the protected action plus multiple identity rules, such as IP addresses, usernames, tenants, sessions, and API keys. By default, the class applies only global limits.
- PHP 5.6 or higher
- Redis server
- PHP Redis extension
Install Redis and the PHP Redis extension for your platform.
On Debian/Ubuntu:
sudo apt-get update
sudo apt-get install redis-server php-redisRestart your web server or PHP-FPM after installing the extension.
Add SimpleRateLimiter.php to your project and include it where needed:
require_once __DIR__ . '/SimpleRateLimiter.php';With no options, new SimpleRateLimiter() uses these limits:
- Rate global: bucket capacity
1000, refilled over60seconds. - Concurrency global:
100simultaneous requests. - Redis:
127.0.0.1:6379, timeout1.0second. - Name:
$_SERVER['SCRIPT_FILENAME'], or the class file when unavailable.
The default concurrency TTL is 60 seconds. It is a safety net for interrupted requests.
The constructor accepts either no argument or one options array. Positional arguments are not supported.
Use a stable name for each protected action and a project-specific key_prefix when Redis is shared with other applications. If your application is behind a reverse proxy, pass an IP address that your application has already resolved from a trusted proxy configuration. Do not trust X-Forwarded-For directly from the public internet.
Use release() whenever concurrency limiting is enabled. If only rate limiting is enabled, release() is not needed.
For a single protected page or a controlled block, use try / finally:
if (!$limiter->acquire()) {
http_response_code(429);
exit('Too many requests.');
}
try {
require __DIR__ . '/protected-page.php';
} finally {
$limiter->release();
}For a whole site loaded through auto_prepend_file, .user.ini, .htaccess, or a shared bootstrap include, register the release once and let PHP call it at shutdown:
if (!$limiter->acquire()) {
http_response_code(429);
exit('Too many requests.');
}
register_shutdown_function(function () use ($limiter) {
try {
$limiter->release();
} catch (Exception $e) {
error_log('SimpleRateLimiter release failed: ' . $e->getMessage());
}
});Keep name for the protected action, such as login or api:v1:orders. Use global options for action-wide limits and rule-level scope + key values for dynamic identities such as IPs, usernames, or tenant IDs.
Production-oriented examples are in the examples/ directory:
examples/protected-endpoint.php: rate limit and concurrency limit, both per-IP and global.examples/rate-limit-only.php: token bucket rate limit without concurrency slots.examples/concurrency-limit-only.php: concurrency slots without rate counters.examples/custom-scopes.php: limits by username, IP, tenant, and global rules.examples/auto-prepend-bootstrap.php: site-wide bootstrap forauto_prepend_fileor shared includes.examples/debug-status-snapshot.php: CLI-only diagnostic example showing onedebugStatus()snapshot.examples/wordpress-front-controller.php: front-controller pattern for a WordPress-styleindex.php.
For WordPress-style usage, rename the existing index.php to index-original.php, put the limiter wrapper in its place, and adjust the two require paths to match your installation.
$limiter = new SimpleRateLimiter(array(
'name' => 'admin:login',
'global_rate_limit' => array('limit' => 200, 'window' => 60),
'rate_limits' => array(
'ip' => array('scope' => 'ip', 'key' => $_SERVER['REMOTE_ADDR'], 'limit' => 10, 'window' => 60),
),
'global_concurrency_limit' => array('max_concurrent' => 20, 'ttl' => 60),
'concurrency_limits' => array(
'ip' => array('scope' => 'ip', 'key' => $_SERVER['REMOTE_ADDR'], 'max_concurrent' => 2, 'ttl' => 60),
),
'redis_host' => '127.0.0.1',
'redis_port' => 6379,
'redis_auth' => getenv('REDIS_PASSWORD'),
'redis_database' => 0,
));name(string): Stable identifier for the protected action. Defaults to the current script filename.key_prefix(string): Redis key prefix. Default:simple_rate_limiter.
Keep name and rule names stable after deployment. Identity rule names, such as ip, user, or tenant, are part of the Redis key namespace. Renaming a rule starts a new bucket or slot counter even if scope and key stay the same.
enable_rate_limit(bool): Enable request rate limiting. Default:true.global_rate_limit(array|false): Action-wide rate rule. Default:array('limit' => 1000, 'window' => 60). Passfalseto disable it. Ifwindowis omitted,60seconds is used.rate_limits(array): Optional list of identity rate rules.rate_limit(int): Fallback identity rate limit when arate_limitsrule omitslimit. Default:120.rate_window(int): Fallback identity refill period when arate_limitsrule omitswindow. Default:60.
Each rate_limits rule accepts:
scope: Identity label such asip,user,tenant,session, orapi_key.key: Identity value, such as$_SERVER['REMOTE_ADDR'],$username, or$tenantId.limit: Bucket capacity and maximum immediate burst.window: Refill period in seconds. The bucket refills from empty to full over this period.
The rate limiter uses token buckets stored in Redis. limit is the bucket capacity and window is the refill period. For example, limit => 10 and window => 60 allows a burst of up to 10 requests, then refills at 10 tokens per minute. The global rule and all identity rules are checked atomically. A token is consumed only when every rule allows the request.
Concrete rate rule example:
'global_rate_limit' => array('limit' => 5000, 'window' => 60),
'rate_limits' => array(
'ip' => array('scope' => 'ip', 'key' => $_SERVER['REMOTE_ADDR'], 'limit' => 30, 'window' => 60),
'user' => array('scope' => 'user', 'key' => $username, 'limit' => 5, 'window' => 300),
'tenant' => array('scope' => 'tenant', 'key' => $tenantId, 'limit' => 500, 'window' => 60),
)enable_concurrency_limit(bool): Enable concurrent request limiting. Default:true.global_concurrency_limit(array|false): Action-wide concurrency rule. Default:array('max_concurrent' => 100, 'ttl' => 60). Passfalseto disable it. Ifttlis omitted,60seconds is used.concurrency_limits(array): Optional list of identity concurrency rules.max_concurrent(int): Fallback identity concurrency limit when aconcurrency_limitsrule omitsmax_concurrent. Default:10.concurrency_ttl(int): Fallback identity safety TTL when aconcurrency_limitsrule omitsttl. Default:60.
Each concurrency_limits rule accepts:
scope: Identity label such asip,user,tenant,session, orapi_key.key: Identity value, such as$_SERVER['REMOTE_ADDR'],$username, or$tenantId.max_concurrent: Maximum simultaneous requests.ttl: Safety TTL in seconds.
Always release concurrency slots after a successful acquire(), either in a finally block or through register_shutdown_function() when using a site-wide bootstrap. The TTL is only a safety net for fatal errors and interrupted requests. Set it higher than the maximum expected runtime of the protected action; if a request runs longer than its TTL, Redis can expire the slot before release() runs.
Concrete concurrency rule example:
'global_concurrency_limit' => array('max_concurrent' => 200, 'ttl' => 60),
'concurrency_limits' => array(
'ip' => array('scope' => 'ip', 'key' => $_SERVER['REMOTE_ADDR'], 'max_concurrent' => 10, 'ttl' => 60),
'user' => array('scope' => 'user', 'key' => $username, 'max_concurrent' => 1, 'ttl' => 60),
'tenant' => array('scope' => 'tenant', 'key' => $tenantId, 'max_concurrent' => 50, 'ttl' => 60),
)With no identity rules, the class creates only the global rate and concurrency rules. To disable those global rules:
new SimpleRateLimiter(array(
'global_rate_limit' => false,
'global_concurrency_limit' => false,
));Use explicit global_rate_limit, global_concurrency_limit, rate_limits, and concurrency_limits for production configuration.
Unknown top-level options and unknown rule options are rejected. This helps catch configuration typos before the limiter silently falls back to defaults.
use_delay(bool): Add a delay as concurrency usage increases. Default:false.min_delay(float): Minimum delay in seconds. Default:0.max_delay(float): Maximum delay in seconds. Default:0.5.
Delay is applied only after all enabled limits have accepted the request. It throttles accepted requests; it does not retry rejected requests or turn a future 429 into a delayed success.
For public HTTP endpoints, returning 429 Too Many Requests is usually preferable to delaying requests.
redis_host(string): Redis host. Default:127.0.0.1.redis_port(int): Redis port. Default:6379.redis_timeout(float): Connection timeout in seconds. Default:1.0.redis_auth(string): Optional Redis password.redis_database(int): Optional Redis database index.redis(object): Optional existing Redis-compatible client.
Returns true when the request is allowed. Returns false when any enabled rule is exceeded.
Releases concurrency slots acquired by the current instance. It is safe to call when concurrency limiting is disabled or when no concurrency slot was acquired.
Returns a structured snapshot of the limiter state, including identity, rate tokens, concurrency rules, rule identities, TTL values, Redis keys, and delay settings. This is intended for logs and internal diagnostics; do not expose it directly in public HTTP responses. The payload includes Redis key names, which should remain server-side.
Rate token values in debugStatus() are diagnostic snapshots. The limiter uses Redis time inside atomic Lua scripts; the debug output estimates the displayed token count at read time and can differ slightly from the next acquire() result.
The scripts/ directory contains internal QA tooling for this repository. It is not production example code and is excluded from exported archives through .gitattributes.
Run the CLI checks with:
php scripts/test-limits.phpThe script requires PHP, Redis, and the PHP Redis extension. HTTP integration helpers also live in scripts/ for local and remote verification during development.
The HTTP test endpoint is locked by default. Set SIMPLE_RATE_LIMITER_TEST_TOKEN on the server and pass the same token to the client:
php scripts/test-http-client.php https://example.com/http-test-endpoint.php --token=change-meIf you find this project useful and would like to support its development, consider making a donation. Any contribution is greatly appreciated!
Bitcoin (BTC) Addresses:
- 1LToggiof3rNUTCemJZSsxd1qubTYoSde6
- 3LToggio7Xx8qMsjCFfiarV4U2ZR9iU9ob
SimpleRateLimiter library is licensed under the Apache License, Version 2.0. You are free to use, modify, and distribute the library in compliance with the license.
Copyright (C) 2024-2026 Luca Soltoggio - https://www.lucasoltoggio.it/