
September 07, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Laravel rate limiting with custom keys is how you stop abuse without punishing legitimate users who share an IP address. Out of the box, Laravel throttles by IP. That works for simple login forms, but it breaks down fast on mobile networks, corporate NAT, and multi-tenant APIs where one bad API key should not block everyone behind the same gateway. On production Laravel applications I maintain—including legal-tech portals and eCommerce APIs—the difference between a default IP limiter and a well-chosen custom key is often the difference between a 429 storm in support tickets and a quiet Sunday deploy. This guide walks through defining, applying, and debugging custom rate limiters in Laravel API best practices you can ship today on Laravel 13 with PHP 8.3 or higher.
RateLimiter::for(), return a custom key string from the callback (user ID, token hash, tenant slug), then attach it via throttle:limiter-name middleware. Laravel stores attempt counts per key in cache or Redis and returns HTTP 429 when the limit is exceeded.How does Laravel rate limiting work before you add custom keys?
Laravel's rate limiting sits on top of the framework cache store. When a request hits a throttled route, the Illuminate\Routing\Middleware\ThrottleRequests middleware asks the RateLimiter facade whether the resolved key has remaining attempts. Each key maps to a counter with a decay window—typically 60 seconds for "60 requests per minute" style limits.
The default throttle:60,1 middleware resolves the key from the client IP via $request->ip(). That is fine for public contact forms. It is a poor fit when:
- Thousands of mobile users exit through the same carrier NAT in Kathmandu or Dubai.
- Authenticated API consumers need per-token quotas, not per-IP quotas.
- A SaaS app must enforce limits per organisation or tenant.
- Payment webhooks and login endpoints need different buckets on the same route prefix.
Custom keys solve this by letting you define exactly what string identifies the "bucket" Laravel increments. The limiter still uses the same sliding-window mechanics documented in the official Laravel 13 rate limiting documentation; you only change how the bucket name is built. For broader context on throttling patterns, see our companion piece on rate limiting and API throttling in Laravel and the deeper dive on token bucket and sliding window algorithms.
Where limiters are registered in Laravel 13
In Laravel 13, named limiters are registered in bootstrap/app.php using the withMiddleware callback, or in a service provider's boot() method. Laravel 12 projects follow the same RateLimiter::for() API; only the bootstrap file layout differs slightly. The registration point does not affect runtime behaviour—what matters is that your limiter name matches the string passed to route middleware.
How do you define a custom rate limiter in Laravel 13?
The core API is RateLimiter::for(string $name, Closure $callback). The callback receives the incoming Request and returns either a Limit object or an array of Limit objects for multi-tier throttling.
Register a limiter that keys by authenticated user ID:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(120)->by(
$request->user()
? 'user:'.$request->user()->id
: 'ip:'.$request->ip()
);
}); Attach it in routes/api.php:
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/orders', [OrderController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
}); The string after throttle: must match the first argument you passed to RateLimiter::for(). Laravel resolves throttle:api to your custom callback instead of the numeric shorthand throttle:60,1.
Multi-tier limits on one route
Return an array when you need both a per-user cap and a global safety net:
RateLimiter::for('uploads', function (Request $request) {
return [
Limit::perMinute(10)->by('user:'.optional($request->user())->id),
Limit::perMinute(200)->by('global:uploads'),
];
}); Laravel evaluates every limit in the array. If any limit is exceeded, the request receives HTTP 429. This pattern works well on document upload endpoints in client portals—I've used similar logic on legal-tech platforms where a single authenticated user should not monopolise storage bandwidth.
Custom response when throttled
Use Limit::response() to return JSON shaped for API consumers:
RateLimiter::for('payments', function (Request $request) {
return Limit::perMinute(30)
->by('merchant:'.$request->header('X-Merchant-Id'))
->response(function (Request $request, array $headers) {
return response()->json([
'error' => 'rate_limit_exceeded',
'retry_after' => $headers['Retry-After'] ?? 60,
], 429, $headers);
});
}); Payment callback routes—whether Stripe, Khalti, or eSewa—benefit from explicit JSON errors instead of HTML error pages. Our Khalti integration guide for Laravel apps covers webhook hardening; pairing that with a merchant-scoped limiter prevents one misconfigured store from flooding your queue.
What should you use as a custom rate limiting key?
The key string should identify the entity you want to throttle, be stable for the decay window, and avoid leaking secrets. Prefix segments (user:, token:, tenant:) prevent accidental collisions when IDs overlap across tables.
| Key strategy | Best for | Watch out for |
|---|---|---|
ip:{ip} | Login, password reset, public forms | Shared NAT blocks innocent users |
user:{id} | Authenticated dashboards, CRUD APIs | Guest endpoints still need IP fallback |
token:{hash} | Sanctum/Passport API tokens | Hash the token; never store raw keys in cache |
tenant:{id} | Multi-tenant SaaS, agency white-label apps | Combine with user key for fairness inside tenant |
route:{name}|ip:{ip} | Mixed public + sensitive endpoints | Long key strings; keep prefixes consistent |
Hashing API tokens for cache keys
Never use the raw bearer token as a cache key—it could appear in Redis MONITOR output, log aggregators, or backup dumps. Hash it:
RateLimiter::for('partner-api', function (Request $request) {
$token = $request->bearerToken();
if (! $token) {
return Limit::perMinute(20)->by('ip:'.$request->ip());
}
return Limit::perMinute(500)->by('token:'.hash('sha256', $token));
}); If you need to test regex patterns for route-specific keys, the regex tester on this site helps validate path-matching expressions before you embed them in middleware.
Composite keys for multi-tenant apps
On directory and marketplace projects—think vendor dashboards with role-based access—I've found composite keys the most maintainable approach:
RateLimiter::for('vendor', function (Request $request) {
$vendorId = $request->user()?->vendor_id ?? 'guest';
return Limit::perMinute(60)->by(sprintf(
'tenant:%s:user:%s',
$request->route('tenant'),
$vendorId
));
}); This throttles each vendor user independently within a tenant subdomain. A burst from one vendor on Ajako Deal-style marketplace architecture does not affect others in the same organisation.
How do you apply custom rate limits to routes and middleware groups?
There are three common attachment points. Pick the one that matches how your routes are organised—consistency matters more than which file you choose.
- Named limiter on route groups —
Route::middleware('throttle:api')for all authenticated JSON endpoints. - Per-route override —
->middleware('throttle:login')onPOST /loginonly. - Global middleware alias — register a custom middleware class when you need logic beyond
RateLimiter::for().
Example split between login and general API traffic in routes/web.php and routes/api.php:
// bootstrap/app.php or AppServiceProvider
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->input('email');
return Limit::perMinute(5)->by(
strtolower($email).'|ip:'.$request->ip()
);
});
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by('user:'.$request->user()->id)
: Limit::perMinute(30)->by('ip:'.$request->ip());
}); Route::post('/login', [AuthController::class, 'store'])
->middleware('throttle:login');
Route::prefix('v1')
->middleware(['auth:sanctum', 'throttle:api'])
->group(base_path('routes/api_v1.php')); Login limiters keyed by email plus IP stop credential-stuffing bots while allowing five attempts per account—not five attempts for an entire office building. That composite pattern aligns with abuse-prevention guidance in our article on API rate limiting and abuse prevention in modern web apps.
Redis as the rate limit store
Rate counters live in whatever cache driver you configure. For production APIs, use Redis 8.x so counters survive PHP-FPM worker restarts and scale horizontally across app servers. Set CACHE_STORE=redis in .env and ensure your Redis instance is reachable from every node behind the load balancer.
On shared EC2 infrastructure where I run Deployer 7 releases for multiple Laravel sites, Redis-backed limiters keep counts consistent after symlink swaps—file-based cache would reset counters on every deploy, briefly opening a window for abuse. If you manage your own servers, our Linux system administration service covers Redis tuning alongside PHP-FPM.
Rate limit headers for API clients
Laravel automatically adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After on throttled responses when using the default middleware. Document these in your OpenAPI spec—tools like Scribe, covered in our API documentation with Scribe for Laravel guide, can surface them for partners building against your RESTful Laravel APIs.
How do you test and debug Laravel rate limiting with custom keys?
Rate limiting bugs are silent until a client hits 429 in production. Test them explicitly in PHPUnit or Pest before deploy.
public function test_api_is_throttled_per_user(): void
{
$userA = User::factory()->create();
$userB = User::factory()->create();
for ($i = 0; $i < 60; $i++) {
$this->actingAs($userA, 'sanctum')
->getJson('/api/v1/notifications')
->assertOk();
}
$this->actingAs($userA, 'sanctum')
->getJson('/api/v1/notifications')
->assertStatus(429);
// User B should NOT be blocked by user A's exhaustion
$this->actingAs($userB, 'sanctum')
->getJson('/api/v1/notifications')
->assertOk();
} Clear limiters between tests with RateLimiter::clear('api') or by using the Cache::flush() helper in your test setUp() when the array driver is configured in phpunit.xml.
Common production gotchas
These issues appear repeatedly on client projects and sister-site deployments:
- TrustProxies misconfiguration — behind Cloudflare or an AWS ALB,
$request->ip()returns the load balancer IP unlessTrustProxiesis configured. Every user collapses into one bucket. - Cache prefix collisions — staging and production sharing one Redis DB without distinct
CACHE_PREFIXvalues will cross-contaminate counters. - Octane / long-lived workers — rate limiting still uses cache, so it works, but static state in custom middleware can leak between requests if you write sloppy singletons.
- Queue workers bypass HTTP middleware — throttling protects HTTP entry points, not queued job retries. Cap job attempts separately.
Fix TrustProxies in Laravel 13 by configuring trusted proxies in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
}) Restrict at: to your actual proxy CIDR ranges in production rather than wildcarding unless you understand the trade-off. The official trusted proxies documentation lists supported headers.
Dynamic limits from database plans
SaaS products often store per-plan limits in MySQL 9.7 or PostgreSQL 18. Resolve the numeric cap at runtime:
RateLimiter::for('billing', function (Request $request) {
$plan = $request->user()?->organisation?->plan;
$maxAttempts = match ($plan) {
'starter' => 100,
'pro' => 1000,
default => 60,
};
return Limit::perMinute($maxAttempts)
->by('org:'.$request->user()->organisation_id);
}); Cache the plan lookup if the query adds latency—just invalidate the cache when subscriptions change. Database design for this pattern is covered in our PostgreSQL for Laravel developers guide. For a shipped example of plan-aware API consumption, see the Nepal Gift Card portfolio case study—a Laravel platform where per-merchant limits protect payment endpoints.
Authorisation still belongs in policies and gates; rate limiting is not a substitute. Use them together as described in the Laravel policies and gates guide. When you need a full API build with throttling baked in from day one, our API development service covers design through deployment.
Key Takeaways
- Register named limiters with
RateLimiter::for()and attach them viathrottle:limiter-name—the name must match exactly. - Key by
user:{id}ortoken:{hash}for authenticated APIs; fall back toip:{address}only for anonymous endpoints. - Hash bearer tokens before using them as cache keys; never store raw secrets in Redis.
- Use Redis as the cache store in production so counters survive deploys and scale across app servers.
- Configure TrustProxies correctly or every custom IP-based key collapses into one bucket behind a load balancer.
- Write PHPUnit tests that exhaust one user's quota and confirm another user on the same IP is unaffected.
People Also Ask
Can you use multiple rate limiters on one route in Laravel?
Yes. Return an array of Limit objects from your RateLimiter::for() callback. Laravel checks each limit independently—the request is blocked if any single limit is exceeded. This is useful for combining a per-user cap with a global safety ceiling on expensive endpoints like PDF generation or image processing.
Does Laravel rate limiting work with Sanctum and Passport?
It works with any authentication guard. Resolve the authenticated user or token inside the limiter callback and build your key from that identity. Sanctum bearer tokens should be hashed before becoming cache keys. Passport personal access tokens follow the same pattern—key by token ID or hash, not by the user alone, if each token represents a separate integration.
What HTTP status code does Laravel return when rate limited?
Laravel returns HTTP 429 Too Many Requests with a Retry-After header indicating seconds until the client may retry. Override the response body with Limit::response() when API consumers expect JSON error envelopes instead of plain text.
How is Laravel rate limiting different from middleware throttle:60,1?
The shorthand throttle:60,1 is inline configuration: 60 attempts per 1 minute keyed by IP. Named limiters via RateLimiter::for() give you reusable, testable callbacks with arbitrary key logic, custom responses, and multi-tier limits—essential for production APIs where one size does not fit every route group.
Ship rate limiting that survives real traffic
Laravel rate limiting with custom keys turns throttling from a blunt IP block into precise, account-level protection. Define your limiters once, key them by the identity that actually matters, store counters in Redis, and test that user A cannot steal user B's quota on a shared network. That is the baseline for any API or client portal worth running in production in 2026. If you want help wiring custom limiters into an existing Laravel 12 or 13 codebase—or designing a new API with throttling from the first commit—contact us or explore custom software development and ongoing support and maintenance. For architecture context, read modern Laravel architecture best practices and validate JSON error payloads with the JSON formatter before you hand docs to partners.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

