In one sentence

Rate limiting caps how many requests a user or IP address can make in a time window, so password guessers, scrapers and buggy scripts get a polite β€œslow down” (429) instead of your server's full attention.

🎒It's the ride attendant at a theme park: everyone gets a turn, but nobody can jump back in line twenty times a minute. Wait your turn, and you're welcome back.

Why it matters

Not every visitor plays fair

πŸ”

Stops password guessing

Five tries a minute turns a million-guess attack from minutes into years.

🧯

Protects your server

One runaway script can't use up all your CPU, database connections or email quota.

βš–οΈ

Fair for everyone

API users share capacity fairly, and you can give paying customers higher limits.

The attack

Brute force without a limit

😈Attackerscript with a list of common passwords
πŸ”POST /loginthousands of attempts per minute
πŸ™‚Victimused β€œsummer2024” as their password
πŸ’₯Effectaccount guessed and taken over
Without a limit, the only thing slowing the attacker down is your server's speed.
The defence

A counter in front of your route

πŸ“¨RequestPOST /login from 203.0.113.7
🚦throttle middlewarecounts hits for this key in the cache
βœ…Under the limitcontinue to the controller
⏳Over the limit429 Too Many Requests + Retry-After
Each limiter has a key (who is being counted: a user ID, an IP, or both) and a window (per minute, hour or day). Counts live in your cache store.
Step by step

Add rate limits to your app

  1. Quick limit with throttle:max,minutes

    The fastest way to limit a route is the throttle middleware with two numbers: how many requests, per how many minutes.

    
              Route::post('/contact', [ContactController::class, 'send'])
                  ->middleware('throttle:3,1');   // 3 requests per 1 minute
            

    The fourth submission within a minute gets a 429 Too Many Requests. Logged-in users are counted by user ID; guests by IP address.

  2. Define named limiters with RateLimiter::for

    For anything more than a one-off, define a named limiter in AppServiceProvider. It's a closure that receives the request and returns a Limit.

    
              use Illuminate\Cache\RateLimiting\Limit;
              use Illuminate\Http\Request;
              use Illuminate\Support\Facades\RateLimiter;
    
              public function boot(): void
              {
                  RateLimiter::for('comments', function (Request $request) {
                      return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
                  });
    
                  RateLimiter::for('api', function (Request $request) {
                      return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
                  });
              }
            

    ->by(...) sets the key. Other windows include Limit::perSecond(), perHour() and perDay().

  3. Attach a named limiter with throttle:name

    
              Route::post('/posts/{post}/comments', [CommentController::class, 'store'])
                  ->middleware(['auth', 'throttle:comments']);
            
    
              Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
                  Route::apiResource('posts', Api\PostController::class);
              });
            

    API responses also include X-RateLimit-Limit and X-RateLimit-Remaining headers so well-behaved clients can pace themselves.

  4. Smarter limits: per plan, multiple limits, custom responses

    Because the limiter is just PHP, you can return different limits for different users, or several limits at once.

    
              RateLimiter::for('uploads', function (Request $request) {
                  if ($request->user()?->isPro()) {
                      return Limit::none();
                  }
    
                  return [
                      Limit::perMinute(10)->by($request->user()?->id ?: $request->ip()),
                      Limit::perDay(100)->by($request->user()?->id ?: $request->ip()),
                  ];
              });
            

    Add ->response(fn () => response('Slow down!', 429)) to a Limit to customise what blocked clients see.

  5. Throttle logins (already done for you)

    The login code in Laravel's starter kits (Breeze and the newer kits) and in Fortify already limits failed attempts: 5 tries, keyed by email + IP, then a lockout with a "Too many login attempts. Please try again in N seconds." message. Here's the pattern, so you can use it in your own code:

    
              use Illuminate\Support\Facades\RateLimiter;
              use Illuminate\Support\Str;
    
              $key = Str::transliterate(Str::lower($this->input('email')) . '|' . $this->ip());
    
              if (RateLimiter::tooManyAttempts($key, 5)) {
                  $seconds = RateLimiter::availableIn($key);
                  throw ValidationException::withMessages([
                      'email' => "Too many login attempts. Try again in {$seconds} seconds.",
                  ]);
              }
    
              if (! Auth::attempt($this->only('email', 'password'))) {
                  RateLimiter::hit($key);             // count the failure (60s decay)
                  throw ValidationException::withMessages(['email' => __('auth.failed')]);
              }
    
              RateLimiter::clear($key);               // success: reset the counter
            
    ℹ️ Why email + IP?

    Keying only by IP lets an attacker try one password against many accounts. Keying only by email lets anyone lock a real user out. Combining both limits guessing without making lockouts easy to abuse.

Common mistakes

Avoid this, do that

❌ Avoid

          Route::post('/password/email', ...);
          Route::post('/register', ...);
          // no limits at all
        

Endpoints that send email or create accounts are favourite targets for spam and abuse.

βœ… Do

          Route::post('/password/email', ...)
              ->middleware('throttle:6,1');
        

Limit anything that sends messages, creates records or checks secrets.

❌ Avoid

          Limit::perMinute(5)->by('global');
        

A single shared key means one noisy client blocks everyone.

βœ… Do

          Limit::perMinute(5)
              ->by($request->user()?->id ?: $request->ip());
        

Key per user (or per IP for guests) so limits apply to each client separately.

πŸ’‘ Pro tip: use a shared cache in production

Rate limit counters live in your default cache store. If you run several servers, use Redis or the database cache so every server sees the same counts. The file store only counts per machine.

πŸ‹οΈ Try it yourself
  1. Add throttle:3,1 to a test route and refresh it four times quickly. Note the 429 page.
  2. Define a comments limiter in AppServiceProvider (5 per minute per user) and attach it to your comment route.
  3. Hit an API route with curl -i and find the X-RateLimit-Remaining header.
  4. With a starter kit installed, enter a wrong password six times and read the lockout message.
Recap

Key takeaways

Check yourself

Mini quiz