aporat/laravel-rate-limiter
Add flexible rate limiting to Laravel routes and APIs with aporat/laravel-rate-limiter. Configure request limits, time windows, and custom keys, then protect endpoints via middleware using Laravel’s cache-backed storage. Ideal for throttling login attempts, webhooks, and public APIs.
Architecture fit — The package lives at the HTTP middleware layer and delegates all state to Redis through a single Lua script per request. v6 modernizes the internals in ways that matter for a Laravel estate: config read through Illuminate\Contracts\Config\Repository, an immutable builder (the facade no longer leaks state between calls), and a singleton that holds no request state — i.e. Octane/RoadRunner-safe. It also stops maintaining a parallel Redis config and uses Laravel's Redis manager with named connections, TLS, cluster and REDIS_URL. The trade-off is that it puts Redis on the synchronous critical path of every limited route, which is a new hard dependency for those endpoints.
Integration feasibility — Low-to-moderate effort. Most policies express declaratively in routes via the validated middleware DSL (rate.limiter:second=,minute=60,hourly=1000,key=ip+route,name=api); the programmatic surface (RateLimit::with(), PendingLimit::attempt(), LimitResult, RateLimiter::attempt(), blockedFor()) covers multi-window and custom flows atomically, which was previously the awkward part. RateLimitExceeded and IpBlocked events give clean hooks into existing metrics. The real cost is not code volume but the removed API surface and the header contract.
Technical risk —
X-RateLimit-Limit / -Remaining / -Reset; withRateLimitHeaders(), setRequestTag(), getRequestTag(), checkIpAddress(), the redis.* settings and Lumen support are gone. Old header names don't error — they disappear. Anything parsing them (client SDKs, mobile backoff logic, gateway rules, dashboards, contract tests) fails silently or degrades to naive retry.10.0.0.0/8, and with trust-all proxies any client could present a private address and be exempt entirely. If we run behind a load balancer that trusts forwarded headers, prior limiting may have been partially or wholly ineffective. This reframes the upgrade as a security fix with an exposure question attached.withUserId('a:b') no longer collides with withUserId('a')->withName('b')) and no shared bucket for missing IPs change which counters exist. Pre-existing collisions may have been masking abuse or over-limiting; expect key-space churn on cutover.on_redis_failure: open | closed (503) with failure_backoff_seconds.Key questions —
X-Forwarded-For while the 10.0.0.0/8 whitelist was active — i.e. was v5 actually limiting anyone?log_errors default false) remove data we currently alert on?Stack fit — Native Laravel throughout: service provider, publishable config, middleware alias, facade, events, config contract. Preferring Laravel's Redis manager over its own connection settings is the right call — it collapses two divergent Redis configs into one and inherits config/database.php, REDIS_URL, TLS, cluster and client choice for free. Fits cleanly alongside cache/queue/session on shared Redis, though isolation (separate database or instance) is worth considering given eviction risk.
Migration path —
rate.limiter usage and RateLimit:: call, capture a week of 429 rates and emitted header values.redis.* settings into a named Laravel Redis connection and reference it via connection (the named connection now wins over host/port/database defaults — a wrong database index silently limits against the wrong dataset).withRateLimitHeaders() → headers config; setRequestTag()/getRequestTag() → name= / key= DSL params or RateLimit::with(); checkIpAddress() → whitelist config.rate.limiter:* string needs a second look.on_redis_failure explicitly per route class rather than accepting a default.Compatibility — PHP 8.3+ is a hard floor; verify base images and any shared runtime before scheduling. Pin to a Laravel line covered by the CI lanes and confirm ext-redis is optional in our setup (predis path is tested, so it's viable). Lumen-based services cannot upgrade and remain on v5 — with the whitelist exposure unresolved on those. The header rename should be treated as a versioned public API change with a deprecation notice, not an internal refactor.
Sequencing — Runtime/PHP upgrade → Redis connection migration (behind a flag, verified against prod topology) → API surface swap + header dual-emit → middleware parameter audit → security defaults validated behind the real proxy chain → canary one route → full rollout. If step zero shows the v5 limiter was ineffective, treat that as an incident finding and fix the whitelist independently of the version upgrade rather than waiting for the full migration.
Maintenance — Small, single-purpose dependency with a compact surface (config + middleware + a few APIs). The concern is churn: this package ships breaking minors, so pin exact versions and review each bump rather than floating ranges. Pint and PHPStan level 8 as separate CI jobs is a reasonable quality signal, and testing both Redis clients plus a redis:8 service image suggests the maintainer is exercising real paths.
Support — Diagnosis shifts from "package misconfigured" to "is Redis healthy". Runbooks need: named-connection validation, NOSCRIPT/EVALSHA behavior, cluster slot and script-propagation checks, and the open/closed setting documented per route. Error reporting is deliberately throttled to one warning per backoff window — good for log noise, but it means app logs are no longer a reliable outage signal; alert on Redis health and on the ratio of limited-to-unlimited traffic instead.
Scaling — One Lua round-trip per limited request: the ceiling is Redis RTT, not app CPU. Size connection pools accordingly and watch EVALSHA hit rate — fallback to EVAL after restarts or SCRIPT FLUSH is a latency spike, and in cluster mode every node needs the script. Hash tags pin all windows of a subject to one shard, so abusive clients create hot shards; multi-window policies multiply keys per subject. Confirm maxmemory-policy: noeviction — eviction under pressure silently resets limits and turns a protection mechanism into a no-op at exactly the wrong moment.
Failure modes —
closed returns 503 to all limited traffic (availability incident) or open serves unlimited (protection gap). Must be chosen per endpoint, not globally by accident.key= composition in cluster → cross-slot errors if hash tags aren't preserved.Ramp-up — Low for a Laravel-fluent engineer: alias plus a parameter string handles the common case in an afternoon. The genuine learning curve is semantic, not syntactic — what a limit means when four windows interact, why headers now describe the most restrictive one, and how blockedFor()/LimitResult compose. Budget half a day of team review against the upgrade guide plus a load test on the production Redis topology (including a failover drill with each on_redis_failure setting). Write the quota policy in product language first — "60/min, 5k/hour, keyed by user+route, 503 on Redis failure for auth routes" — so route files become transcription rather than design.
How can I help you explore Laravel packages today?