Rate Limiting Laravel Routes and Actions the Right Way
Rate limiting protects your app from abuse, runaway loops, and expensive operations getting hammered. Laravel gives you two complementary tools: named limiters for routes and the RateLimiter facade for throttling any action you like. Here is how to use both well.
Named Route Limiters
Define limiters in a service provider, usually AppServiceProvider, using RateLimiter::for(). The name you give it becomes the argument to the throttle middleware.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
);
});
}
The by() call is the important part. It sets the key the limit is counted against, so authenticated users get their own 60-per-minute budget and everyone else is limited by IP address.
Attach it to routes with the throttle middleware:
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/reports', ReportController::class);
});
Dynamic Limits by Plan
Because the limiter is a closure, limits can depend on the user. This is a clean way to give paying customers higher throughput:
RateLimiter::for('uploads', function (Request $request) {
return $request->user()->onPlan('pro')
? Limit::perMinute(100)->by($request->user()->id)
: Limit::perMinute(10)->by($request->user()->id);
});
You can also return an array of limits to enforce several at once, for example a per-minute and a per-day cap:
return [
Limit::perMinute(30)->by($request->user()->id),
Limit::perDay(1000)->by($request->user()->id),
];
Throttling Any Action
Route middleware is not the only place limits are useful. The RateLimiter facade throttles arbitrary code. This is perfect for expensive operations like sending a verification email or calling a paid third-party API from the PHP Architect billing service.
use Illuminate\Support\Facades\RateLimiter;
$executed = RateLimiter::attempt(
key: 'send-verification:' . $user->id,
maxAttempts: 3,
callback: fn () => $user->sendVerificationEmail(),
decaySeconds: 300,
);
if (! $executed) {
return back()->withErrors('Too many attempts. Try again in a few minutes.');
}
attempt() runs the callback only if the limit has not been hit, and returns false otherwise. For finer control you can reach for tooManyAttempts(), hit(), remaining(), and availableIn() directly.
A Couple of Gotchas
Rate limit counters live in your cache store, so in production use a shared, persistent store like Redis. The default array cache used in tests resets every request, and a per-server file cache will not coordinate limits across multiple app servers.
Also give your limiter keys a stable, unique prefix. Using something like send-verification:{id} rather than a bare {id} prevents two unrelated limiters from accidentally sharing a counter.
Done right, rate limiting is a small amount of code that saves you from a surprising number of production incidents, from brute-force login attempts to a single client accidentally DDoSing your API.
Further Reading
- Laravel documentation, "Rate Limiting" (laravel.com/docs/13.x/rate-limiting)
- Laravel documentation, "Routing", Rate Limiting (laravel.com/docs/13.x/routing#rate-limiting)