Laravel rate limiting works in two steps. You define a named limiter with RateLimiter::for(), then attach it to routes with the throttle middleware, which returns a 429 once a client uses up its budget. To do it properly, tie each limit to a user, token, or IP, keep the counters in a shared cache like Redis, and test the 429 before you ship.

Here's the uncomfortable part. If your limiter counts every request against your load balancer's IP, it doesn't protect anything. Your noisiest client can use it to block everyone else.

You already know your API needs a throttle. What most tutorials skip is how the limiter behaves after you deploy. Each Laravel version puts the code in a different place, one missing by() call merges every user into a single bucket, and it matters what clients see when they hit the limit. This guide covers the Laravel rate limiting setup for Laravel 9 through 13, the production mistakes that quietly break it, and a Pest test that proves it works.

Key Takeaways New Laravel 11, 12, and 13 apps define limiters in AppServiceProvider::boot(). The default api middleware group doesn't throttle anything until you call $middleware->throttleApi() or add throttle:api. A Limit without by() is one global bucket shared by every client. Tie each limit to a user ID, team, or IP, and give each one a prefix. Laravel counts hits in a fixed window stored in your cache. APIs running on more than one server need Redis or another shared store, plus a per-second limit to smooth out bursts. Behind a load balancer, $request->ip() returns the balancer's address until you configure trusted proxies. Throttled responses carry X-RateLimit-Limit and X-RateLimit-Remaining. A 429 also adds Retry-After, and a short Pest test can check all three.

How Laravel rate limiting works under the hood

Laravel rate limiting is built from three pieces. A named limiter returns one or more Limit objects, the throttle middleware enforces them, and your cache stores the counters. Each request adds one to the counter for its bucket. Once the count passes the limit, Laravel stops the request before your controller runs and returns 429 Too Many Requests.

Here's the smallest useful version, placed in App\Providers\AppServiceProvider:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
	RateLimiter::for('api', function (Request $request) {
		if ($request->user()) {
			return Limit::perMinute(120)->by('user:'.$request->user()->id);
		}

		return Limit::perMinute(30)->by('ip:'.$request->ip());
	});
}

Signed-in users get 120 requests per minute, counted against their user ID. Guests get 30, counted against their IP. The user: and ip: prefixes keep the two groups in separate buckets. The official Laravel rate limiting docs list the other builders, including perHour(), perDay(), and none(), and Laravel 11 added perSecond() for finer windows.

Named limiters vs inline throttles

You'll also see the inline form, throttle:60,1, which allows 60 requests per one-minute window. It counts per signed-in user or per client IP, not per route. So every route that uses an inline throttle shares one counter for the same client, unless you pass a prefix as the third argument. Named limiters include the limiter name in the cache entry, so throttle:api and throttle:uploads never collide.

Want a working API to experiment on instead of an empty routes/api.php? Get started free with StackMivo. Describe the app in plain English, then review the production-ready Laravel app it builds, from Eloquent models to Pest tests.

Where Laravel rate limiting lives in each version

On new projects, the most common Laravel rate limiting bug isn't a wrong number. It's a Laravel throttle that never runs. Laravel 11 slimmed down the application skeleton, and in new 11, 12, and 13 apps the api middleware group contains only SubstituteBindings. Older tutorials assume the throttle is already there.

Laravel version

Where limiters live

API group throttled by default?

How to turn it on

9 and 10

App\Providers\RouteServiceProvider

Yes, throttle:api is in app/Http/Kernel.php

Already on, so just edit the api limiter

11, 12, and 13 (new skeleton)

AppServiceProvider::boot()

No

$middleware->throttleApi() or throttle:api on route groups

On a fresh Laravel 11+ app, php artisan install:api creates routes/api.php and installs Sanctum. Then turn on throttling for the group in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
	$middleware->throttleApi();
})

Prefer explicit control? Skip throttleApi() and attach limiters to the routes that need them. Put auth on the same group so the limiter can see the user:

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
	Route::get('/orders', [OrderController::class, 'index']);
	Route::post('/orders', [OrderController::class, 'store'])->middleware('throttle:writes');
});

The store route counts against both the api budget and a stricter writes limiter, which you define the same way.

Apps upgraded from older versions can keep the old Kernel and RouteServiceProvider, so check which structure you have before you copy a snippet. Generic AI assistants often get this wrong and suggest a RouteServiceProvider that a Laravel 11 app doesn't have. With StackMivo's Laravel-native intelligence, you set a target version (Laravel 9 through 12) in project settings, so the generated code matches the app you run.

Choosing the right key for every Laravel throttle

The by() call decides who shares a bucket, and that's the whole point of Laravel rate limiting. Leave it out and Limit::perMinute(60) becomes one global counter for every user you have. Your first busy customer uses up the budget, and everyone else gets 429s.

A missing by() still passes any test that uses a single user, which is why it slips through review. It's the kind of one-line bug that AI code review tools for PHP should flag on any change to AppServiceProvider.

  • Authenticated users: count by user ID. This is the fairest default for first-party apps.

  • Tenants: count by team or account ID when all of a customer's users should share one budget.

  • Guests: count by IP and keep the limit low, because an office or a mobile carrier can put hundreds of people behind one address.

  • Logins: count by email plus IP, so an attacker on another network can't lock a real user out.

Tiered limits for SaaS plans

Limits based on plan tier turn a defensive feature into a pricing lever. This example assumes a string plan column and a team_id on users. A match keeps the rules readable:

RateLimiter::for('api', function (Request $request) {
	$user = $request->user();

	if (! $user) {
		return Limit::perMinute(30)->by('ip:'.$request->ip());
	}

	return match ($user->plan) {
		'pro' => Limit::perMinute(600)->by('user:'.$user->id),
		'team' => Limit::perMinute(1200)->by('team:'.$user->team_id),
		default => Limit::perMinute(60)->by('user:'.$user->id),
	};
});

The team tier counts by team_id, so every seat on a team draws from one shared pool. That's the behavior you want in a multi-tenant SaaS, where one tenant's batch import shouldn't slow down another tenant. Don't have plan data yet? The php artisan make:model cheatsheet covers the flags that create a Plan model, migration, and factory in one command.

Counting only the responses that matter

Newer Laravel 12 releases and Laravel 13 add an after() method. It looks at each response and decides whether that request counts toward the limit. That makes it a good fit for stopping ID guessing, where only failed lookups should use up the budget:

use Symfony\Component\HttpFoundation\Response;

RateLimiter::for('lookups', function (Request $request) {
	return Limit::perMinute(10)
		->by('lookup:'.$request->ip())
		->after(function (Response $response) {
			// Only 404s count, which slows down ID guessing.
			return $response->getStatusCode() === 404;
		});
});

Limiting actions, not just routes

Not every limit belongs on a route. Take capping one-time codes at three per 10 minutes. That's a job for the RateLimiter facade, called from your controller (OneTimeCode here stands in for your own notification class):

$sent = RateLimiter::attempt('otp:'.$user->id, 3, function () use ($user) {
	$user->notify(new OneTimeCode());
}, 600);

if (! $sent) {
	$seconds = RateLimiter::availableIn('otp:'.$user->id);

	return response()->json(['message' => "Try again in {$seconds} seconds."], 429);
}

The fourth argument is the window length in seconds, and attempt() returns false once the budget runs out. The RateLimiter facade docs also cover increment(). It returns the new count, and on Redis, Memcached, and database stores it updates the count atomically, so simultaneous requests can't slip past the limit.

Running a Laravel API rate limit in production

Laravel rate limiting is only as accurate as the store behind it. Unless you choose another store, the limiter uses your default cache, and new Laravel 11+ apps default to the database store.

Cache store

Shared across servers?

Fit for rate limiting

array

No, it lasts for a single request

Tests only

file

No, each server keeps its own count

Single-server apps

database

Yes

Works, but every request adds a database write

redis or memcached

Yes

The right default for production APIs

Say you use the file store on three servers. A client whose requests are spread across all three gets three times the limit you set. To fix it, point the limiter at Redis with the limiter key in config/cache.php:

'default' => env('CACHE_STORE', 'database'),

'limiter' => env('RATE_LIMITER_STORE', 'redis'),

If Redis is already your main cache store, call $middleware->throttleWithRedis() in bootstrap/app.php. It replaces the throttle middleware with a version built directly on Redis.

Fixed windows and burst traffic

Laravel counts hits in a fixed window. The window opens with the first request, lasts for the decay period, and then resets all at once. That means a client can use a full budget at the end of one window and another full budget at the start of the next.

Picture an agency developer who ships a public search endpoint limited to 60 requests per minute and moves on. Within a day, a scraper figures out the window: 60 requests in the last second of one window, then 60 more in the first second of the next. That's 120 slow LIKE queries in about two seconds, and the database CPU spikes on every cycle. A per-second limit next to the per-minute one flattens the bursts, and real users, who never send more than two searches a second, don't notice.

RateLimiter::for('search', function (Request $request) {
	return [
		Limit::perSecond(5)->by('search-second:'.$request->ip()),
		Limit::perMinute(60)->by('search-minute:'.$request->ip()),
	];
});

Don't skip the prefixes. If two limits in one limiter use the same by() value, they share a counter, and the Laravel docs warn about exactly this.

Trusted proxies decide what an IP means

Here's another common failure. A two-person team launches a mobile app on a Laravel API behind an AWS load balancer, with guests limited to 30 requests per minute per IP. On launch morning, sign-ups stall. Every request reaches Laravel with the balancer's private address, so the entire user base shares one 30-request bucket and most people see 429s within seconds. The fix is small: trust the balancer so $request->ip() returns the real client address, and count signed-in users by ID instead of IP.

In Laravel 11 and later, you configure trusted proxies in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
	$middleware->trustProxies(at: [
		'10.0.0.0/8',
	]);
})

Where you can, list your balancer's IP ranges instead of '*'. Trusting every proxy is only safe if clients can't reach your servers directly. Otherwise, anyone can send a fake X-Forwarded-For header with a new made-up IP each time and get around your per-IP limits.

Put a coarse limit at the edge too

Laravel's limiter only runs after PHP has started up the framework, so even requests it rejects still tie up a PHP worker. It protects your database and business logic, not your bandwidth. OWASP's API Security Top 10 lists unrestricted resource consumption as a major API risk. The practical answer is two layers: a blunt per-IP ceiling at Nginx, your CDN, or a WAF, and user-aware, plan-aware limits in Laravel.

Returning a 429 your API clients can act on

Good Laravel rate limiting tells clients where they stand before they fail. Every throttled response carries X-RateLimit-Limit and X-RateLimit-Remaining. When Laravel blocks a request, it adds Retry-After (seconds to wait) and X-RateLimit-Reset (a Unix timestamp), and JSON clients get a message of "Too Many Attempts."

That default works, but a consistent error format is kinder to the people writing client libraries for your API. On an authenticated route, chain response() onto the limit and pass the headers through:

RateLimiter::for('api', function (Request $request) {
	return Limit::perMinute(60)
		->by('user:'.$request->user()->id)
		->response(function (Request $request, array $headers) {
			return response()->json([
				'error' => 'rate_limited',
				'retry_after' => $headers['Retry-After'],
			], 429, $headers);
		});
});

Keep $headers in the response. If you drop them, you lose Retry-After, which MDN's 429 reference describes as the header that tells a client how long to wait. Also publish your limits for each plan, so developers using your API can watch X-RateLimit-Remaining and slow down before they ever get a 429.

Testing Laravel rate limiting with Pest

A limiter you haven't tested is a guess. The Pest test below checks the limit, the headers, and the reset. It assumes /api/orders sits behind auth:sanctum with a 60-per-minute limit for the default plan:

use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('throttles orders after 60 requests a minute', function () {
	Sanctum::actingAs(User::factory()->create());

	for ($i = 0; $i < 60; $i++) {
		$this->getJson('/api/orders')->assertOk();
	}

	$this->getJson('/api/orders')
		->assertStatus(429)
		->assertHeader('Retry-After')
		->assertHeader('X-RateLimit-Remaining', '0');

	$this->travel(61)->seconds();

	$this->getJson('/api/orders')->assertOk();
});

Laravel's default phpunit.xml uses the array cache, which keeps counters only for the length of one test. If you pointed the limiter setting at Redis through RATE_LIMITER_STORE, set that variable to array in phpunit.xml as well. Otherwise, counters carry over between tests through the shared Redis, and you get flaky failures that depend on test order.

Would you rather start from an app where the routes, auth, and tests already exist? Try StackMivo Now to generate a production-ready Laravel app from a prompt. Then add your limiters and this test to code you own.

Start with one limiter and one test

Laravel rate limiting done right comes down to a handful of decisions. Put the limiter where your Laravel version expects it, and make sure the api group is actually throttled. Tie every limit to a user, team, or IP, with prefixes that keep buckets apart. Keep counters in Redis once you run more than one server, and add a per-second cap next to per-minute limits where bursts hurt.

Then make your limits visible and prove they work. Pass the rate limit headers through to clients, trust only the proxies you control, and keep a coarse ceiling at the edge. Finally, commit a Pest test that hits the limit, checks the 429, and moves the clock past the window.

Pick your busiest endpoint today and give it a named limiter and that test. Under load, your API will turn away extra requests with a clear 429 instead of falling over, and you'll know exactly why.