wolfcode/rate-limiting
A Laravel package for adding rate limiting to your application. It provides configurable limiters and middleware to throttle requests, helping protect endpoints from abuse and excessive traffic while keeping behavior predictable and easy to tune.
Install and confirm the surface area
composer require wolfcode/rate-limiting
With zero stars and no dependents, treat the repo itself as the documentation. Before writing any integration code, spend ten minutes in three places:
composer.json — check the PHP constraint and whether it requires PSR-15/PSR-7 middleware interfaces, a Laravel service provider, or is framework-agnostic. This single fact determines your whole integration shape.src/ — look for the top-level contracts (LimiterInterface, StorageInterface, RateLimitResult/Limit value objects). These are what you inject; everything else is an implementation detail.tests/ — the tests are the real API spec. Search for ->attempt(, ->hit(, ->allow(, remaining, retryAfter to learn the naming the author actually uses.First use case: throttle a login endpoint
The fastest value is brute-force protection, because it needs no negotiation about quotas.
use Wolfcode\RateLimiting\Limiter;
use Wolfcode\RateLimiting\Storage\RedisStorage;
$limiter = new Limiter(new RedisStorage($redis));
// key by IP + submitted username so one attacker can't lock out everyone
$result = $limiter->attempt("login:{$ip}:{$username}", maxAttempts: 5, decaySeconds: 300);
if (! $result->allowed()) {
return response()->json(['message' => 'Too many attempts'], 429)
->header('Retry-After', $result->retryAfter());
}
If attempt() doesn't exist under that name, hit() + remaining() is the conventional fallback. Either way, the shape you want is: key → consume → inspect result → emit Retry-After.
Pick your storage immediately. An in-memory/array store is fine for tests and worthless in production (each PHP-FPM worker has its own memory). If the package ships Redis and file drivers, choose Redis for anything multi-process — atomic increments are the whole ballgame in rate limiting.
Framework shortcut. If it ships a Laravel service provider, don't hand-construct limiter objects; resolve from the container and register a named limiter in a service provider so routes can reference it by string. That keeps config in one place.
1. Middleware for route-level quotas
The dominant day-to-day pattern is a thin middleware that derives a key, runs the check, and decorates the response.
final class ThrottleApiKey
{
public function __construct(private Limiter $limiter) {}
public function handle($request, Closure $next, string $bucket = 'api', int $max = 60, int $window = 60)
{
$key = $bucket . ':' . ($request->header('X-Api-Key') ?? $request->ip());
$result = $this->limiter->attempt($key, $max, $window);
$response = $result->allowed()
? $next($request)
: response()->json(['message' => 'Rate limit exceeded'], 429);
return $response
->header('X-RateLimit-Limit', $max)
->header('X-RateLimit-Remaining', $result->remaining())
->header('X-RateLimit-Reset', $result->resetAt());
}
}
Returning headers on both allowed and denied paths matters — clients that only learn about the limit at the moment of failure will hammer your endpoint, and your support queue fills with "API randomly broke."
2. Tiered limits per plan
Almost every real rate-limiting requirement is a matrix, not a number. Resolve the tuple from the authenticated principal before calling the limiter:
$tiers = [
'free' => ['per_minute' => 20, 'per_day' => 1_000],
'pro' => ['per_minute' => 200, 'per_day' => 50_000],
'enterprise' => ['per_minute' => 2000,'per_day' => 500_000],
];
$tier = $user->plan ?? 'free';
$limits = $tiers[$tier];
foreach ($limits as $unit => [$max, $window]) { // e.g. per_minute => [20, 60]
if (! $limiter->attempt("api:{$unit}:{$user->id}", $max, $window)->allowed()) {
return $this->tooManyRequests($unit);
}
}
Cost note: this is N storage round-trips per request. For high-traffic tiers, collapse to a single Lua script or accept the latency — but measure before you optimize, because a Redis round-trip is ~0.2 ms and the two-limit check is usually fine.
3. Multi-cost operations (the most-missed pattern)
Standard rate limiting counts requests. Your users care about work. An endpoint that accepts ?limit=1000 on a search API should consume 1000 tokens, not 1.
$cost = min($request->integer('limit', 25), 1000);
$result = $limiter->attempt("search:{$user->id}", maxAttempts: 10_000, decaySeconds: 3600, cost: $cost);
Not every implementation supports a cost/weight argument. Check the signature early — if it doesn't, you either fork a small subclass or keep a separate "units consumed" counter and check it manually. Discover this on day one, not in month three.
4. Fail-open vs. fail-closed as an explicit decision
Rate limiting sits on the critical path of every request. If Redis is down, do you stop serving traffic or stop protecting it? Make this a config flag, not an accident:
try {
$result = $this->limiter->attempt($key, $max, $window);
} catch (StorageException $e) {
report($e);
$result = config('rate-limiting.fail_open')
? RateLimitResult::allowAll()
: RateLimitResult::denyAll(30);
}
Default to fail-open for public read endpoints, fail-closed for auth and payment endpoints. Write this down in the README of your app, because the next developer will otherwise "fix" the try/catch.
5. Key design is the actual architecture
Everything above is boilerplate; the key is where the judgment lives. Rules of thumb:
$request->ip() is the balancer. Use X-Forwarded-For only if your edge is trusted and configured to strip client-supplied headers.prod:api:user:1234. Shared Redis instances are guaranteed to collide eventually.(ip, identifier) and identifier separately — one stops enumeration, the other stops a distributed attack on one account.6. Testing with a fake clock
Any windowing algorithm is time-dependent, so tests that sleep are flaky and slow. If the package accepts a clock/Psr\Clock\ClockInterface or a "now" callback, use it. If it doesn't, wrap time access behind your own interface and inject a frozen clock in tests. Assert on the three states explicitly: under limit, at limit, after window expiry.
Fixed-window burst at the boundary. A naive fixed window lets a caller spend the full quota at t=59s and again at t=61s — 2× the intended rate in a two-second span. If the package only offers fixed window and your traffic is bursty, either accept it or move to a sliding-window/token-bucket driver. Check which algorithms are actually implemented before you promise a customer a "60 requests per minute" SLA.
INCR and EXPIRE are two commands, not one. A common bug in homegrown PHP limiters: increment succeeds, process dies, key never expires, and that user is locked out forever. Look for a Lua script or SET ... EX semantics in the storage driver. If the driver is naive, you may need to verify TTLs are set on every call, not just the first. TTL key returning -1 during a manual smoke test is the tell.
TTL granularity and window sizing. Redis TTLs are second-granular; sub-second windows (e.g. 10 req / 500 ms) will round in ways you didn't plan. Keep windows at ≥1 second unless the driver explicitly documents sub-second support.
Header correctness. X-RateLimit-Reset should be an absolute epoch timestamp by convention (some clients expect delta seconds — pick one and document it). Retry-After is defined in seconds. Mixing these up causes retry storms that look like a rate-limiter bug but are a client-integration bug.
429 is not enough — add jitter guidance. If you return a flat Retry-After: 60 to a thousand clients, they all retry at second 60. Recommend clients add random jitter, or stagger Retry-After slightly. This is the difference between a limiter that protects you and one that manufactures thundering herds.
Cache/config quirks. If there's a published config, the values are almost certainly cached in production (php artisan config:cache). Changing a limit in .env and not seeing behavior change is the single most common "the package is broken" report — always clear the config cache after tuning limits.
Zero-star dependency risk. With no dependents and a single maintainer, pin the version ("wolfcode/rate-limiting": "1.2.*" or a commit hash) and keep a two-hour exit ramp in mind: the interface you depend on is essentially attempt($key, $max, $window) → {allowed, remaining, retryAfter}. Write your own tiny wrapper around that contract so a replacement (or Laravel's built-in RateLimiter, which covers most of this for free) is a one-file swap rather than a grep across the codebase.
Extension points to look for. The valuable seams are (a) a custom StorageInterface implementation — lets you plug DynamoDB, Memcached, or a database table; (b) a key-resolver/strategy hook — lets you centralize key derivation; (c) a "response callback" — lets you standardize 429 bodies. If the package exposes none of these, subclassing or wrapping is the only route, which is a strong argument for the wrapper pattern above.
Log, don't just block. Emit a metric or log line on every denial with the key, bucket, and limit. Rate limiting without observability means you discover you set per_minute to 20 instead of 200 when a customer calls, not when you deploy. A counter like rate_limit.denied{bucket=api,tier=free} pays for itself in one incident.
How can I help you explore Laravel packages today?