For a Laravel weather-data API with per-plan quotas, how would you combine Limit::perSecond, Limit::perDay, limit arrays and Limit::none() in one named limiter?
answer
- the closure picks limits by plan
- return an array for burst plus daily
- prefix by() values per window
- all limits checked before any is counted
- Limit::none() skips throttling entirely
basics
~10 sHave one RateLimiter::for closure read the user's plan and return an array such as [Limit::perSecond(20)->by('s:'.$id), Limit::perDay(100_000)->by('d:'.$id)], or Limit::none() for unlimited plans. Every limit in the array must pass.
solid answer
~30 sThe limiter closure runs per request, so it can branch on `$request->user()->plan`. Return an **array** to enforce a burst limit and a daily quota together: `[Limit::perSecond(20)->by('second:'.$id), Limit::perDay(100_000)->by('day:'.$id)]`. The middleware checks **every** limit first and throws a 429 for the first one that is exhausted; only if all pass does it count the request against each. Give each limit a distinct `by()` value — the docs recommend a prefix — so the windows keep separate counters. `Limit::none()` returns an `Unlimited` limit and the middleware skips counting entirely, which suits an enterprise tier. Response headers report the limit with the fewest remaining attempts.
code
php · 19 lines<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('weather-api', function (Request $request) {
$user = $request->user();
if ($user?->plan === 'enterprise') {
return Limit::none();
}
$id = $user?->id ?: $request->ip();
return $user?->plan === 'pro'
? [Limit::perSecond(20)->by('second:'.$id), Limit::perDay(100_000)->by('day:'.$id)]
: [Limit::perMinute(60)->by('minute:'.$id), Limit::perDay(1_000)->by('day:'.$id)];
});go deeper
Recall that a limiter closure can return an array of limits and that Limit::none() switches limiting off.
Explain that all limits are checked before any is counted, why by() values need prefixes, and which limit the headers report.
Design burst and daily tiers per plan, account for rolling windows versus calendar quotas, and keep the plan lookup cheap on every request.
Align technical quotas with pricing and contracts, including how upgrades, overages and calendar-based billing periods are enforced.
## The requirement A weather-data service sells three plans: | Plan | Burst limit | Daily quota | |---|---|---| | Free | 60 requests per minute | 1,000 per day | | Pro | 20 requests per second | 100,000 per day | | Enterprise | none | none | One named limiter can express all of it, because its closure runs for each request and may return a single `Limit`, an array of limits, or `Limit::none()`. ## The limiter ```php RateLimiter::for('weather-api', function (Request $request) { $user = $request->user(); if ($user?->plan === 'enterprise') { return Limit::none(); } $id = $user?->id ?: $request->ip(); return $user?->plan === 'pro' ? [Limit::perSecond(20)->by('second:'.$id), Limit::perDay(100_000)->by('day:'.$id)] : [Limit::perMinute(60)->by('minute:'.$id), Limit::perDay(1_000)->by('day:'.$id)]; }); ``` ## How an array is evaluated The default `ThrottleRequests` middleware processes the array in a fixed order: 1. **Check every limit.** For each one, if its counter has reached the maximum, throw a 429 built from that limit (its `Retry-After`, or its custom `response()`). 2. **Count the request** against every limit that has no `after()` callback. 3. **Run the route.** 4. **Add headers** for each limit. The header logic keeps the most restrictive values, so `X-RateLimit-Remaining` reports the limit closest to exhaustion. Because checking happens before counting, a request rejected by the daily quota does not also burn a slot in the per-second window. ## Why distinct `by()` values matter Each limit's stored key is derived from the limiter name plus its `by()` value, so two limits with the same `by()` value would point at the same counter: one request would be counted twice and the tighter window's count would leak into the other. The documentation therefore tells you to **prefix** each value (`'second:'.$id`, `'day:'.$id`). The pinned Laravel 13 source adds a safety net: when one returned array contains duplicate keys, `RateLimiter::limiter()` renames each duplicate with its attempt count and decay (`Limit::fallbackKey()`), so the unprefixed version keeps separate counters too. Keep the prefix anyway: it states the intent, and the safety net cannot separate two limits that also share the same numbers. ## `Limit::none()` and short-circuits - `Limit::none()` returns an `Illuminate\Cache\RateLimiting\Unlimited` instance. When the closure returns it, the middleware passes the request straight on — no counter is read or written, and no rate-limit headers are added. - The closure may also return a **response** directly, which the middleware sends without calling the route — for example a 403 when an API key is missing. ## The builders and their windows - `Limit::perSecond($max, $decaySeconds = 1)` - `Limit::perMinute($max, $decayMinutes = 1)` - `Limit::perHour($max, $decayHours = 1)` - `Limit::perDay($max, $decayDays = 1)` - `Limit::perMinutes($decayMinutes, $max)` — note the reversed argument order. Each window starts at the first counted request and lasts the decay period; the counter and a timer entry live in the cache with that lifetime. A "per day" limit therefore resets 24 hours after the first request of the window, not at midnight. If the business promise is a calendar-day quota, that needs its own counter keyed by the date. ## A worked timeline A Pro user sends 25 requests within one second, early in the day: 1. Requests 1-20 pass both checks and are counted against `second:` and `day:`. Their responses report `X-RateLimit-Limit: 20` and a falling `X-RateLimit-Remaining`, because the per-second limit is the tighter one. 2. Request 21 finds the per-second counter at 20 and receives a 429 with `Retry-After` of about one second. The daily counter stays at 20, because checking happens before counting. 3. After the one-second window expires, requests are accepted again and the daily counter resumes from 20. The daily quota only becomes the binding constraint when the user has spent most of it, and from then on the headers switch to report it. ## Operational points - **Read the plan cheaply.** The closure runs on every request; loading the plan from the authenticated user is free, a separate query per request is not. - **Change plans safely.** Upgrading a user takes effect on the next request because the closure re-reads the plan, but counters already in the cache keep their current window. - **Tell clients.** Document that `X-RateLimit-Remaining` reflects the tightest limit, and that `Retry-After` on a 429 belongs to the limit that tripped.
- In a Laravel limit array, does a request rejected by the daily limit still use up a per-second slot?Not with the default `ThrottleRequests` middleware: it checks every limit first and only counts the request against each once all have passed. A rejected request throws the 429 before any counter is incremented.
- Why doesn't Limit::perDay(1000) in Laravel reset at midnight?The window is a cache entry created by the first counted request and expiring after the decay period. A day-long window therefore resets 24 hours after that first request. A calendar-day quota needs a key that includes the date, such as `by('day:'.now()->toDateString().':'.$id)`.
saying these in an interview costs you the question
- Only the first limit in the array is enforced
- Limit::none() still counts requests but never rejects them
- Laravel stores every limit of one limiter in a single shared counter
- Limit::perDay() resets at midnight in the app's timezone
- Each rejected request still consumes a slot in every window