Beyond throttle:60,1
The built-in throttle middleware is fine for a quick demo, but production APIs need rate-limiting that reflects business rules: free-tier users get 100 requests/minute, paid users get 2 000, and certain endpoints — like password reset — have their own hard caps regardless of tier.
Laravel's RateLimiter facade, introduced in Laravel 8 and refined since, gives you exactly that control.
Defining Named Limiters in a Service Provider
Register all 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
{
// Tier-aware API limiter
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if (! $user) {
return Limit::perMinute(30)->by($request->ip());
}
return match ($user->plan) {
'enterprise' => Limit::none(),
'pro' => Limit::perMinute(2000)->by($user->id),
default => Limit::perMinute(100)->by($user->id),
};
});
// Hard cap on auth-sensitive endpoints
RateLimiter::for('auth-sensitive', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perHour(20)->by($request->ip()),
];
});
}
Returning an array of Limit objects lets you stack multiple windows on a single route — both must pass.
Attaching Limiters to Routes
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])
->group(function () {
Route::get('/widgets', [WidgetController::class, 'index']);
});
Route::post('/forgot-password', [PasswordController::class, 'store'])
->middleware('throttle:auth-sensitive');
The string passed to throttle: maps directly to the name registered with RateLimiter::for.
Consistent Response Headers
Laravel automatically adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After when a limiter is hit. But for clients that need to proactively back off, you want those headers on every response, not just 429s.
Add a middleware that reads the current hit count without incrementing it:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class AddRateLimitHeaders
{
public function handle(Request $request, Closure $next, string $limiterName = 'api'): Response
{
$response = $next($request);
$limiter = RateLimiter::limiter($limiterName);
/** @var Limit $limit */
$limit = value($limiter, $request);
if ($limit instanceof Limit) {
$key = $limit->key;
$maxAttempts = $limit->maxAttempts;
$remaining = max(0, $maxAttempts - RateLimiter::attempts($key));
$response->headers->set('X-RateLimit-Limit', $maxAttempts);
$response->headers->set('X-RateLimit-Remaining', $remaining);
}
return $response;
}
}
Register it after throttle in the middleware stack so it runs on successful responses too.
Handling 429 Responses Gracefully
The default 429 response is an HTML page. Override it in your exception handler:
// bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (
\Illuminate\Http\Exceptions\ThrottleRequestsException $e,
Request $request
) {
return response()->json([
'error' => 'rate_limit_exceeded',
'retry_after' => $e->getHeaders()['Retry-After'] ?? null,
], 429, $e->getHeaders());
});
})
Passing $e->getHeaders() ensures Retry-After and X-RateLimit-* headers survive the custom renderer.
Testing Limiters with Pest
use Illuminate\Support\Facades\RateLimiter;
it('blocks free-tier users after 100 requests per minute', function () {
$user = User::factory()->create(['plan' => 'free']);
// Exhaust the limit without real HTTP overhead
RateLimiter::hit('100|' . $user->id, 60, 100);
$this->actingAs($user)
->getJson('/api/widgets')
->assertStatus(429)
->assertJsonFragment(['error' => 'rate_limit_exceeded']);
});
it('does not throttle enterprise users', function () {
$user = User::factory()->create(['plan' => 'enterprise']);
$this->actingAs($user)
->getJson('/api/widgets')
->assertOk();
});
RateLimiter::hit lets you seed the counter directly, avoiding 100 actual HTTP calls in your test suite.
Key Takeaways
- Use
RateLimiter::forwith closures to express business-tier logic, not magic middleware strings. - Return an array of
Limitobjects to enforce multiple windows (per-minute and per-hour) simultaneously. - Always emit
X-RateLimit-*headers on successful responses so clients can self-throttle. - Override the 429 renderer to return JSON with the
Retry-Afterheader intact. - Seed
RateLimiter::hitin tests to simulate exhausted limits without real HTTP loops.