Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Sliding Window Limiter Laravel Package

beyondcode/laravel-sliding-window-limiter

A drop-in extension for Laravel's RateLimiter that adds sliding window rate limiting. Instead of fixed intervals that reset abruptly, throttling rolls continuously, giving smoother, more accurate limits for APIs, queued jobs, and other rate-limited actions.

View on GitHub
Deep Wiki
Context7

Getting Started

First, read the source — it will take ten minutes. This package is archived (last release 2019-09-23), has 42 stars, zero dependents, and pre-dates Laravel 8+ and PHP 8.x. The docs are a README and a one-line changelog. The codebase is small enough — a service provider, a limiter class, and a config file — that reading src/ is faster and more reliable than any tutorial, including this one. Treat that reading as the actual onboarding step.

Attempt the install, but time-box it.

composer require beyondcode/laravel-sliding-window-limiter

On a legacy stack (Laravel 5.8/6.x, PHP 7.x) this may resolve cleanly and auto-register its provider. On Laravel 10/11/12 it will very likely fail dependency resolution against modern illuminate/* constraints. If it fails to resolve, stop. Do not pin, --ignore-platform-reqs, or patch constraints to force it in; you are about to put an unmaintained component on the hot path of every request. Copy the algorithm instead — MIT permits it with attribution.

If it does resolve, verify three things before writing a single route:

  1. The Redis connection it uses. Confirm it honors a named connection from config/database.php, and that you can point it at a dedicated Redis database — not the one holding your sessions and cache.
  2. The config keys for limit, window, and key prefix. Publish the config if the provider supports it; otherwise the values are in the class.
  3. Whether the check-and-record path is a single Lua script. Open the limiter class and look for eval/evalsha. If it does a ZCOUNT followed by a separate ZADD, you have a check-then-act race under concurrency and you should not use it as a security control.

First use case: defend one outbound integration, not all your routes. Pick a single endpoint that hits a vendor with a rolling quota — a payment provider, SMS gateway, identity service, or a customer webhook. That gives you a bounded blast radius, a real workload to measure against, and a place where a boundary burst would actually cause an incident. Instrument it before you enforce it (see shadow mode below), then widen.

A note before you start: check whether you need this at all. Laravel's native Redis::throttle() is sorted-set based and effectively sliding already. If it clears your bar, the delta between it and this package is a composer require you don't need to make.

Implementation Patterns

Never call the package directly from routes or controllers. Bind an internal contract and let the implementation be swappable — this is the single highest-value pattern here, because the day you need to fork, replace, or move to a gateway, only one binding changes.

// app/Contracts/SlidingWindowLimiter.php
interface SlidingWindowLimiter
{
    /** @return array{allowed: bool, remaining: int, retryAfter: int} */
    public function attempt(string $key, int $limit, int $windowSeconds): array;
}
// app/Providers/AppServiceProvider.php
$this->app->bind(SlidingWindowLimiter::class, RedisSlidingWindowLimiter::class);

Wrap the package — or your reimplementation — inside RedisSlidingWindowLimiter. Nothing outside that one class imports a vendor namespace.

Middleware for HTTP, and the same contract for jobs. The most common production gap is limiting HTTP and forgetting queue workers. A job that fans out 500 API calls bypasses every route middleware you have.

public function handle($request, Closure $next)
{
    $key = sprintf('rl:%s:%s', $this->tenantId($request), $request->path());
    $result = $this->limiter->attempt($key, limit: 1000, windowSeconds: 3600);

    if (! $result['allowed']) {
        return response()->json(['message' => 'Too Many Requests'], 429)
            ->header('Retry-After', $result['retryAfter'])
            ->header('X-RateLimit-Limit', 1000)
            ->header('X-RateLimit-Remaining', 0);
    }

    return $next($request)
        ->header('X-RateLimit-Remaining', $result['remaining']);
}

Header semantics matter more than the algorithm. If you publish "1,000 requests per rolling hour," Retry-After must be the actual seconds until the oldest entry in the window expires — not the seconds until the top of the hour. A fuzzy Retry-After manufactures retry storms, and clients will build backoff logic against whatever you emit. Compute it from the same window data, in the same round trip.

Design key granularity deliberately. rl:{tenant}:{route} is the sweet spot for most apps. Per-IP keys are trivially evaded and explode cardinality behind NAT; per-user-only keys let one tenant hammer a shared downstream. For multi-tenant fairness, rate limit on the tenant, and consider a secondary global key as a circuit breaker.

Shadow-mode first. Run the limiter in log-only mode against a low-traffic route and diff its decisions against your current behavior for a week before enforcing anything.

if (config('limits.enforce')) {
    abort_if(! $result['allowed'], 429);
}

Log::channel('ratelimit')->info('swl.decision', [
    'key' => $key, 'allowed' => $result['allowed'],
    'would_retry_after' => $result['retryAfter'],
]);

This surfaces the two things that will actually surprise you: which keys are hotter than you assumed, and how much of your traffic sits near the limit.

For outbound calls, translate the limit into queue backoff, not a 429. Your app isn't the one being protected — the vendor is. Release the job rather than failing it:

if (! $limiter->attempt("vendor:{$provider}", 100, 60)['allowed']) {
    $this->release($result['retryAfter']);
}

Test against the contract, not the package. Bind a fake implementation in tests; use a real Redis (a container, not a mock) in one integration test to prove the atomicity and TTL behavior you're relying on.

Gotchas and Tips

It is archived and unmaintained. This is the headline. No security response path for a limiter-bypass report, no compatibility patches, no one to escalate to. A rate limiter is a security and cost control on every request; that is not a component to inherit permanently. Read it, reimplement it, attribute it.

Verify atomicity yourself. A sliding-window log must be check-and-record-in-one-round-trip, almost certainly Lua. If the implementation counts and then inserts as separate commands, two concurrent requests can both observe a count below the limit and both be admitted. Under real concurrency this converts your strict limit into an approximate suggestion.

Use Redis's clock, not PHP's. If the Lua script derives window boundaries from a timestamp passed in by the application, clock skew across app nodes will corrupt the window — entries land outside the range the reader scans, and the limiter either over- or under-admits depending on which node answers. Confirm the script calls TIME server-side.

Redis Cluster will break naive key naming. Any operation spanning multiple keys must land in one hash slot. Hash-tag the prefix — {rl}:tenant:42:route — or the script fails with CROSSSLOT. This is also a good reason to keep everything for one limiter decision under a single key.

Memory is O(limit) per key, and that gets real. A precise log stores one sorted-set member per request: a 1,000/hour limit is 1,000 members per key, per tenant, per route. Multiply by cardinality and TTLs and you have a Redis memory plan. Before rollouting: size keys × limit, define TTLs, and make sure the implementation runs ZREMRANGEBYSCORE on every call — if it only trims lazily or relies on EXPIRE alone, keys grow unboundedly between resets.

The 1.1.1 changelog is a diagnostic. "Use string instead of array in hdel" means the codebase was passing array arguments to phpredis, which expects strings. If you fork onto a modern phpredis and hit Array to string conversion or a type error in the limiter internals, that's the same class of bug — audit every Redis call for argument types.

Decide fail-open vs. fail-closed explicitly, and write it down. When Redis is unavailable, does the limiter admit everyone (latency incident avoided, cost/abuse protection gone) or reject everyone (protection intact, full outage)? Neither is free. This needs a product owner's signature, not a default try/catch.

Don't stack limiters. If you add this alongside Laravel's throttle: middleware, the tighter one wins silently and your headers contradict each other. Pick one enforcement point per route and disable the other.

Extension points are limited, so plan for replacement. Because it plugs into Laravel's cache and RateLimiter conventions, you can usually swap the binding underneath — that's the seam to build on. Don't subclass the vendor's limiter class; the next maintainer (you, in six months) won't remember why.

Watch the p99, not just the 429s. The limiter is on every request path now. Add limiter latency and 429-rate-by-tenant dashboards before broad rollout, or a hot shard will show up as a general latency regression with no obvious owner.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console