How to add rate limiting to a Laravel 13 API
A step-by-step guide to protecting a Laravel 13 API with named rate limiters: per-user and per-IP limits, strict login limits, JSON 429 responses, Redis counters, limits on non-HTTP actions and feature tests.
8 min read

In this article
An API without rate limits will eventually be hit by something it did not plan for: a script stuck in a retry loop, a scraper, a credential-stuffing run against your login endpoint, or simply one customer whose integration sends far more traffic than everyone else. Each of these can slow the service down for every other user, raise your hosting bill, or turn into a security incident.
Laravel ships with a rate limiter that covers most of these cases without extra packages. This tutorial shows how to use it in a Laravel 13 API: defining named limiters, attaching them to routes, giving logged-in users and anonymous visitors different limits, protecting the login route, returning a clean JSON error, moving the counters to Redis, and testing that the limits really work. Everything here was checked against the Laravel 13.x documentation and the framework source on 7 October 2026.
Laravel 13 requires PHP 8.3 or newer. The examples assume a standard application created with the Laravel installer, with API routing enabled.
How Laravel rate limiting works
Laravel's rate limiter counts attempts against a key inside a time window and stores the counter in your application's cache. When the count for a key goes over the allowed maximum, the throttle middleware stops the request and returns an HTTP 429 Too Many Requests response.
There are three building blocks:
- Named limiters, defined with
RateLimiter::for(), usually in thebootmethod ofApp\Providers\AppServiceProvider. Each returns one or moreLimitobjects. - The
throttlemiddleware, which you attach to routes asthrottle:<limiter-name>. - The cache store that holds the counters. By default this is your default cache store; you can point the limiter at a different store with a
limiterkey inconfig/cache.php.
The Limit class offers perSecond(), perMinute(), perMinutes(), perHour(), perDay() and none(), plus by() to choose the key, response() to customise the 429 response and after() to count only some responses.
When the middleware runs, it also adds X-RateLimit-Limit and X-RateLimit-Remaining headers to responses. When a client is blocked, the 429 response carries Retry-After and X-RateLimit-Reset headers so a well-behaved client knows when to try again.

Step 1: Enable API routing
A new Laravel 13 application does not include routes/api.php by default. Create it with:
php artisan install:apiThis installs Laravel Sanctum for token authentication and creates routes/api.php. Routes in that file are stateless, belong to the api middleware group and receive the /api prefix automatically.
One detail matters for this tutorial: according to the middleware documentation, the default api group contains only SubstituteBindings. In other words, a fresh Laravel 13 API is not rate limited until you add throttling yourself.
Step 2: Define your limiters
Open app/Providers/AppServiceProvider.php and define the limiters in boot(). The example below creates three:
api: a general limit for every API route, 60 requests per minute per user, or per IP address for guests.login: a strict limit for the login route, keyed by email address and IP address.exports: an expensive endpoint that gets both a per-minute and a per-day limit.
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by('user:'.$request->user()->id)
: Limit::perMinute(30)->by('ip:'.$request->ip());
});
RateLimiter::for('login', function (Request $request) {
$email = strtolower((string) $request->input('email'));
return [
Limit::perMinute(500),
Limit::perMinute(5)->by('login:'.$email.'|'.$request->ip()),
];
});
RateLimiter::for('exports', function (Request $request) {
return [
Limit::perMinute(2)->by('exports-minute:'.$request->user()->id),
Limit::perDay(20)->by('exports-day:'.$request->user()->id),
];
});
}
}A few points worth noting:
- Prefix your keys. When a limiter returns several limits keyed by the same value, the Laravel documentation says each
by()value must be unique. Prefixes such asexports-minute:andexports-day:keep the counters separate. - Multiple limits are checked in order. The
loginlimiter caps total login traffic at 500 per minute and also caps each email-and-IP pair at 5 per minute. - Different limits for guests and users. Because the closure receives the request, you can decide the limit at run time, for example from the user's plan.
The numbers above are examples. Choose limits from your own traffic data: look at what a normal heavy user does in a minute and set the limit comfortably above it.
Step 3: Attach the limiters to routes
There are two ways to apply the general api limiter. To add it to every route in routes/api.php, call throttleApi() in bootstrap/app.php. In the Laravel 13 framework source this method adds throttle:api to the api middleware group:
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->throttleApi();
})Alternatively, apply limiters route by route in routes/api.php:
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ReportExportController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::post('/reports/export', ReportExportController::class)
->middleware('throttle:exports');
});Order matters. The exports limiter calls $request->user()->id, so it must run after auth:sanctum has authenticated the request. Placing it inside the authenticated group guarantees that. Also note that the export route here is subject to both the general api limiter and the exports limiter; a request must pass both.
Do not combine throttleApi() with a route-level throttle:api on the same routes, or each request will be counted twice against the same limiter.
Step 4: Return a clear JSON error
By default Laravel returns a 429 response automatically. For a public API it often helps to return a consistent JSON body that matches your other errors. Use response() on the limit and pass the headers through so clients still receive Retry-After:
RateLimiter::for('api', function (Request $request) {
$limit = $request->user()
? Limit::perMinute(120)->by('user:'.$request->user()->id)
: Limit::perMinute(30)->by('ip:'.$request->ip());
return $limit->response(function (Request $request, array $headers) {
return response()->json([
'error' => [
'code' => 'RATE_LIMITED',
'message' => 'Too many requests. Please retry later.',
],
], 429, $headers);
});
});Document the limits and the Retry-After behaviour in your API reference so integrators can build polite retry logic.
Step 5: Count only the responses that matter
Sometimes you want to limit failures rather than all traffic. A common case is resource enumeration: someone requesting /api/orders/1, /api/orders/2 and so on, looking for records that exist. The after() method receives the response and returns true when it should count:
use Symfony\Component\HttpFoundation\Response;
RateLimiter::for('lookups', function (Request $request) {
return Limit::perMinute(10)
->by($request->user()?->id ?: $request->ip())
->after(function (Response $response) {
return $response->getStatusCode() === 404;
});
});With this limiter, normal successful lookups are not counted, but ten 404 responses in a minute block the client.
Step 6: Use Redis for the counters
The rate limiter stores its counters in the cache. On a single small server the default database cache store works. Once you run several application servers, or traffic grows, Redis is the usual choice. If Redis is your cache driver, Laravel can switch the throttle middleware to its Redis-specific implementation:
->withMiddleware(function (Middleware $middleware): void {
$middleware->throttleApi(redis: true);
// or, if you attach limiters per route:
// $middleware->throttleWithRedis();
})If you want the counters in Redis while keeping a different default cache, set the limiter store in config/cache.php:
'default' => env('CACHE_STORE', 'database'),
'limiter' => 'redis',Whatever you choose, every application server must share the same store. If each server keeps its own counters, a client can multiply its allowance by the number of servers.
Limiting actions that are not HTTP routes
The same limiter works for actions inside your code, such as sending one-time passwords by SMS or email. RateLimiter::attempt() runs a callback only if the key still has attempts left, and returns false otherwise:
use Illuminate\Support\Facades\RateLimiter;
$sent = RateLimiter::attempt(
'send-otp:'.$user->id,
3,
function () use ($user) {
$user->sendOneTimePassword();
},
600,
);
if (! $sent) {
$seconds = RateLimiter::availableIn('send-otp:'.$user->id);
return response()->json([
'error' => ['code' => 'RATE_LIMITED', 'message' => "Try again in {$seconds} seconds."],
], 429);
}Here sendOneTimePassword() stands for your own method. The fourth argument is the decay time in seconds, so the user can request three codes every ten minutes. For endpoints with many concurrent requests, the documentation recommends checking the value returned by RateLimiter::increment(), which is atomic on the Redis, Memcached and database cache stores.
Step 7: Test the limits
The default Laravel 13 phpunit.xml sets CACHE_STORE to array, so counters start empty for each test. A feature test can send requests until the limit is reached and assert the 429:
<?php
namespace Tests\Feature;
use Tests\TestCase;
class LoginRateLimitTest extends TestCase
{
public function test_login_is_blocked_after_five_attempts(): void
{
$payload = ['email' => 'reader@example.com', 'password' => 'wrong-password'];
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/login', $payload);
}
$this->postJson('/api/login', $payload)
->assertStatus(429)
->assertHeader('Retry-After');
}
}The five failed attempts count toward the limit because the login limiter does not use after(). If your login controller needs database tables, add the RefreshDatabase trait as you normally would.

Checklist before you ship
| Check | Why |
|---|---|
| Every public route is covered by a limiter | New Laravel 13 API routes are not throttled by default |
| Login, registration, password reset and OTP routes have strict limits | These are the usual targets of brute-force and abuse |
| Keys are prefixed and unique per limit | Prevents two limits from sharing one counter |
| All servers share one cache store for counters | Per-server counters multiply the real limit |
| Trusted proxies are configured if you sit behind a load balancer or CDN | Otherwise $request->ip() may return the proxy address for every visitor |
| 429 responses keep the rate-limit headers | Clients need Retry-After to back off correctly |
| Limits are documented for API consumers | Integrators can design around them instead of guessing |
The trusted-proxy point deserves attention. If the application only ever sees your load balancer's IP address, every guest shares one counter and a single busy minute can lock everyone out.
Summary
Rate limiting in Laravel 13 takes a few lines: define named limiters with RateLimiter::for(), attach them with throttle: middleware or throttleApi(), key them sensibly, and store the counters somewhere all servers share. Start with a general API limit plus strict limits on authentication routes, return a clear JSON 429, and add a feature test so a later refactor cannot remove the protection silently. Then review the limits against real traffic after launch and adjust.
Frequently asked questions
Does a new Laravel 13 API have rate limiting by default?
No. According to the Laravel 13 middleware documentation, the default
apimiddleware group contains onlySubstituteBindings. AddthrottleApi()inbootstrap/app.phpor attachthrottle:middleware to your routes.What does Laravel return when a client exceeds the limit?
An HTTP 429 Too Many Requests response with
Retry-AfterandX-RateLimit-Resetheaders. You can replace the body with your own JSON using the limit'sresponse()method.Where are rate-limit counters stored?
In your application's default cache store, unless you set a
limiterstore inconfig/cache.php. With several servers, use a shared store such as Redis.Can I rate limit something that is not a route?
Yes. Use
RateLimiter::attempt(),tooManyAttempts(),increment()andavailableIn()from theRateLimiterfacade, for example to limit one-time password emails or SMS messages.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Routing: Rate Limiting (Laravel 13.x) — Laravel
- Rate Limiting (Laravel 13.x) — Laravel
- Middleware (Laravel 13.x) — Laravel
- ThrottleRequests middleware source, 13.x branch — Laravel (GitHub)
- Middleware configuration source (throttleApi), 13.x branch — Laravel (GitHub)
- laravel/laravel 13.x phpunit.xml — Laravel (GitHub)
- Release Notes (Laravel 13.x) — Laravel
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Laravel
Upgrading from Laravel 12 to Laravel 13: a practical guide
Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.
By Cloudsoft Zone

ComparisonLaravel
Laravel Sanctum vs Passport: which API authentication package to use
Sanctum and Passport both authenticate Laravel APIs, but they solve different problems. This comparison explains tokens, SPA cookies, OAuth2 grants, expiry and clean-up in Laravel 13, and when each package is the right choice.
By Cloudsoft Zone

Resource guideResources
Cron syntax cheat sheet: crontab, GitHub Actions, Kubernetes
Cron syntax with tested expressions, the day-of-week trap, and how time zones and limits differ in crontab, GitHub Actions, Kubernetes and Laravel in 2026.
By Cloudsoft Zone
