beyondcode/laravel-fixed-window-limiter
A Laravel rate limiter using fixed time windows instead of the default sliding windows, so limits reset predictably. Useful for API throttling where you need strict, evenly spaced counters per window.
Read the source before you install anything. This package is archived (last release 1.2.0, January 2020, 67 stars, zero dependents). It is a ~200-line abstraction over a cache store, so "evaluating" it means reading two files, not reading docs:
src/FixedWindowLimiter.php — the increment path. Look specifically for whether the counter write and the expiry are issued as a single atomic operation (Lua EVAL, MULTI/EXEC, or a SET ... EX NX-style primitive) or as INCR followed by EXPIRE. This is the single most important detail in the whole package; see Gotchas.src/FixedWindowLimiterServiceProvider.php — whether the limiter is bound as a singleton and whether it's config('cache.default')-driven or takes a store name.composer.json — the illuminate/* and php constraints. On Laravel 10–12 / PHP 8.1+ you will almost certainly be fighting these.Install (expect friction on anything modern):
composer require beyondcode/laravel-fixed-window-limiter
# If constraints block you, that's your first signal — see Gotchas.
First use case: protect an outbound third-party call. This is the one scenario where the package's semantics are genuinely better than nothing and easy to justify — the counter is cheap, reset time is explainable, and nobody's SLA depends on the burst edge.
use BeyondCode\FixedWindowLimiter\FixedWindowLimiter;
class SyncInvoicesToPartner
{
public function __construct(private FixedWindowLimiter $limiter) {}
public function handle(int $tenantId): void
{
$key = "partner:invoices:{$tenantId}";
// Shape: (key, maxAttempts, windowInSeconds). Confirm exact
// parameter names/order in the source before wiring it in.
if (! $this->limiter->attempt($key, 100, 60)) {
$this->release($this->retryAfter($key, 60));
return;
}
// ... call the partner API
}
private function retryAfter(string $key, int $window): int
{
// getCreationDate() was added in 1.2.0 — pin ^1.2 if you rely on it.
$createdAt = $this->limiter->getCreationDate($key);
return max(0, ($createdAt + $window) - now()->getTimestamp());
}
}
Second use case: an HTTP middleware on an internal/expensive endpoint (report exports, admin bulk actions, webhook ingest). Register an alias and attach it per-route rather than globally, so you don't accidentally throttle the whole app while you're learning the failure modes.
Do not start with login, OTP, payments, or anything customer-facing. Those are exactly the surfaces where an unmaintained dependency plus a boundary burst is a support-ticket generator.
Key construction is your job, not the package's. Every call needs a stable, low-cardinality-per-identity key. Build it in one place so you can audit it:
final class LimitKeys
{
public static function forUser(Request $request, string $bucket): string
{
$id = $request->user()?->getAuthIdentifier() ?? $request->ip();
return "fw:{$bucket}:{$id}";
}
}
Never interpolate raw user input (emails, arbitrary header values) into the key — Redis key cardinality is unbounded and you'll fill memory with one-shot keys that never get read again.
Wall-clock aligned windows. If you actually need "resets at :00" (mirroring a partner's advertised quota), the TTL-based window won't give it to you — the window starts at the identity's first hit. Add the bucket to the key yourself:
$bucket = intdiv(now()->getTimestamp(), 60); // changes on the minute
$key = "partner:invoices:{$tenantId}:{$bucket}";
Now every identity rolls over at the same instant, and your X-RateLimit-Reset header is truthful.
Response headers. The pattern that makes fixed windows pay off — clients can self-pace instead of hammering into 429s:
class FixedWindowThrottle
{
public function __construct(private FixedWindowLimiter $limiter) {}
public function handle(Request $request, Closure $next, int $limit = 60, int $window = 60)
{
$key = LimitKeys::forUser($request, 'route:'.$request->route()?->getName());
if (! $this->limiter->attempt($key, $limit, $window)) {
$retryAfter = max(0, $this->limiter->getCreationDate($key) + $window - now()->getTimestamp());
return response('Too Many Requests', 429)
->header('Retry-After', $retryAfter)
->header('X-RateLimit-Limit', $limit)
->header('X-RateLimit-Remaining', 0)
->header('X-RateLimit-Reset', now()->addSeconds($retryAfter)->getTimestamp());
}
$response = $next($request);
return $response
->header('X-RateLimit-Limit', $limit)
->header('X-RateLimit-Remaining', /* limit - hits, if the package exposes it */ 0);
}
}
Fail mode: decide it explicitly, in your code, not the package's. Wrap calls where availability matters more than the limit (internal endpoints) and where it doesn't (outbound partner protection — you'd rather reject and retry than get a 429 from Stripe):
try {
$allowed = $this->limiter->attempt($key, $limit, $window);
} catch (Throwable $e) {
report($e);
$allowed = false; // fail closed for outbound quota protection
}
Testing. Use a real Redis (or the database cache store) in CI. The array store gives you per-request increments only, so a test that "proves" the limit works under array proves nothing about atomicity or TTL behaviour. Write three tests minimum: (a) limit+1 is rejected, (b) the key expires after the window, (c) a key created mid-window behaves as expected at the boundary.
Composition. Use exactly one limiter per route. Running native throttle:60,1 and this middleware double-counts and produces 429s at half your configured limit — a bug that looks like a bug in the package.
The boundary burst is real and it's the documented tradeoff. Fixed windows allow up to ~2× the limit across the reset edge (100 at :59 plus 100 at :00 is 200 in two seconds). If any product surface is fairness-sensitive or billing-adjacent, this package is the wrong model — use a sliding window or token bucket, not a bigger number here.
Verify the increment/expiry atomicity, or own the consequence. A non-atomic INCR + EXPIRE where the process dies in between leaves a key with no TTL — that identity is throttled forever, and the symptom is a support ticket weeks later, not an alert. If the source isn't atomic, either wrap it in your own Lua (INCR + conditional PEXPIRE) or run a nightly janitor that scans your fw:* keyspace and fixes TTL == -1. Do not ship without one of those.
Pin the version hard. getCreationDate() only exists from 1.2.0. Anything looser than "beyondcode/laravel-fixed-window-limiter": "1.2.0" risks a composer update on a transitive dep resolving you into a version without the method — and there is no upstream to fix it.
Constraints will fight you on modern Laravel. If composer require fails on illuminate/* or php, your options are --ignore-platform-reqs (don't, for production), a Composer replace/alias, or a fork with widened constraints. Every one of those converts "install a package" into "own a code artifact." If you go there: vendor it into App\Support\RateLimiting, rename the namespace, add it to your test suite, and record the removal trigger in an ADR.
Pick a store whose increment() is atomic. Redis, Memcached, database, DynamoDB: fine. file and array: no — no cross-process atomicity, so you'll get wildly wrong counters under concurrent PHP-FPM workers with no error to tell you. Set it explicitly rather than inheriting CACHE_DRIVER, which differs between local, CI, and prod:
'stores' => [
'limiter' => ['driver' => 'redis', 'connection' => 'limiter', 'prefix' => 'fw:'],
],
Isolate the keyspace. Give the limiter its own Redis connection/DB and prefix, separate from cache, sessions, and queues. Sharing a DB with CACHE_PREFIX unset means a flush-happy teammate or a cache:clear wipes live counters mid-window — which looks like a burst of traffic, not a config problem.
Redis Cluster: watch the key slots. Any multi-key Lua breaks with CROSSSLOT unless you hash-tag the key ({tenant:42}:partner:invoices). Zero dependents means nobody has tested this for you.
Compute reset times from one clock source. getCreationDate() plus PHP's now() across a fleet of app servers gives you skew in Retry-After. Fine at ±1s; if clients are strict, derive the reset from the server-side TTL instead.
No observability, so build your own. No artisan commands, no dashboards, no metrics. Log every rejection with the key and window (Log::warning('fw_limit', [...])) and emit a counter — otherwise you cannot answer "are we actually being throttled?" during an incident.
Consider whether you need it at all. Laravel's first-party Illuminate\Cache\RateLimiter + throttle: middleware gives you the same per-key counter with attempt(), remaining(), retryAfter(), and availableIn(), on any atomic store, with a supported upgrade path. The honest question before adopting this is: which requirement does the native limiter fail? If the answer is "nothing, we just liked the API," delete the dependency. If it's "we need clock-aligned buckets and reset headers that match a partner's published quota," you can get that with the bucket-in-the-key trick above and ~30 lines of your own code — with a named owner, tests, and no archive notice.
How can I help you explore Laravel packages today?