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.
The decision on the table. Not "do we need rate limiting" — we almost certainly do on several surfaces — but "does this specific implementation's fixed-window semantics close a gap that Laravel's first-party Illuminate\Cache\RateLimiter plus throttle: middleware does not, and is that gap worth owning an archived dependency to get?" The package is a clock-aligned counter: one cache key per identity per window bucket, incremented per request, expiring at the boundary. That model is genuinely valuable for exactly one product scenario — mirroring an external provider's advertised quota ("100/min, resets on the minute") so outbound calls stop before the remote returns 429s, and so customer-facing "remaining / resets at :00" UI matches the partner's mental model. Every other justification resolves to primitives we already have.
Architecture fit — clean but shallow, and shallow is the problem. Cache-store abstraction, RateLimiter-style facade, HTTP middleware: nothing conflicts with a standard Laravel service. But a component this easy to replace cannot justify an ownership premium. We would be accepting permanent maintenance liability for roughly 30–50 lines of behaviour we could own outright.
Integration feasibility — mechanically trivial, practically constrained. MIT license, small surface, Composer-installable. The blocker is vintage: last release January 2020, illuminate/* and PHP constraints targeting the Laravel 6/7 era. Running it on Laravel 10–12 requires --ignore-platform-reqs, a Composer alias, or a fork with widened constraints. Each of those silently converts "add a dependency" into "own a code artifact" — a resourcing decision that must be funded before the roadmap commits, not discovered at implementation time.
Technical risk, ranked by product blast radius.
INCR followed by EXPIRE. A crash between the two leaves a key with no TTL and permanently throttles that identity. The symptom is a support ticket, not an alert — a product-visible failure with no telemetry path. Must be confirmed by source inspection (Lua or MULTI) before any production exposure.predis vs phpredis divergence, or connection-failure behaviour. We would be the first.Key questions for the team.
RateLimiter::for() + throttle: fail — exact clock alignment, cross-service shared counters, or response-header shape?INCR + conditional PEXPIRE) with our own tests?Stack fit. Positive on infrastructure — Redis is already provisioned, nothing new to run, no new operational surface. Negative on governance — an archived dependency in the graph triggers security review, license-scanning exceptions, dependency-policy exceptions, and a recurring "is this still safe?" conversation at every audit. Net: adopt only if a concrete semantic gap is written down and agreed by product, security, and the owning engineer.
Frame it as a three-way decision, not a purchase.
| Option | Upfront cost | Ongoing cost | When it wins |
|---|---|---|---|
Native RateLimiter + Redis store |
Near zero | Near zero | Default case; sliding/decay semantics acceptable |
| In-house Lua limiter (~30–50 LOC) | 1–3 days incl. tests | Low, well understood, ours | Exact clock-aligned windows genuinely required |
| Fork this package | 1–2 days to widen constraints | Permanent ownership | Already embedded in a legacy path |
The honest TCO comparison is dominated by the third column, which is why the middle row almost always beats the bottom row. Present this table to stakeholders as the decision — do not present the package as the only route to fixed windows.
Migration path. Given zero dependents and a 2020 release, the most likely finding is that nothing in our estate uses it and no work is required. Sequence it as containment, not adoption:
RateLimiter::for() with the Redis store, or onto the in-house Lua script.Compatibility. Any retained code must be validated against PHP 8.1–8.3, Laravel 10–12, Redis 6/7, and both predis and phpredis. That matrix is not covered upstream; it is entirely ours. This is the single most important scoping input: the integration is a day, the compatibility matrix is a recurring commitment that outlives the sprint.
Sequencing. Decide native vs. in-house → 1–2 day spike proving atomic increment and fail-mode → pin and vendor only if forking → canary one low-risk endpoint behind a feature flag → compare 429 rates and headers against the incumbent for a full week → record the outcome in an ADR with rationale and removal trigger.
Legitimate near-term scope where I would not block a short-term bridge: internal-only endpoints, webhook throttling, login/OTP attempt caps, expensive export/report endpoints, and outbound third-party API protection. None carry SLA, billing, or fairness commitments. Deliberately out of scope: per-tenant quota hierarchies, billing-linked limits, public API contracts, and anything where the limit is a customer-facing promise.
Maintenance. There is no upstream to lean on. Every Laravel major, every PHP minor, and every Redis client bump becomes our work — triage, patch, test, redeploy. That is a recurring line item, not a one-time cost, and it must be budgeted with a named owner. If nobody will own it, do not adopt it; that rule alone resolves most of this evaluation.
Support. No security response path means vulnerabilities in this component have no vendor to escalate to and no CVE pipeline to watch. Audits will ask; we will need a standing exception with a rationale. Worse, the failure signatures here are support-ticket-shaped rather than alert-shaped: a permanently throttled identity from a TTL-less key, or a boundary burst that lets a caller momentarily reach ~2x the configured limit. Neither trips an alert. Both generate user-visible complaints, and the on-call path has no runbook.
Scaling. The key model is one key per identity per window, TTL-bounded, so memory is self-limiting — that is a real advantage over unbounded counters. But high-cardinality identities plus window turnover produce steady key churn, and cache-store eviction policy becomes a silent correctness input: under memory pressure, evicted keys reset quotas early and the limiter fails open without a log line. Multi-region or multi-store deployments need a documented stance on whether counters are global or per-region, and Redis Cluster requires confirming the increment-and-expiry path is single-slot.
Failure modes to specify and test before exposure.
| Failure | Observable symptom | Required decision |
|---|---|---|
| Redis unavailable | All requests allowed or all rejected | Fail open vs. fail closed — sign off explicitly |
Crash between INCR and EXPIRE |
Identity throttled permanently | Verify atomicity; add TTL-repair |
| Window boundary | Traffic briefly ~2x limit | Accept for non-critical surfaces only |
| Cache eviction under pressure | Quota resets early, silently | Pin eviction policy; alert on evictions |
| Clock skew across app nodes | Misaligned windows, uneven bursts | Confirm time source is the cache, not app servers |
Each row is a product decision disguised as an engineering detail. The TPM's job is to get them decided in writing before code ships, not after the first incident review.
Ramp-up. Learning the API is an afternoon; that is not the cost. The real ramp-up is ownership capability — writing the boundary-burst and atomicity tests, building the compatibility matrix, establishing the runbook, and training on-call to recognize "quota stuck at zero" as this component. Budget 3–5 engineer-days of enablement for anything beyond a throwaway spike.
Exit plan. Whatever we decide, record the removal trigger and date in the ADR. For a component this small, replacement is cheap — the only thing that makes it expensive is discovering it embedded in a critical path two years from now with no owner and no tests.
How can I help you explore Laravel packages today?