perimeter/rate-limiter-php
A lightweight PHP rate-limiting library from Perimeter for controlling request throughput across APIs and endpoints. Define limits and time windows to prevent abuse, smooth traffic spikes, and protect application resources with a straightforward, consistent API.
This is a 2015-era, single-purpose library (17 stars, last tagged release April 2015). Treat it as a small utility you vendor in, not as a framework component. There is no Laravel service provider, facade, config file, or middleware — anything Laravel-shaped you will write yourself.
Install and pin
composer require perimeter/rate-limiter-php:^0.1
Check composer.json of the package before you commit. A 2015 package commonly declares php: >=5.4 with no upper bound and no illuminate/* constraints; on PHP 8.x you may hit deprecation notices or a hard composer conflict. If so, resolve it explicitly rather than with --ignore-platform-reqs in CI:
"config": { "platform": { "php": "8.2.0" } }
Where to look first
vendor/perimeter/rate-limiter-php/src/ — find the two or three classes that matter: a limiter/rate-limiter class and a storage/driver abstraction (StorageInterface, Adapter, or similar). Everything else is likely a driver.composer.json — the autoload namespace, the PHP constraint, and whether it pulls in a Redis/Memcached client or expects you to inject one.tests/ — the clearest documentation of the intended call order. If tests are absent, read the driver code to understand the increment/expiry sequence.First use case
Do not use this to replace Laravel's throttle middleware. Use it where Laravel's built-in limiter is awkward: throttling outbound calls to a third-party API from queued jobs, or a per-tenant budget that spans multiple routes. The typical shape is:
use Perimeter\RateLimiter\RateLimiter;
use Perimeter\RateLimiter\Storage\RedisStorage;
$limiter = new RateLimiter(new RedisStorage($redis), limit: 60, window: 60);
if (! $limiter->attempt('stripe:account:'.$accountId)) {
// over budget
}
Verify the actual method names (attempt, check, hit, remaining, reset) and argument order against the source before building on them — this package predates any convention here, and attempt() in particular is not guaranteed to increment and check atomically.
Bind it once, in a service provider
$this->app->singleton(RateLimiter::class, function ($app) {
return new RateLimiter(
new RedisStorage($app['redis']->connection('limiter')),
60,
60
);
});
Keep the storage connection separate from your cache connection. Rate-limiter keys are high-churn and should not share an eviction policy with application cache data.
Centralize key construction
The library has no opinion about keys, which means every call site will invent its own unless you stop it. One helper, one format:
final class RateKeys
{
public static function api(string $provider, string $tenant): string
{
return sprintf('rl:%s:%s:%s', $provider, $tenant, now()->format('YmdHi'));
}
}
The timestamp suffix only makes sense for a fixed-window limiter. Including it manually is a workaround for a limiter that doesn't expire keys — confirm whether yours does before adopting it.
Middleware adapter (hand-rolled)
final class ThirdPartyThrottle
{
public function __construct(private RateLimiter $limiter) {}
public function handle($request, Closure $next, string $bucket, int $limit = 60)
{
$key = $bucket.':'.($request->user()?->id ?? $request->ip());
if (! $this->limiter->attempt($key, $limit)) {
return response()->json(['message' => 'Too many requests'], 429)
->header('Retry-After', 60);
}
return $next($request);
}
}
Register it as a route middleware alias. Note that Retry-After is a guess unless the limiter exposes a TTL/remaining() method — many limiters of this vintage only return a boolean.
Throttle queued jobs, not requests
This is the strongest fit. In an outbound-API job, check before dispatching the HTTP call and release with backoff when over budget:
public function handle(RateLimiter $limiter)
{
if (! $limiter->attempt('crm:'.$this->tenantId)) {
$this->release(30);
return;
}
$this->callExternalApi();
}
release() is cheaper than sleeping and survives worker restarts. Never use sleep() inside a job worker to wait out a rate limit — it occupies a worker slot for the whole window.
Test against an in-memory driver
If the package ships a memory/array driver, use it in unit tests and bind the Redis driver only in AppServiceProvider when not testing. If it doesn't ship one, write a tiny fake implementing the storage interface; it will be a handful of methods and is less work than mocking Redis.
Coexistence rule
If you already use RateLimiter::for('api', ...) from Laravel, keep them in separate namespaces and separate concerns. Two limiters counting the same logical action with different windows is the fastest way to produce unexplainable 429s in production.
Assume it is unmaintained. Last release 2015 means no PHP 8 fixes, no declare(strict_types=1), no PSR-18/PSR-17 anything. Vendor it deliberately: pin the version, commit composer.lock, and be prepared to fork. If a PHP 8.4 deprecation warning breaks a strict error handler, patch the fork rather than suppressing E_DEPRECATED globally.
Check the atomicity of the increment. The single most common bug in hand-rolled limiters is GET, compare, then INCR — two requests in the gap both pass. In Redis this should be one MULTI block or a Lua script; a check() followed by a separate hit() is not the same operation. If the package exposes separate check and hit methods, prefer any combined attempt-style call, and if none exists, drive Redis yourself:
$count = $redis->eval(<<<'LUA'
local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return c
LUA, 1, $key, $window);
Fixed windows leak at the boundary. A 60-per-minute counter keyed to the wall-clock minute permits 120 requests in a two-second span straddling the boundary. For third-party APIs with strict contracts this matters. If the package offers only fixed windows, either offset the window per tenant or accept the burst knowingly.
Keys without TTL grow forever. Confirm the driver sets an expiry on first increment. If the limiter is keyed by tenant and by timestamp string, you own the cleanup — a Redis maxmemory-policy of allkeys-lru will do it, but on the same instance it will also evict your sessions.
Clocks are hard-coded. Most 2015 implementations call time() directly. That makes window-boundary tests impossible to write deterministically. If there is no clock injection point, wrap the limiter in your own class that accepts a Closure clock, or accept writing tests that actually sleep for a second.
Redis is required for correctness under load. A file- or array-backed store is fine for a single-process CLI tool and wrong for anything with php-fpm workers or Horizon. Two application servers with local storage means the effective limit is N × limit.
Debugging: read the keys directly.
redis-cli --scan --pattern 'rl:*' | head -20
redis-cli TTL rl:stripe:acme
If keys are missing, the limiter is silently falling through (swallowed exception, wrong connection, or a driver that returns true on error). If keys are present with TTL -1, expiry is broken — that's your leak.
Error handling defaults. Decide explicitly whether a storage outage should fail open (allow the request) or fail closed (429 everything). Most libraries this old throw the driver exception straight through, which in Laravel means a 500 on the third-party path. Wrap the call:
try {
$allowed = $limiter->attempt($key);
} catch (\Throwable $e) {
report($e);
$allowed = true; // fail open, but log loudly
}
Extension points to reach for first. The storage interface (swap Redis for Memcached or a database table) and the key namespace. Avoid subclassing the limiter itself; the API surface is small enough that composition — a TenantRateLimiter that owns an instance — keeps you portable when you eventually replace the package.
Migration path. Laravel's Illuminate\Cache\RateLimiter does everything this package does, is atomic on Redis, has attempt, tooManyAttempts, availableIn, and integrates with throttle: middleware and RateLimiter::for(). Keep this package only where it is already load-bearing, and do not adopt it for new features — budget an afternoon to port each call site to the framework limiter and then drop the dependency entirely.
How can I help you explore Laravel packages today?