Beyond throttle:60,1: Real API Rate-Limiting in Laravel
The built-in throttle middleware is fine for a quick guard, but production APIs need more: per-plan quotas, per-endpoint budgets, graceful degradation, and headers clients can actually parse. Laravel's RateLimiter facade gives you all of that — most teams just never reach for it.
Registering Named Limiters
Define limiters in AppServiceProvider::boot() (or a dedicated RateLimitServiceProvider).
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if ($user?->plan === 'enterprise') {
return Limit::none(); // unlimited
}
return Limit::perMinute($user?->rate_limit ?? 60)
->by($user?->id ?? $request->ip());
});
RateLimiter::for('search', function (Request $request) {
return [
Limit::perMinute(30)->by($request->user()?->id ?? $request->ip()),
Limit::perDay(5_000)->by($request->user()?->id ?? $request->ip()),
];
});
}
Returning an array of Limit objects enforces multiple windows simultaneously — a per-minute burst cap and a daily quota. Laravel checks all of them; the first exceeded limit triggers the 429.
Attaching Limiters to Routes
// routes/api.php
Route::middleware('auth:sanctum', 'throttle:api')
->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
Route::middleware('auth:sanctum', 'throttle:search')
->get('/search', [SearchController::class, 'index']);
The string passed to throttle: maps directly to the name you registered with RateLimiter::for().
Understanding the Response Headers
When a request is allowed, Laravel automatically appends:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 43
When the limit is hit (HTTP 429):
Retry-After: 47
X-RateLimit-Reset: 1718023847
Retry-After is in seconds; X-RateLimit-Reset is a Unix timestamp. Well-behaved clients should honour Retry-After before retrying. Document this contract explicitly — it saves support tickets.
Programmatic Checking Inside Business Logic
Sometimes you need to gate an expensive operation inside a controller or action, not at the routing layer.
use Illuminate\Support\Facades\RateLimiter;
class GenerateReportAction
{
public function execute(User $user): Report
{
$key = 'report-generation:' . $user->id;
if (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {
$seconds = RateLimiter::availableIn($key);
throw new TooManyRequestsException(
"Try again in {$seconds} seconds."
);
}
RateLimiter::hit($key, decaySeconds: 3600);
return Report::generate($user);
}
}
RateLimiter::hit() increments the counter and sets the TTL on first hit. availableIn() returns the seconds until the window resets — pipe that into your API error payload so clients can back off intelligently.
Clearing Limits on Successful Upgrade
When a user upgrades their plan mid-session, stale limits can frustrate them. Clear programmatically:
RateLimiter::clear('report-generation:' . $user->id);
This is also useful in tests — reset state between feature test cases rather than waiting for cache TTLs.
Testing Rate Limiters with Pest
use Illuminate\Support\Facades\RateLimiter;
it('returns 429 after exceeding the search limit', function () {
$user = User::factory()->create();
// Exhaust the limiter
foreach (range(1, 30) as $_) {
actingAs($user)->getJson('/api/search?q=test');
}
$response = actingAs($user)->getJson('/api/search?q=test');
$response->assertStatus(429)
->assertHeader('Retry-After');
});
beforeEach(fn () => RateLimiter::clear('search:' . auth()->id()));
Always clear the limiter in beforeEach — cache bleeds between tests otherwise.
Key Takeaways
- Use
RateLimiter::for()with named limiters instead of rawthrottle:N,Mfor any non-trivial API. - Return an array of
Limitobjects to enforce burst and daily quotas simultaneously. Retry-AfterandX-RateLimit-Resetare your client contract — document them.RateLimiter::hit()/tooManyAttempts()/availableIn()let you gate expensive operations inside business logic, not just at the HTTP layer.- Clear limiters in Pest's
beforeEachto prevent cache state leaking between tests.