Here is a common failure mode in business APIs. A partner's integration suddenly starts getting 429 Too Many Requests on every call. Nothing changed on their side. Nothing changed on the API side either, except that a second partner started a long-running nightly sync.

Both partners sit behind the same shared corporate network, and the API is rate limited by IP address. One noisy client quietly eats the other client's budget.

That is the moment to stop thinking of rate limiting as "add throttle:60,1 and move on." In a business API, a rate limit is part of the contract you have with each customer. This post walks through a setup that handles it in Laravel: limits per client and per plan, separate budgets for expensive endpoints, 429 responses that clients can actually act on, and a small client that respects them.

All examples target Laravel 13 and the rate limiter API documented on laravel.com/docs/routing.

The problem with "one limit for everyone"

A default limiter keyed by IP has three problems in B2B integrations:

  1. Shared IPs. Offices, VPNs and cloud NAT gateways put many clients behind one address. One client's batch job blocks everyone behind the same IP.
  2. One size fits nobody. A partner on a higher plan who syncs thousands of orders a day needs a different budget from a small shop polling once a minute.
  3. Not all requests cost the same. Listing orders is cheap. Generating a stock-aging report over three years of movements is not. A single per-minute counter treats them as equal.

What we want instead:

  • A limit per API client, not per IP, once the client is authenticated.
  • Two windows per client: a short one (per minute) to absorb bursts and a long one (per day) to enforce the plan.
  • A separate, stricter budget for heavy endpoints.
  • A 429 response with headers that tells the client exactly when to try again.

Step 1: Describe plans as data

Keep the numbers out of the limiter code. A config file is enough to start with; later it can be a column on the client or plan table.

<?php // config/api_plans.php return [ 'basic' => ['per_minute' => 60, 'per_day' => 10_000], 'pro' => ['per_minute' => 300, 'per_day' => 100_000], 'internal' => ['per_minute' => 1_000, 'per_day' => 1_000_000], ]; 

Step 2: Define named limiters

Named limiters live in the boot method of AppServiceProvider. The closure receives the request, so you can decide the limit per client at request time.

<?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('partner-api', function (Request $request) { $client = $request->user(); // Unauthenticated calls: small budget, keyed by IP. if (! $client) { return Limit::perMinute(10)->by('guest:'.$request->ip()); } $plan = config("api_plans.{$client->plan}", config('api_plans.basic')); return [ Limit::perMinute($plan['per_minute'])->by('minute:'.$client->id), Limit::perDay($plan['per_day'])->by('day:'.$client->id), ]; }); RateLimiter::for('heavy-reports', function (Request $request) { return Limit::perHour(20)->by('reports:'.$request->user()?->id); }); RateLimiter::for('lookup', function (Request $request) { return Limit::perMinute(30) ->by('lookup:'.($request->user()?->id ?: $request->ip())) ->after(fn ($response) => $response->status() === 404); }); } } 

A few details matter here:

  • Returning an array of Limit objects applies every limit in order. The request is rejected if any of them is exhausted.
  • The minute: and day: prefixes are not decoration. The Laravel docs explicitly warn that multiple limits segmented by identical by values must be made unique. Without the prefixes, both limits would write to the same counter key.
  • Guests get a tiny budget keyed by IP. Authentication endpoints and public lookups still need protection, but authenticated clients never share a bucket by IP.
  • after() makes a limit count only the responses you choose. For the SKU lookup, only 404 responses count, which slows down anyone enumerating SKUs while normal lookups stay unaffected.

Step 3: Attach limiters to routes

<?php // routes/api.php use App\Http\Controllers\Api\OrderController; use App\Http\Controllers\Api\ProductLookupController; use App\Http\Controllers\Api\ReportController; use Illuminate\Support\Facades\Route; Route::middleware(['auth:sanctum', 'throttle:partner-api'])->group(function () { Route::get('/orders', [OrderController::class, 'index']); Route::post('/orders', [OrderController::class, 'store']); Route::get('/products/by-sku/{sku}', [ProductLookupController::class, 'show']) ->middleware('throttle:lookup'); Route::post('/reports/stock-aging', [ReportController::class, 'store']) ->middleware('throttle:heavy-reports'); }); 

The report and lookup routes are covered by both limiters: the general client budget and their own stricter one. A report request consumes one unit of the partner's daily plan and one of the twenty hourly report slots.

Step 4: Return a 429 that clients can act on

When a limit is exceeded, Laravel's ThrottleRequests middleware throws a 429 with these headers:

  • X-RateLimit-Limit and X-RateLimit-Remaining
  • Retry-After (seconds until the next attempt is allowed)
  • X-RateLimit-Reset (a Unix timestamp)

Successful responses also carry X-RateLimit-Limit and X-RateLimit-Remaining. When several limits apply, the middleware keeps the headers of the limit with the lowest remaining count, so the client always sees the tightest budget.

If your API uses a JSON error format, use response() to keep 429s consistent with your other errors. The callback receives the computed headers; pass them through.

<?php use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('heavy-reports', function (Request $request) { return Limit::perHour(20) ->by('reports:'.$request->user()?->id) ->response(function (Request $request, array $headers) { return response()->json([ 'error' => 'rate_limited', 'message' => 'Report quota reached. Try again later.', 'retry_after_seconds' => (int) ($headers['Retry-After'] ?? 60), ], 429, $headers); }); }); 

The most common mistake here is returning response()->json([...], 429) and forgetting the $headers argument. The status code is right, but the client has lost the one piece of information it needed: when to retry.

Step 5: Use Redis when you run more than one server

Rate limits are stored in your cache. With the file or array driver on several app servers, every server keeps its own counters and your real limit becomes "limit × number of servers." Use a shared cache store. If Redis is your cache driver, Laravel can use a Redis-specific throttle middleware:

<?php // bootstrap/app.php (excerpt) use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->throttleWithRedis(); }) ->create(); 

Step 6: A client that respects Retry-After

Half of a rate-limiting contract is on the client side. If you also write the integrations that call other people's APIs, this small wrapper is a good default: honour Retry-After when it is present, fall back to exponential backoff when it is not, add a little jitter, and give up after a few attempts.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function callApi(url, options = {}, maxRetries = 3) { for (let attempt = 0; ; attempt++) { const response = await fetch(url, options); if (response.status !== 429 || attempt >= maxRetries) { return response; } const retryAfter = Number(response.headers.get('Retry-After')); const waitSeconds = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** attempt; const jitterMs = Math.floor(Math.random() * 250); console.warn(`429 received, retrying in ${waitSeconds}s (attempt ${attempt + 1}/${maxRetries})`); await sleep(waitSeconds * 1000 + jitterMs); } } 

I tested it against a local server that answers 429 twice with Retry-After: 1 and then 200: the wrapper waited about one second each time and returned the successful response on the third request. With a server that always returns 429, it stops after the configured retries and hands the 429 back to the caller.

Note that Retry-After may also be an HTTP date. Number() returns NaN for a date, so this wrapper falls back to backoff; parse the date if the APIs you call use that format.

Step 7: Test the limit

A feature test makes the contract explicit and catches regressions when someone edits the plans. This uses Pest and Sanctum:

<?php use App\Models\User; use Laravel\Sanctum\Sanctum; it('returns a 429 with retry headers after the minute budget', function () { config(['api_plans.basic.per_minute' => 3]); Sanctum::actingAs(User::factory()->create(['plan' => 'basic'])); foreach (range(1, 3) as $i) { $this->getJson('/api/orders') ->assertOk() ->assertHeader('X-RateLimit-Limit', 3); } $this->getJson('/api/orders') ->assertStatus(429) ->assertHeader('Retry-After'); }); 

Because the limiter closure runs on every request, changing the config value inside the test is enough to make the budget small.

Common pitfalls

  • Keying by IP behind a proxy. If your load balancer or CDN is not configured as a trusted proxy, $request->ip() returns the proxy's address and every guest shares one bucket. Configure trusted proxies before relying on IP keys.
  • Identical by keys in multiple limits. Prefix them (minute:, day:), as the docs recommend.
  • Assuming $request->user() exists. On routes that can be called without authentication, it is null. Handle the guest case explicitly instead of letting a null id become a shared key.
  • Non-shared cache in production. Each server counting separately silently multiplies your limits.
  • Custom 429 without headers. Clients cannot back off properly without Retry-After.
  • Limiting only HTTP. Queue workers and scheduled commands are not covered by the throttle middleware. If a background job calls an external API, it needs its own throttling on the calling side.
  • Changing limits without telling clients. Rate limits are part of your API contract. Document them per plan, next to the endpoints.

Summary

  • Key limits by API client, and fall back to IP only for guests.
  • Use two windows (per minute and per day) with prefixed keys.
  • Give heavy endpoints their own stricter limiter, and use after() to count only the responses that matter.
  • Keep Retry-After and the X-RateLimit-* headers in every 429.
  • Store counters in a shared cache such as Redis.
  • On the client side, respect Retry-After, back off with jitter and give up after a few tries.

How do you communicate rate limits to the clients of your API: in the docs, in the response headers, or only when they hit their first 429?