Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Rate Limiting with Custom Keys

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.

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.

Laravel Rate Limiting with Custom Keys — Request FlowHTTP RequestRoute + headersThrottleMiddlewareKey ResolverCustom callbackCache / RedisCounter per keyAllow or 429+ Retry-After headerExample custom key: api-token:sha256(abc123) | user:42 | tenant:acmeSame IP, different keys = independent rate buckets
How Laravel rate limiting with custom keys resolves a bucket name before incrementing the cache counter

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.

Choosing a Custom Rate Limit KeyIncoming requestAuthenticated?Session or SanctumPublic endpointNo auth contextuser:{id}Per-account quotatoken:{hash}API key scopeip:{address}+ route prefixMulti-tenant: tenant:{slug}:user:{id} — never throttle entire tenant by IP alone
Decision tree for selecting the right custom key in Laravel rate limiting
Key strategyBest forWatch out for
ip:{ip}Login, password reset, public formsShared NAT blocks innocent users
user:{id}Authenticated dashboards, CRUD APIsGuest endpoints still need IP fallback
token:{hash}Sanctum/Passport API tokensHash the token; never store raw keys in cache
tenant:{id}Multi-tenant SaaS, agency white-label appsCombine with user key for fairness inside tenant
route:{name}|ip:{ip}Mixed public + sensitive endpointsLong 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.

  1. Named limiter on route groupsRoute::middleware('throttle:api') for all authenticated JSON endpoints.
  2. Per-route override->middleware('throttle:login') on POST /login only.
  3. 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.

Default IP Key vs Custom User / Token KeysIP-only key1 bucket per NATMobile users collideHigh false 429 rateuser:{id} keyFair per accountWorks behind NATBest for dashboardstoken:{hash}Per integrationRevoke = new bucketBest for public APIsSame office IP — three users, three independent buckets with user keysUser AUser BUser CShared IP
Why Laravel rate limiting with custom keys beats IP-only throttling on shared networks

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 unless TrustProxies is configured. Every user collapses into one bucket.
  • Cache prefix collisions — staging and production sharing one Redis DB without distinct CACHE_PREFIX values 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.
Gotcha: TrustProxies and Collapsed IP KeysClient AClient BClient CLoad balancerX-Forwarded-ForTrustProxies OFFip() = 10.0.0.1One shared bucketFix: configure TrustProxies + use user/token custom keysSee Laravel routing docs for proxy header trust settings
Misconfigured proxy trust collapses Laravel rate limiting custom keys into a single IP bucket

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 via throttle:limiter-name—the name must match exactly.
  • Key by user:{id} or token:{hash} for authenticated APIs; fall back to ip:{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

Custom keys tell Laravel which string identifies each throttle bucket—user ID, token hash, or tenant slug—instead of default client IP.

Register a named limiter with RateLimiter::for(), passing a callback that receives the Request and returns a Limit object. Chain Limit::perMinute(n)->by('user:'.$request->user()->id) to set both the cap and the bucket name. Attach it on routes with throttle:limiter-name, where the name must exactly match the first argument to RateLimiter::for(). Laravel 12 uses the same API; only the bootstrap file layout differs slightly from Laravel 13.

In Laravel 13, register limiters in bootstrap/app.php inside the withMiddleware callback, or in a service provider boot() method. The registration point does not change runtime behaviour. What matters is that the limiter name matches the string passed to route middleware. On production apps I maintain, I keep limiter definitions near other middleware configuration so throttle rules stay visible during deploy reviews.

Three attachment points work: named limiter on route groups via Route::middleware('throttle:api'), per-route override such as ->middleware('throttle:login') on POST /login, or a custom middleware class when logic exceeds RateLimiter::for(). Pick whichever matches your route organisation and stay consistent. A typical split keys login by email plus IP while authenticated API routes use a separate api limiter under auth:sanctum.

The key string should identify the entity you throttle, stay stable for the decay window, and avoid leaking secrets. Prefix segments like user:, token:, tenant:, and ip: prevent collisions when IDs overlap across tables. Use user:{id} for authenticated dashboards, token:{hash} for Sanctum or Passport APIs, tenant:{id} for multi-tenant SaaS, and ip:{ip} only for anonymous endpoints. Composite keys such as tenant:{slug}:user:{id} work well on marketplace vendor dashboards where fairness inside an organisation matters.

Default throttle:60,1 middleware resolves keys from $request->ip(), so every user behind the same carrier NAT or office gateway shares one bucket. Thousands of mobile users in Kathmandu or Dubai can exit through one address, meaning one abusive client triggers HTTP 429 for everyone. Authenticated API consumers also need per-token quotas, not per-IP quotas. Custom keys fix this by building bucket names from user ID, hashed token, or tenant slug while keeping Laravel's same sliding-window cache mechanics.

No. Hash bearer tokens with SHA-256 before using them as keys. Raw tokens can appear in Redis MONITOR output, logs, or backup dumps.

Yes. Return an array of Limit objects from RateLimiter::for(). Laravel blocks the request if any limit in the array is exceeded.

Rate counters live in whatever cache driver you configure. File-based cache resets counters on every deploy, briefly opening an abuse window. On shared EC2 infrastructure where I run Deployer 7 releases, Redis-backed limiters keep counts consistent after symlink swaps and survive PHP-FPM worker restarts. Set CACHE_STORE=redis and ensure Redis is reachable from every node behind the load balancer. Use Redis 8.x so counters scale horizontally across app servers.

Behind Cloudflare or an AWS ALB, $request->ip() returns the load balancer IP unless TrustProxies is configured correctly. Every user collapses into one bucket, defeating custom IP-based keys entirely. Fix this in Laravel 13 by configuring trusted proxies in bootstrap/app.php with $middleware->trustProxies(at: '*'). Restrict at: to your actual proxy CIDR ranges in production rather than wildcarding unless you understand the trade-off. This is one of the most common silent rate-limiting bugs I see on client projects.

Chain Limit::response() on your Limit object to shape the HTTP 429 payload for API consumers. Return JSON with fields like error set to rate_limit_exceeded and retry_after pulled from the Retry-After header Laravel passes into the callback. Payment callback routes for Stripe, Khalti, or eSewa benefit from explicit JSON errors instead of HTML error pages. Pair merchant-scoped limiters keyed by X-Merchant-Id with this pattern so partners know exactly when to back off.

Exhaust one user's quota in a loop with actingAs($userA, 'sanctum'), assert the next request returns 429, then confirm a second user on the same IP still receives 200. Rate limiting bugs stay silent until production, so test them explicitly before deploy. Clear limiters between tests with RateLimiter::clear('api') or Cache::flush() in setUp() when phpunit.xml uses the array cache driver. Verify that user A's exhaustion does not block user B.

Resolve the numeric cap at runtime inside your RateLimiter::for() callback by reading the authenticated user's organisation plan from MySQL or PostgreSQL. Use a match expression on plan names like starter, pro, or default to set Limit::perMinute($maxAttempts)->by('org:'.$organisation_id). Cache the plan lookup if the query adds latency, and invalidate that cache when subscriptions change. Authorisation still belongs in policies and gates; rate limiting is not a substitute for proper access control.

Laravel automatically adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After on throttled responses when using the default ThrottleRequests middleware. Document these in your OpenAPI spec so API partners know their remaining quota and when to retry. Tools like Scribe can surface them for consumers building against your RESTful Laravel APIs. Custom Limit::response() callbacks still receive the headers array, including Retry-After, for inclusion in your JSON body.

TrustProxies misconfiguration collapses all IP keys into one bucket behind load balancers. Staging and production sharing one Redis DB without distinct CACHE_PREFIX values cross-contaminate counters. Octane long-lived workers can leak static state in sloppy custom middleware, though rate limiting itself still uses cache correctly. Queue workers bypass HTTP middleware entirely, so cap job retries separately. Misconfigured proxy trust, cache prefix collisions, and assuming throttling protects queued jobs are the issues I encounter most often on sister-site deployments.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: