If you have put throttle:30,1 on one route and throttle:60,1 on another, you probably assume they hold separate allowances.

The first route does permit thirty requests a minute. The second does not get sixty, because the two share a single counter. We measured a running Laravel application to find out what the limit counts, whose identity it counts against, and what it declines to charge for — and that one finding changes what an API limit means in practice.

What the number means at the boundary

Thirty per minute means thirty succeed and the thirty-first is refused. Off-by-one questions are worth a single experiment rather than an argument:

requests 1–30   200
request 31      429

Retry-After: 59
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 

The window is fixed, not rolling. After the refusal we polled until the endpoint answered again: it came back at 60.2 seconds, and the first successful response reported 29 remaining — the whole allowance returned at once. There is no gradual drip. Everyone refused during that minute is released at the same instant, which matters for what the second guide in this pair measures.

It counts your address, not your session

Two clients, same machine, distinguished only by their cookies. The first saw 29 remaining, the second saw 28 — one counter between them.

This is the intended behaviour, and it cuts both ways. An attacker cannot get a fresh allowance by discarding cookies. An office, a school or a mobile carrier's NAT can exhaust your limit for everyone behind it, and each of those users will see a 429 they cannot explain or clear.

The finding: two routes, two limits, one counter

Here is what we expected to be a formality. Alternate between a page route limited to 30 and an API route limited to 60, and read each response's own counter:

request 1  /search              remaining 29   (limit 30)
request 2  /api/…/instant       remaining 58   (limit 60)
request 3  /search              remaining 27   (limit 30)
request 4  /api/…/instant       remaining 56   (limit 60)
request 5  /search              remaining 25   (limit 30)

The page route's allowance falls by two per own-request. So does the API route's. Neither is losing anything to itself — every reading fits a single shared count: after n requests in total, one route reports 30−n and the other reports 60−n.

The framework says why. ThrottleRequests builds its key like this:

$route->getDomain() . '|' . $request->ip()

The route is not in the key. Every route on one domain using a plain throttle:n,m shares one counter for a given address; the only thing that differs per route is the ceiling it compares that shared count against.

So the practical rule is not that an endpoint allows 60 requests. The effective limit for any client is the smallest ceiling among the routes it touches. A client that loads a page limited to 30 and then calls your 60-per-minute API gets refused by the API after thirty combined requests, having consumed an allowance it never knew it shared. If you want a separate budget, you need a named limiter — RateLimiter::for('api', …) — and a key you choose deliberately.

Redirects are free

A smaller result, easy to get backwards. Our canonical URLs carry a trailing slash and the unslashed form answers with a 301. Does that redirect spend budget?

three unslashed requests (301, not followed)
one real request
total slots consumed: 1

It does not. The redirect is issued before the throttle middleware runs, so it costs nothing. A client that follows the redirect spends one slot on the destination, which is the same as going there directly — so the shape of your URLs is not quietly taxing your callers.

Our first test gave the opposite result. It used a Python HTTP client that follows redirects by default, so its "three redirects" were three completed requests to the destination, and it reported that each redirect cost a slot. The instrument had silently changed what was being measured. Turning redirect-following off produced the result above.

Whose address, though?

Everything above rests on "the address", but what that address is depends on which upstream hops your application trusts rather than on the application itself.

We ran the same probe against development and production. Same code, same throttle:30,1, requests carrying a fabricated X-Forwarded-For:

development   plain 29, 28   spoofed 29, 28   another spoof 29
production    plain 29, 28   spoofed 27, 26   another spoof 25

In development, every fabricated address received its own full allowance — the limit is bypassable by changing a header. In production the counter continued unbroken through the same requests; the header was ignored entirely.

The difference is one configuration line. Development trusts the Docker bridge network as a proxy, and the container receives every request from that bridge, so the application dutifully reads the client address out of a header the client controls. Production sits behind Cloudflare and a host proxy, and the address the limiter keys on is not one the caller can set.

Your rate limit's identity is defined by your proxy configuration, not your throttle declaration. Trusting too much is the whole vulnerability, and it does not appear anywhere in the throttle declaration.

What to check on your own system

1. send limit+1 requests   — confirm where the refusal actually falls
2. exhaust, then poll      — fixed window or rolling?
3. alternate two routes    — do their counters move together?
4. change only the cookie  — same bucket, or a fresh one?
5. fabricate X-Forwarded-For — does it get its own allowance?

Test 5 is the one to run first, and to run against every environment separately. The others tell you what your limit does; that one tells you whether it can be bypassed.

How this was measured, and what it does not settle

One Laravel application, MySQL and Redis behind it, throttles declared as route middleware with the framework's default limiter. Development was measured locally; production was touched with roughly a dozen requests, enough to read a counter and far too few to affect anything. Every figure is an observation from those runs, with a fresh window waited out before each test.

The shared-counter result is specific to the unnamed limiter. A named limiter with its own key behaves differently by design, and that is precisely the fix. The general lesson is to check rather than assume. The surprise here was not in our code, but in the framework's default behaviour.

Storage matters and was not varied. The counter lives in the cache; with a single Redis instance, the arithmetic above is exact. Spread the same application across several nodes with per-node caches and the effective limit multiplies by the node count, which is a different measurement we did not make.

None of this is a defence against a distributed client. A limit keyed on an address is defeated by having many addresses, which is what a botnet is. It bounds accidental load and casual abuse well, and treating it as protection against a determined adversary is a category error.

Knowing where the refusals fall is only half the problem. Being on the receiving end turned out to reward politeness more than we expected.