Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 043 / 100

Laravel API rate limiting: practical tips and trade-offs

Protect APIs from abuse.

Expona Admin6 min read

Understanding API Rate Limiting and Why It Matters

Your API is under constant attack—not always from malicious actors, but from poorly designed clients, runaway scripts, and the occasional bot that doesn't know when to stop. Laravel API rate limiting: practical tips and trade-offs is a cornerstone of production API design, separating healthy traffic management from chaotic outages. Without it, a single misbehaving client can exhaust your database connections, spike your AWS bill, or lock out legitimate users entirely.

Rate limiting isn't a one-size-fits-all solution. The strategy you choose depends on your user base, authentication model, and infrastructure. We've built APIs across e-commerce platforms, ride-hailing services, and SaaS products, and we've learned that getting rate limiting right requires balancing protection with user experience. In this guide, we'll walk you through defining limiters, choosing the right strategy, and avoiding the pitfalls we've encountered in production.

Defining Rate Limiters in Service Providers

Rate limiters live in Laravel's service provider layer, typically defined in the RouteServiceProvider or a dedicated provider. This is where you declare your limiting rules before they're applied to routes.

The standard approach is to use the RateLimiter::for() method within the boot() method of a provider:

php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

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

This declares an 'api' limiter that allows 60 requests per minute. The by() method sets the identifier—we'll cover that in the next section. You can define as many limiters as you need, each with its own rules and scope.

The closure receives the Request object, so you have full access to headers, user data, and context. This flexibility is crucial when building sophisticated rate limiting strategies. You might want a different limit for premium users, or to bypass limits for internal services. The closure makes that possible.

Laravel also provides perSecond(), perHour(), and perDay() helpers. Most APIs use perMinute() as a sensible default—it's fast enough to catch abuse but lenient enough for legitimate bursts in normal traffic.

Choosing Per-User or Per-IP Limiting

The identifier you pass to by() is the most important decision in rate limiting strategy. It determines what you're limiting: individual users, IP addresses, or something else entirely.

Authenticated Users

For authenticated requests, limit by user ID. This ensures that each user account gets its own quota, regardless of where they're making requests from. If Alice has a mobile app and a desktop app, both draw from the same 60 requests per minute.

php
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id);
});

In production, we've seen this work well for SaaS platforms where users expect a predictable quota. A user switching between office WiFi and mobile data won't suddenly hit limits twice as fast. The trade-off is that you're storing rate limit state in Redis keyed by user ID, which works well at scale but requires a reliable cache backend.

Guest or IP-Based Limiting

For unauthenticated requests or public endpoints, fall back to IP-based limiting:

php
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->ip());
});

This protects your API from anonymous abuse. The catch: IP-based limiting can be problematic in shared network environments. If your users are behind a corporate proxy or NAT gateway, they'll all appear as one IP and share a single quota. We've had to relax IP-based limits on public endpoints specifically because customers at large enterprises were hitting them too easily.

Hybrid Approach

The most practical approach combines both: use user ID when available, fall back to IP when not:

php
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by(
        $request->user()?->id ?: $request->ip()
    );
});

This is what we deploy in most production systems. Authenticated users get per-account limits, guests get per-IP limits. It's a sensible middle ground that handles both logged-in customers and anonymous API callers.

One caveat: if you're using a reverse proxy or load balancer, make sure $request->ip() returns the client's real IP, not the proxy's. Configure TrustedProxies middleware to handle this correctly.

Applying Rate Limiters to Routes

Once a limiter is defined, you apply it using the throttle middleware on your routes. The middleware syntax is throttle:limiterName:

php
Route::middleware('throttle:api')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::post('/posts', [PostController::class, 'store']);
    Route::get('/users/{user}', [UserController::class, 'show']);
});

You can also apply it to individual routes:

php
Route::get('/posts', [PostController::class, 'index'])
    ->middleware('throttle:api');

If you've registered the limiter as 'api', this middleware will use it automatically. When a client exceeds the limit, Laravel returns a 429 (Too Many Requests) response with appropriate headers:

php
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1234567890

These headers are critical for well-behaved clients. They can read X-RateLimit-Remaining and back off before hitting the hard limit. In our e-commerce APIs, we've seen clients handle rate limits gracefully when they get this feedback; without it, they just retry blindly and make things worse.

Stricter Limits for Authentication Endpoints

Your login, password reset, and token endpoints deserve special attention. These are prime targets for brute-force attacks, and they're often less tolerant of high volume than your regular API endpoints. A strict rate limit here prevents account takeover attempts without affecting normal API usage.

Define a separate, more aggressive limiter:

php
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by(
        $request->user()?->id ?: $request->ip()
    );
});

Then apply it to your authentication routes:

php
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Route::post('/password/reset', [PasswordResetController::class, 'reset'])
    ->middleware('throttle:login');

Route::post('/auth/token', [TokenController::class, 'issue'])
    ->middleware('throttle:login');

Five requests per minute for login is aggressive, but it's effective. An attacker trying 100 passwords will hit the limit instantly. A real user who enters their password wrong a few times will see the rate limit before they lock themselves out. In a ride-hailing app we built, this prevented 90% of credential-stuffing attempts before they consumed any real resources.

You might also want progressive delays. After three failed attempts, add a delay before accepting the next request. This is outside the scope of basic rate limiting but worth considering for high-security endpoints.

Common Mistakes

Using the wrong identifier: We've seen teams limit by user agent or request path instead of user ID or IP. This breaks legitimate use cases and doesn't protect against real abuse. Stick with user ID or IP.

Forgetting to configure TrustedProxies: If you're behind a load balancer or reverse proxy, $request->ip() will return the proxy's IP, not the client's. Every client appears to come from the same source, defeating IP-based rate limiting. Configure this in your App\Http\Middleware\TrustedProxies middleware.

Rate limiting too aggressively: A 60-per-minute limit sounds reasonable until a legitimate client (like a mobile app with retry logic) hits it on a busy day. We've had to relax limits twice in production. Start generous, tighten if you see abuse. Monitor your 429 responses closely.

Applying the same limit everywhere: Your public API endpoints have different traffic profiles than internal admin endpoints. Don't use one limiter for everything. Define multiple limiters and apply them strategically.

Not returning rate limit headers: If your clients don't know they're being rate limited, they'll retry blindly and make it worse. Laravel includes rate limit headers by default, but verify they're present in your responses and well-documented for your API clients.

Key Takeaways

  • Define rate limiters in your service provider's boot() method using RateLimiter::for(), specifying both the limit (per minute, hour, etc.) and the identifier.
  • Choose your identifier strategy: user ID for authenticated requests, IP for guests, or a hybrid approach that uses both.
  • Apply limiters to routes using the throttle middleware, and ensure your clients receive rate limit headers so they can adjust their behavior.
  • Use stricter, separate limiters for authentication endpoints to prevent brute-force attacks without affecting normal API traffic.
  • Test rate limiting in staging with realistic traffic patterns, and monitor 429 responses in production to find the right balance between protection and usability.
  • If you're behind a proxy or load balancer, configure TrustedProxies correctly or IP-based limiting will fail silently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles