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

Throttle Laravel Package

graham-campbell/throttle

Rate limiting for Laravel by Graham Campbell. Throttle provides middleware and a facade to limit requests and actions by key, IP, or route, backed by your cache driver, with configurable hit limits and decay times. Ideal for APIs and forms.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm the runtime gate first. v11 supports only PHP 8.1–8.4 and Laravel 10–12. Run composer why-not before anything else — if you're on PHP 8.0 or Laravel 9 the package will not resolve and the rest of this document is moot until the platform upgrade lands.

composer require graham-campbell/throttle

The service provider and Throttle facade are auto-discovered; there is no config/app.php edit. Publish the config:

php artisan vendor:publish --provider="GrahamCampbell\Throttle\ThrottleServiceProvider"

That drops config/throttle.php, which is where the day-to-day tuning lives (default max attempts, decay window, and the cache store the counters use). Read that file before writing any code — it is the actual contract of this package, more so than the README.

Where to look first, in order:

  1. config/throttle.php — limits, decay, cache store.
  2. vendor/graham-campbell/throttle/src/ — the facade's real method signatures. The v11 release notes mention a "cleaned up transformer design," and the package description never mentions a transformer at all; treat the source as authoritative and the docs as a sketch.
  3. The middleware class — to see what response shape it returns on a 429 (bare, or with Retry-After / X-RateLimit-*).

First use case: throttle a login route. Add the middleware to the route group, then confirm the 429 body by hammering it with curl in a loop. Do this before wiring anything else — you want to see the failure shape while the route is trivial:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://app.test/login -d '[email protected]&password=x'
done

If you see ten 200s, your cache store is not shared or the middleware is not actually attached. Fix that before proceeding.


Implementation Patterns

Programmatic throttling keyed to a business action

Middleware throttles requests; the facade throttles events. The distinction matters for login, OTP, and password reset, where you want to count by identity rather than by socket:

use GrahamCampbell\Throttle\Facades\Throttle;

final class LoginController
{
    public function store(LoginRequest $request)
    {
        $key = ['login', $request->input('email'), $request->ip()];

        // Check without consuming an attempt.
        if (Throttle::check($key, 5, 15) === false) {
            abort(429, 'Too many attempts.');
        }

        if (! Auth::attempt($request->only('email', 'password'))) {
            Throttle::hit($key, 5, 15);   // consume one
            return back()->withErrors(['email' => 'Invalid credentials.']);
        }

        Throttle::clear($key);            // reset on success
        return redirect()->intended('/dashboard');
    }
}

attempt($key, $limit, $decay) is the combined check-and-hit if you want it in one call; verify the argument order and the return type against the installed source, since v11 reworked how the response object is built. The pattern to internalize is the check → hit → clear triad: it lets you avoid burning a budget slot on a successful login, which is the single most common complaint about naive login throttles.

Wrap the facade in one class

Do not sprinkle Throttle:: through controllers. The package is a thin wrapper over RateLimiter; you want the option to swap it back to the framework in one file.

final class LoginThrottle
{
    public function __construct(
        private readonly int $max = 5,
        private readonly int $decay = 15,
    ) {}

    public function tooManyAttempts(array $key): bool
    {
        return Throttle::check($key, $this->max, $this->decay) === false;
    }

    public function recordFailure(array $key): void
    {
        Throttle::hit($key, $this->max, $this->decay);
    }

    public function clear(array $key): void
    {
        Throttle::clear($key);
    }
}

Bind it in a service provider and pull limits from config, not from constructor defaults in call sites. When you later migrate to RateLimiter::for('login', ...), you change this one class and delete a dependency.

Route middleware on the groups that matter

Put the middleware on the route groups, not on individual routes — the inventory is easier to review and harder to accidentally miss:

Route::middleware(['api', 'throttle'])->group(function () {
    Route::post('/webhooks/stripe', [WebhookController::class, 'stripe']);
    Route::post('/reports/export', [ReportController::class, 'export']);
});

Throttling is a security checklist item, not a one-time ticket. Keep the list of throttled groups somewhere reviewable, and make "which public routes are unthrottled?" a question someone actually asks in review.

Choose keying deliberately

The three keys you will actually use:

Key Use for Failure mode if wrong
[$request->ip()] Unauthenticated public endpoints, webhooks Collapses behind NAT/proxy — one user 429s everyone
['route', $request->ip()] Per-endpoint per-client limits Budget shared across endpoints if you omit the route
[$user->id] Authenticated per-account limits Useless on login, where there is no user yet

For login specifically, use [email, ip] together. IP alone lets a botnet spray one account; email alone lets an attacker lock out a victim by burning their budget.

Test the boundary, not the happy path

A single request test proves nothing about a rate limiter. Test concurrency and the exact edge:

it('blocks at the limit and allows before it', function () {
    Cache::flush();

    for ($i = 0; $i < 5; $i++) {
        $this->post('/login', [...])->assertStatus(302); // or 422
    }

    $response = $this->post('/login', [...]);
    $response->assertStatus(429);
    expect($response->headers->get('Retry-After'))->not->toBeNull();
});

The Retry-After assertion is the one people skip and the one that determines whether your API is usable by automated clients. If the package's 429 is bare, add a small middleware after it that synthesizes the header — but first check what it actually returns.

Combine with RateLimiter::for() rather than replacing it

The pragmatic split: use Laravel's named limiters where you need per-plan logic or you want the framework's standard headers, and use this package where a static config value is genuinely enough. They share the same cache, so they coexist without interference. If you find yourself writing RateLimiter::for() for everything anyway, the package has stopped earning its place.


Gotchas and Tips

The middleware alias likely collides with Laravel's built-in throttle. Laravel's ThrottleRequests is registered under that alias, and this package registers its own. Decide which one throttle resolves to in bootstrap/app.php (or Kernel::$middlewareAliases on L10) and be explicit; a silent resolution to the wrong middleware is the kind of thing that passes tests and fails in production. Reference the class FQCN if you need certainty.

A shared cache is a correctness dependency, not a tuning knob. If production runs multiple nodes on file or array cache, every server counts independently and your effective limit is limit × node count. It looks perfect in staging and is decorative in production. The array driver makes throttling per-process — never let it near production config. Ship the cache change before enabling the middleware, never in the same release.

Failure semantics when cache is down are not documented. Either requests pass (silent protection loss) or all requests 429 (self-inflicted outage). Both are defensible; not knowing which you have is not. Pin it with a test: point the throttle store at an unreachable Redis and assert the behavior you intend. For login and webhooks, fail-closed is usually right; for public read endpoints, fail-open is usually right. You may have to implement that choice yourself.

Client identity behind a CDN or load balancer. Without correct TrustProxies, keys either collapse into one global bucket (site-wide 429s from normal traffic) or become spoofable via X-Forwarded-For (protection bypassed with one header). Verify with a real request through your edge: log the derived key and confirm it varies per client.

The v11 xxh128 hashing change resets in-flight counters on deploy. Derived keys change, so existing counters become unreachable. A client at 4/5 login attempts gets a fresh allowance the moment you ship. Schedule that deploy deliberately; watch 429 rates and cache health for an hour afterwards.

Decay window vs. cache TTL. If the cache evicts the counter before the decay window elapses — short TTLs, tight Redis maxmemory with allkeys-lru — limits silently relax under memory pressure. Make sure the decay window is comfortably shorter than any TTL or eviction policy that could touch these keys, and keep throttle keys out of any LRU-heavy keyspace.

The 429 response is your API contract. Check whether X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After are emitted. If they aren't, your SDK authors and API docs have nothing to work with. Adding a header-emitting middleware around the package is cheap; discovering the gap after your first enterprise customer is not.

Extension point: bind your own implementation. The container resolves the throttle implementation; you can bind a subclass or your own Throttle contract in a service provider to change key derivation or response shaping without forking. This is also your escape hatch back to RateLimiter::for().

Config caching and Octane. php artisan config:cache is required in production and the limits are read once at boot. In Octane, config changes need a worker reload. The facade itself is stateless (all state is in cache), so it is Octane-safe — but long-lived workers won't pick up config edits.

Don't let this close the DDoS conversation. Application middleware sheds abusive clients; it does not absorb volumetric traffic. Keep the WAF/edge discussion open and separate.

Zero known dependents means you are the QA. Read the changelog against the installed source before upgrading. Pin an exact version, roll out to one low-risk route group first, and keep the whole thing behind the single wrapper class so removal is a one-file change.

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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core