Rate limiting is your API’s defense against abuse and against honest mistakes. Without it, one misconfigured client in a retry loop degrades the service for everyone.
throttleApi() turns on Laravel’s throttle middleware for the api group. It uses a limiter named api, which you define once:
// app/Providers/AppServiceProvider.php, in boot()
RateLimiter::for('api', function (Request $request): Limit {
$consumer = $request->user();
if ($consumer === null) {
return Limit::perMinute(60)->by($request->ip());
}
return Limit::perMinute($consumer->rate_limit ?? 60)
->by((string) $consumer->id);
});
Each consumer gets its own counter. If one burns through its sixty requests, the others aren’t affected. The ceiling comes from the rate_limit column on the consumer, with a default, so a partner that needs more gets more without a deploy. (Laravel documentation: Routing › Rate Limiting.)
The Order You Don’t Have to Arrange
The limiter reads $request->user(), so authentication has to run before it. But throttleApi() put the throttle in the group, and auth:sanctum is on the route, which looks like the wrong order.
It isn’t, because Laravel sorts middleware by a built-in priority list, and authentication comes before throttling in it whatever order you wrote them. A request without a valid token is rejected by auth:sanctum and never reaches this limiter.
That has two consequences. The first is that the anonymous branch above only ever serves routes that are deliberately public. Rejecting a request is authentication’s job, and a limiter that tries to do it too ends up authorizing by accident.
The second is easy to miss: requests with a bad token are not rate limited at all. Each one costs a database lookup, and nothing in the application stops a client from sending ten thousand. You can’t fix that by adding another throttle to the route, because the same priority list would sort it after authentication too.
The place for that limit is in front of the application. A web server can cap requests per address before PHP starts, which is cheaper than anything Laravel could do and covers every route at once. In Nginx:
# in http {}
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
# in the location {} that serves the API
limit_req zone=api burst=200 nodelay;
limit_req_status 429;
Without the last line Nginx answers 503, which tells a client the wrong thing. Set the rate at or above what your busiest consumer is allowed: Chapter 5 lets rate_limit go up to 6,000 a minute, which is 100 a second. This is a ceiling against floods and guessing, not a quota. The per-consumer limiter above remains the quota.
Whose Address Is That?
The anonymous branch of the limiter relies on $request->ip(), as does the request log later in this chapter, and behind a load balancer that is the load balancer’s address. Every anonymous caller in the world shares one counter.
Laravel reads the real client address from the X-Forwarded-For header only if you tell it which proxies to believe:
// bootstrap/app.php, inside withMiddleware()
$middleware->trustProxies(at: ['10.0.0.0/8']);
Name your load balancer’s addresses, not *. Trust every proxy and you trust the header itself, which any client can set to whatever it likes. (Laravel documentation: HTTP Requests › Configuring Trusted Proxies.)
The Nginx limit above has the same problem and needs the same answer. Behind a load balancer, $binary_remote_addr is the balancer, and every client in the world shares one counter. Nginx’s set_real_ip_from and real_ip_header directives are its version of trusted proxies. Set them before you rely on that limit.
What the Client Sees
Laravel adds the headers a well-behaved client needs to every throttled response:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
And when the limit is hit, a 429 with two more:
Retry-After: 42
X-RateLimit-Reset: 1790000042
{
"message": "Too Many Attempts."
}
You write none of this. I once maintained a middleware that added these headers by hand, and it read a different counter from the one the throttle was writing, so the numbers it reported were wrong. Laravel’s are right because they come from the thing doing the counting.
Where the Counters Live
The limiter counts in your cache. With the database cache store that Laravel ships by default, every request to your API is a write to your database before it has done any work. In production, point the cache at Redis:
CACHE_STORE=redis
If you want the limiter on a different store from the rest of your cache, cache.limiter in config/cache.php names it.