Rate limiting is one of those features that developers tend to add reactively - usually after something goes wrong. A rogue script hits an endpoint a thousand times in a minute, a misconfigured integration floods your queue, or a competitor starts scraping your API. I've been on the receiving end of all three, and each time I've wished I'd put proper rate limiting in place from day one.
These days, it's one of the first things I wire up on any new Laravel SaaS project. It's not complicated, and the peace of mind it buys is well worth the hour or so it takes to do properly.
Let me walk you through the setup I use.
Why Rate Limiting Matters More Than You Think
If you're building a SaaS - even a small one - you've almost certainly got some kind of API. Whether that's a REST API for a mobile client, webhooks, a public-facing integration layer, or just Livewire endpoints backing your frontend, all of it is potentially abusable.
Without rate limiting, a single bad actor (or a well-meaning but poorly configured client) can:
- Max out your database connections
- Push your queue workers into a backlog that takes hours to clear
- Burn through your server resources and cause slowdowns for legitimate users
- Run up unexpected infrastructure costs if you're on a usage-based plan
None of that is fun to deal with at 11pm.
Laravel's Built-In Rate Limiter
The good news is that Laravel ships with a genuinely capable rate limiting system built right in. It lives in App\Providers\AppServiceProvider (or RouteServiceProvider if you're on older versions) and uses Redis or your cache driver under the hood.
Here's the basic shape of how I define rate limiters:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
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());
});
RateLimiter::for('public-search', function (Request $request) {
return Limit::perMinute(20)->by($request->ip());
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
}
Then in your routes file, you apply the limiter as middleware:
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
// your protected API routes
});
Route::middleware('throttle:auth')->group(function () {
Route::post('/login', [AuthController::class, 'store']);
Route::post('/forgot-password', [PasswordController::class, 'store']);
});
Simple, readable, and it just works.
Tiered Limits Based on Subscription Plan
One pattern I find really useful in SaaS apps is tiering your rate limits based on a user's subscription plan. Free users get tighter limits, paid users get more headroom, and enterprise customers can have limits relaxed further still.
Here's roughly how I handle that:
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
$limit = match(true) {
$user?->onPlan('enterprise') => 1000,
$user?->onPlan('pro') => 300,
$user?->subscribed() => 100,
default => 30,
};
return Limit::perMinute($limit)->by($user?->id ?: $request->ip());
});
This gives you a genuinely useful business lever too - your rate limits become part of your pricing story. "Pro plan gets 5x the API throughput" is a meaningful upgrade incentive.
Returning Useful Error Responses
By default, Laravel returns a 429 Too Many Requests response with a basic message. That's fine, but I like to make it a bit more informative for API consumers. You can customise this in your exception handler:
use Illuminate\Http\Exceptions\ThrottleRequestsException;
$exceptions->render(function (ThrottleRequestsException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'Too many requests. Please slow down.',
'retry_after' => $e->getHeaders()['Retry-After'] ?? null,
], 429);
}
});
The Retry-After header is particularly useful - good API clients will read it and back off automatically.
Rate Limiting Beyond Routes
What I really like about Laravel's RateLimiter facade is that it's not just for HTTP routes. You can use it anywhere in your application - inside a job, a service class, a command. This is useful for things like:
- Limiting how often a user can trigger a specific action (exporting data, sending invites)
- Protecting third-party API calls you're making on behalf of users
- Throttling background jobs that hit external services
Here's a quick example inside a job:
use Illuminate\Support\Facades\RateLimiter;
public function handle(): void
{
$key = 'send-report:' . $this->user->id;
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->release(60);
return;
}
RateLimiter::hit($key, 3600);
// do the actual work
}
This is a pattern I use a lot when integrating with services that have their own rate limits - like sending emails via a third-party provider or calling an external API.
A Note on Redis
For anything beyond a simple single-server setup, I'd strongly recommend configuring your cache driver to use Redis for rate limiting. The file driver works in development, but under real load you want the atomicity and performance Redis provides. Laravel's Horizon and Octane setups assume Redis anyway, so it's usually already in the stack.
Set CACHE_STORE=redis in your .env and you're most of the way there.
Wrapping Up
Rate limiting doesn't have to be complex. Laravel gives you everything you need out of the box to protect your API, shape traffic intelligently, and even use it as a product differentiator in your pricing tiers.
The version I've described here has served me well across multiple SaaS projects - it's not over-engineered, it's easy to reason about, and it's saved me from more than a few late-night incidents.
If you're not already doing this, carve out an hour this week and get it in place. Future you will be grateful.