Skip to content

Rate limiting

Any route with Laravel’s throttle middleware is documented with a 429 Too Many Requests response and the accompanying Retry-After and X-RateLimit-* headers. This is on by default — no package or configuration required.

Every throttle form Laravel accepts is recognized, and every one of them produces the same 429:

Middleware Recognized
throttle:60,1, throttle:60, throttle:60,0.5 ✓
throttle (bare, no arguments) ✓
throttle:10|60 (guest|authenticated) ✓
throttle:api (a named limiter) ✓ — and its RateLimiter::for registration is checked, see Named limiters
ThrottleRequests::using('api') / ::with(...), including the FQCN strings they render to ✓
ThrottleRequestsWithRedis (the Redis variant) ✓

When a route stacks several throttle middlewares, a single 429 is documented from the first, with a rate-limit.multiple-throttles info diagnostic noting the others are enforced independently but not separately represented.

routes/api.php
Route::middleware('throttle:60,1')->get('/invoices', [InvoiceController::class, 'index']);

Every header is documented by what it means — no example, no allowance baked into the description, whatever the route throttles at. That’s deliberate:

  • A number in the spec goes stale silently. Nothing fails when somebody edits throttle:60,1 to throttle:120,1 and the published spec keeps saying 60.
  • It duplicates your own prose. Your API’s docs are where a reader looks for “100 requests a minute on the free plan” — with the tiers and the exceptions that go with it, which a single example can never carry.
  • It churns the document. Baked-in numbers make a rate-limit tweak rewrite bytes across every operation that route touches, so docuccino:diff reports a change on operations whose contract did not move.
  • The live headers are authoritative anyway. A client reads the real limit, the real remaining count and the real reset time from its very first response, and a generated SDK reads them from the same place.

The payoff is a smaller, calmer document. Because every 429 is byte-identical, all of your throttled routes share one TooManyRequests component — baking the numbers in would split it into a separate component per distinct limit, and a generated client into a type per throttle setting.

If your published spec genuinely needs a documented number, state it yourself with an Overlay — a deliberate claim you maintain and review, rather than a build artifact that drifts.

Laravel’s ThrottleRequests sets Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every 429 it produces — there is no branch in which it sends only some of them — so all four are published with "required": true. That is what lets a generated client type them non-optional instead of guarding every read, and it is the strongest true statement the document can make: publishing them as optional would describe a weaker server than the one you are running.

Every header here is also a claim your test suite can hold the application to. assertValidResponse() checks the response headers alongside the body, so a 429 that sends Retry-After: soon fails against the integer the document publishes, and a 429 that omits Retry-After altogether fails too — in both cases the failure names the rate-limit integration as the producer that wrote the claim.

If your application replaces the 429 renderer and drops those headers, that test failure is the point: your responses and your published document disagree, and they disagreed before anything checked. Either send the headers, or say what you really send — #[ResponseHeader(name: 'Retry-After', status: 429, required: false)] on the action states the one member you are correcting and leaves the rest of the header standing, and an Overlay says it once for a whole document.

Middleware synthesizes the 429, so no throw announces it — Docuccino asks the error-response chain about ThrottleRequestsException anyway, and the body matches the rest of the document:

The situation The 429
error_responses is default Laravel’s { "message": string }, which is what ThrottleRequests returns.
Your own handler covers throttling Whatever that handler builds.
Your own handler covers throttling, and its body doesn’t read No body — the 429 keeps its status, description and rate-limit headers. Publishing Laravel’s shape over a renderer you replaced would name members your server never sends.
Nothing in the chain answers The stock { "message": string }.
error_responses is none No 429 at all. That switch turns off every error the document synthesizes rather than reads, and this is one of them; the rate-limit headers go with it, since a response is the only place a header can be published.

The body is copied in rather than $ref’d, because the chain’s own shared response carries none of the X-RateLimit-* headers this 429 needs. The finished response — headers and all — is then hoisted like any other error response, which it can be, because every throttled route states it identically.

A named limiter (throttle:api) states its rate inside a closure — RateLimiter::for('api', fn () => ...). Docuccino neither runs that closure nor reads it. It doesn’t need to: the 429 says the same thing whatever the closure returns, so a limiter that computes a rate per user, branches on the plan, or hands back Limit::none() documents exactly what Limit::perMinute(60) documents.

What Docuccino does check is that the limiter is there:

Route::middleware('throttle:reports')->get('/reports', ReportController::class);
// …and nothing anywhere calls RateLimiter::for('reports', …)

That route is broken. Laravel’s named-limiter lookup misses, falls through to reading reports as an allowance, and casts it to 0 — so every guest request gets a 429, always. Docuccino reports it as a rate-limit.unregistered-limiter info diagnostic naming the limiter and the route, which turns a production surprise into a line of build output.

Nothing else about a throttle raises anything. An inline allowance is never mistaken for a limiter name: throttle:60,1, bare throttle, and the guest|authenticated throttle:10|60 — whose halves may name an attribute on the user rather than a number — all state their own limit and are left alone.

The integration is always on, and has one switch, in docuccino.yaml:

documents:
default:
integrations:
rate_limit: { enabled: false } # stop documenting 429s in this document

Turning it off for a document emits one integration.disabled info diagnostic per build, so the choice stays visible in the output rather than looking like a bug. See the integrations reference.

Two other switches reach the same response. error_responses => 'none' removes every error the document synthesizes rather than reads, and the 429 is one of them — so it is the wrong reach if the 429 is the one you wanted to keep. Drop responses one at a time with #[IgnoreResponse] instead: #[IgnoreResponse(429)] takes the 429 off one operation, and naming the other statuses leaves the 429 standing.