Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
LaravelDevelopmentPHPTutorial

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.

Cloudsoft Zone

8 min read

Rows of server racks with blinking status lights inside a data centre aisle
Photograph: Carl Lender from Sunrise, USA / CC BY 2.0, via Wikimedia Commons
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 the boot method of App\Providers\AppServiceProvider. Each returns one or more Limit objects.
  • The throttle middleware, which you attach to routes as throttle:<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 limiter key in config/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.

Concentric-circle diagram of a Laravel request moving from index.php through the kernel, service providers and routing
A request passes index.php, the HTTP kernel, service providers and routing, where throttle middleware runs. Labels are in Portuguese.

Step 1: Enable API routing

A new Laravel 13 application does not include routes/api.php by default. Create it with:

Shell
php artisan install:api

This 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
<?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 as exports-minute: and exports-day: keep the counters separate.
  • Multiple limits are checked in order. The login limiter 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:

PHP
use Illuminate\Foundation\Configuration\Middleware;

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

Alternatively, apply limiters route by route in routes/api.php:

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:

PHP
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:

PHP
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:

PHP
->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:

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:

PHP
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
<?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.

Technician using a laptop while working on cables at an open server rack
Rate-limit counters must live in a cache store that every application server shares.

Checklist before you ship

CheckWhy
Every public route is covered by a limiterNew Laravel 13 API routes are not throttled by default
Login, registration, password reset and OTP routes have strict limitsThese are the usual targets of brute-force and abuse
Keys are prefixed and unique per limitPrevents two limits from sharing one counter
All servers share one cache store for countersPer-server counters multiply the real limit
Trusted proxies are configured if you sit behind a load balancer or CDNOtherwise $request->ip() may return the proxy address for every visitor
429 responses keep the rate-limit headersClients need Retry-After to back off correctly
Limits are documented for API consumersIntegrators 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 api middleware group contains only SubstituteBindings. Add throttleApi() in bootstrap/app.php or attach throttle: middleware to your routes.

  • What does Laravel return when a client exceeds the limit?

    An HTTP 429 Too Many Requests response with Retry-After and X-RateLimit-Reset headers. You can replace the body with your own JSON using the limit's response() method.

  • Where are rate-limit counters stored?

    In your application's default cache store, unless you set a limiter store in config/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() and availableIn() from the RateLimiter facade, for example to limit one-time password emails or SMS messages.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Routing: Rate Limiting (Laravel 13.x) — Laravel
  2. Rate Limiting (Laravel 13.x) — Laravel
  3. Middleware (Laravel 13.x) — Laravel
  4. ThrottleRequests middleware source, 13.x branch — Laravel (GitHub)
  5. Middleware configuration source (throttleApi), 13.x branch — Laravel (GitHub)
  6. laravel/laravel 13.x phpunit.xml — Laravel (GitHub)
  7. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Plush elePHPant toy from Heroku, a version of the PHP community mascot, photographed close up

    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

  • Blue plush elephant toy, the PHP community mascot known as the elePHPant

    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