Skip to content

Requests

Docuccino documents everything an endpoint accepts — path parameters, query parameters, and request bodies — by reading your routes and validation rules statically. It never runs a rule and never dispatches a request, so generating docs has no side effects.

Path parameters

From the route template and route-model bindings.

Query parameters

From validation on read routes — and your query builders.

Request bodies

From form requests and inline validation.

File uploads

Flip the media type to multipart/form-data automatically.

Every {param} in a route’s URI becomes a path parameter. Docuccino types it from the route itself:

  • A parameter bound to a model through route-model binding is typed from the model’s route key: a uuid- or ulid-formatted string for a HasUuids / HasUlids model, an integer for the default incrementing key, or a plain string for a non-incrementing string key — so a UUID- or ULID-keyed model is documented correctly with no annotation. A model that overrides getRouteKeyName() binds on a column its method body picks, which nothing static can read, so that segment is a plain string and the build reports route-binding.untyped rather than typing it off a key it does not use.
  • A binding that names its own column — {post:slug} — is typed from that column instead: its $casts entry, or the @property type the analyzer recovered for it.
  • An unbound segment is a required string, narrowed by any constraint the route puts on it — see Route constraints.
  • A route with an optional segment ({param?}) is published once per URL it answers — see Optional segments.
  • A route that resolves soft-deleted records (->withTrashed()) notes it on the parameter, so consumers know a trashed record can resolve there.

A model binding also documents the implicit 404 the binding produces when the record is missing (one 404 per operation). See implicit responses.

routes/api.php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']); // {invoice} typed from Invoice's route key
Route::get('/posts/{post:slug}', [PostController::class, 'show']); // {post} typed from Post::$slug

When nothing types the named column — no cast, no @property tag, no analyzer installed — the parameter falls back to a plain string and the build reports route-binding.column-untyped naming it. It never falls back to the model’s route key: an endpoint that takes a slug documented as an integer id is a wrong answer, and a plain string is at least a true one. Declaring the segment’s type with #[PathParameter] settles it and the notice stops — the parameter then carries the type you wrote, so nothing is being described loosely.

A constraint on the route — ->whereUuid(), ->where(), or a Route::pattern() registered before the route — is part of what the endpoint accepts: a value that doesn’t match never reaches your action, because the router answers 404. So a segment nothing else types is documented as what the router matches:

Constraint Documented as
->whereUuid('id') format: uuid
->whereUlid('id') format: ulid
->whereNumber('id'), ->whereAlpha(), ->whereAlphaNumeric() the framework’s expression as a pattern, such as ^[0-9]+$
->whereIn('status', ['draft', 'published']) an enum of those values
->where('code', '[a-z]{3}') pattern: ^[a-z]{3}$
Route::get('/invoices/{number}', [InvoiceController::class, 'show'])->whereNumber('number');
Route::get('/reports/{period}', [ReportController::class, 'show'])->whereIn('period', ['daily', 'weekly']);

A pattern is anchored, because the router matches the whole segment. A bound model’s route key and #[PathParameter] still take precedence over the constraint, and a catch-all such as .* says nothing a plain string doesn’t already.

A constraint on a segment of the route’s host (Route::domain('{tenant}.example.com')) reaches the server variable that host becomes — see Routes bound to a host.

JSON Schema reads a pattern in JavaScript’s regular-expression dialect, and PHP’s differs in ways that matter. In the router \d and \w match every script’s digits and letters, where JavaScript’s match ASCII only, so ->where('id', '\d+') is published as ^(?:[0-9]|[^\x00-\x7F])+$: exact for ASCII, and admitting any other character rather than refusing an Arabic-Indic digit the router serves. A pattern that disagreed with the router would reject values your server serves, so a constraint that no pattern can state truly is documented as a plain string and the build reports route-constraint.unportable: ., an inline flag such as (?i), a possessive quantifier, or a count such as {4} over \d, \w, a property such as \pL, or a negated class — the router counts characters there and JavaScript counts UTF-16 units, so an emoji is two. Write [0-9]{4} where ASCII digits are meant and it is published. For the same reason whereIn() values containing . or other regex characters are not published as an enum: the framework doesn’t escape them, so whereIn('v', ['v1.0']) also matches v1x0.

A route with an optional segment answers more than one URL: /invoices/{status?} serves /invoices and /invoices/{status}. OpenAPI has no optional path parameter — every version requires a path parameter to be required: true, because the path it sits in is only ever that path with the segment present — so Docuccino publishes one operation per URL the route answers. A client, a mock server or a contract test can then call each of them:

Route::get('/invoices/{status?}', [InvoiceController::class, 'index'])->name('invoices.index');
class InvoiceController
{
public function index(string $status = 'open') { /* … */ }
}
paths:
/invoices:
get:
operationId: invoices.index.without-status
/invoices/{status}:
get:
operationId: invoices.index
parameters:
- name: status
in: path
required: true
description: Leaving it off is the same as sending `open`.
schema: { type: string, default: open }

Each additional optional segment adds one more form: /archive/{year?}/{month?} publishes /archive, /archive/{year} and /archive/{year}/{month}. The full form keeps the identity and operationId the route has always had, so a document diffs as that operation unchanged and the shorter ones added. A shorter form’s operationId names the segments it leaves off (.without-status), whether the id comes from the route’s name, the controller method or #[OperationId]; an unnamed route mints one from each form’s own path.

Where leaving the last segment off sends a value — the route’s ->defaults() value, or else the action parameter’s own default — that value is the parameter’s default. A client that fills it in gets exactly what the shorter form gets, which is the only thing a default may promise, so it is published only where the segment could carry it: a string or an integer the route’s constraint accepts, on a segment nothing else typed. A default of null, an array, a value outside the constraint, or one on a bound model is left out, and so is one behind #[PathParameter] with a type of its own.

A {param?} followed by more of the path — /invoices/{year?}/items — is required at runtime too, because the router cannot tell where it would have ended, so it publishes one form.

The router serves a URL with the first route it tries that matches it — in registration order, except that Laravel 13 tries every route bound to a host before the rest — and the document follows, reading each route’s constraints, host and scheme as the router compiles them. Where another route tried first answers every URL a form does, that route is the operation published there: a shorter form it covers is left out, and a route of its own that it covers is never reached, which the build reports as route.shadowed naming both. Where only a route’s full form is covered — /users/{id?} after /users/{id} — the warning names the shorter paths that still reach it. Where the two only share a path — /reports/{year} constrained to numbers beside a /reports/{year} that takes summary or latest — both are reached, so neither is dropped as unreachable; OpenAPI has room for one operation there, so the route’s own path is published and the shorter form is reported as route.operation-collision. Between two shorter forms, the one published is the first by path template, then host, never the one registered first, and a host does not give a path a slot of its own: OpenAPI keys an operation by path and method alone. A cached router (route:cache) builds the same document as the routes it was cached from.

A schema says what shape the segment is. It does not say which attribute of the resource the server looks the value up by, whether the record has to belong to the one before it in the path, or whether a deleted record still resolves — and none of that is guessable from the URL. Docuccino writes what it can prove onto the parameter, as a description a person reads and as an x-docuccino.facts.routeBinding object a client generator can read:

Fact Written when Says
key The route names a column ({post:slug}), or the model’s route key is its declared $primaryKey The attribute the value is matched against
scopedTo The framework resolves this parameter through the one before it — ->scopeBindings(), or a nested child naming its own column, and not where the route calls ->withoutScopedBindings() Only records belonging to that parent match, so a valid id from elsewhere still 404s
withTrashed The route calls ->withTrashed() A soft-deleted record resolves here too

key is left out wherever the column stops being a declaration: a model that overrides getRouteKeyName(), or a segment with a binder of the application’s own (Route::bind) both decide it in a method body. The parameter then falls back to a plain string and says nothing about the column, because naming the wrong one would send every client to fetch by the wrong attribute. Pin it with #[PathParameter] where you know the answer.

Reach for #[PathParameter] to refine a parameter — set an OpenAPI format, a description, or an example, or give an unbound segment a non-string type:

#[PathParameter(name: 'invoice', type: 'string', format: 'uuid', description: 'Invoice public id')]
public function show(Invoice $invoice): InvoiceResource { /* … */ }

Route::fallback() answers whatever no other route matched, so its URI is a placeholder rather than a path a client can call — and OpenAPI has no way to say “any unmatched path”. A fallback route is therefore left out of the document, with a route.fallback-omitted note saying so. Document what a client gets for an unknown path as a 404 response on the operations that can produce one.

Docuccino expresses validation by HTTP verb: read verbs (GET and HEAD) turn the recovered rules into query parameters, and every other verb (POST, PUT, PATCH, DELETE) gets a request body. So the same validation rules that document a create endpoint’s body document a list endpoint’s query string.

// A GET action that validates its input documents query parameters:
public function index(Request $request): AnonymousResourceCollection
{
$request->validate([
'status' => ['nullable', 'in:draft,sent,paid'],
'per_page' => ['nullable', 'integer', 'max:100'],
]);
// …
}

Each field becomes one query parameter carrying that field’s schema; a rule that produced a description (in: on an enum class, a required_if, a date format) puts it on the parameter’s description rather than inside its schema.

List endpoints also get their filters, sorts, includes, and pagination inferred straight from Spatie Query Builder — no annotation needed. To add or patch one parameter by hand, reach for #[QueryParameter].

An endpoint that returns a paginated resource collection documents the key that paginator actually reads — no annotation, no config. Docuccino follows the call chain to the paginating terminal, so the key comes from the call your controller makes:

Your call Documented key
paginate() page — integer, default 1, minimum 1
simplePaginate() page — the same
cursorPaginate() cursor — an opaque string
app/Http/Controllers/InvoiceController.php
public function index(): AnonymousResourceCollection
{
return InvoiceResource::collection(Invoice::query()->cursorPaginate(25));
}

page is the key Paginator::resolveCurrentPage() reads and cursor the one CursorPaginator::resolveCurrentCursor() reads, so the pair matches the {data, links, meta} envelope on the same operation: the body says which page a client is on, and the parameter says how to ask for the next one.

Rename the key at the call site and the document follows it. Laravel takes the name as the third argument, and Docuccino reads it there:

Invoice::query()->paginate(25, ['*'], 'p'); // documents `p`, not `page`

If that argument is something Docuccino can’t read statically — a variable, a config lookup — no page parameter is documented at all. A guessed page would name a key the endpoint never reads, and #[QueryParameter] is the way to state the real one.

paginate() takes its page size from whatever you hand it, so whether a request key is involved is a fact about your own code. Docuccino follows the size argument back — through a local variable, and into the helper that produced it — and documents the key when that argument turns out to come off the request:

final class ListPageSize
{
public static function clamp(Request $request, int $default = 15, int $max = 100): int
{
return max(1, min($request->integer('per_page', $default), $max));
}
}
// In your list builder — `per_page` is documented, because that clamp reads it.
$perPage = ListPageSize::clamp($request);
return Invoice::query()->paginate($perPage);

The argument is the evidence, not the name: a key called limit documents limit. Any of the request accessors that name one key read it — integer(), input(), query(), get(), post() — so you don’t have to cast to be understood. And the same rule says nothing where there is nothing to say — paginate(25) fixes the size at the call site, and paginate() takes it from the model’s $perPage, so neither documents a size key at all.

The key’s value has to reach the size. Inside a helper, that means reaching what the helper returns — through a clamp, an (int) cast, a ??, a ternary or match arm, or a local it named on the way. A key read to decide something else is not a size, however close to the return it sits:

// Documents nothing. `preset` chooses which size to use; the size is a literal either way.
public static function preset(Request $request): int
{
return match ($request->input('preset')) {
'small' => 10,
'large' => 100,
default => 25,
};
}

Two bounds keep it honest beyond that: one variable hop per body, and one helper deep. A variable assigned twice, a helper reading two different keys, a size that is arithmetic over a read ($perPage * 2) — each documents nothing, because the key alone would no longer describe the size the endpoint uses. A shared clamp imported from a trait is read like any other; so is one inherited from a parent class.

Where nothing is recovered, #[QueryParameter] is the way to state the real key:

#[QueryParameter('per_page', type: 'int', description: 'Results per page (max 100).', default: 25)]

This holds wherever the page key comes from, Spatie Query Builder list endpoints included.

Two things have to hold for the key above. What you page has to be a query builder — paginate(), simplePaginate() or cursorPaginate() on an Eloquent or base query builder, a relation, or a Spatie QueryBuilder — and the action has to answer with a plain AnonymousResourceCollection, which is the return type this parameter belongs to. Page a real query builder and return something else — the paginator itself, a Data collection — and no key is documented either. A JSON:API collection is the one return type that misses this and is still covered: its keys come from the json-api-paginate integration instead.

Plenty of applications page something else again: an in-memory Collection, a search-engine result set, a list assembled from several services. A helper like this reads page and per_page off the request, and Docuccino still documents neither — because it builds its LengthAwarePaginator by hand. There is no paginate() on a query builder anywhere in the call graph, so there is no page key to read a name off and no size argument to follow:

public function index(Request $request, CatalogQuery $catalog): PaginatedDataCollection
{
return $catalog->paginate($request, $this->entries(), InvoiceData::class);
}

The response is fine here: a Spatie PaginatedDataCollection (or CursorPaginatedDataCollection) names its paginator in the return type, so its envelope comes from that type alone. That’s a property of those types, not of paginators generally — a Laravel resource collection reads AnonymousResourceCollection whether or not it was paginated, so its envelope comes from the same trace as the page key. It’s the request half that’s missing here, and no configuration recovers it: which keys that helper honors is a fact about its body, not its name. Declare them, once, where they belong:

use Docuccino\Attributes\QueryParameter;
#[QueryParameter('page', type: 'int', description: 'Page number.', default: 1, example: 2)]
#[QueryParameter('per_page', type: 'int', description: 'Results per page (max 100).', default: 25)]
public function index(Request $request, CatalogQuery $catalog): PaginatedDataCollection { /* … */ }

type takes a PHP type string, so 'int' lands as {"type": "integer"}. Set required: true for a key clients must send.

Most applications paginate the same way everywhere, so put the pair where it covers the most actions:

Where you put it Applies to
The action method That one operation.
The controller class Every action on that controller.
A trait method Every class using the trait — trait methods flatten into the using class.
An action method on an abstract base controller Every child that inherits that action.
The class of an abstract base controller Nothing. PHP class attributes don’t inherit.

That last row is the one to watch: #[QueryParameter] on abstract class ApiController is silently inert for the controllers extending it. Put the attributes in a trait those controllers use instead — it reads the same and it works.

A route behind Laravel’s signed middleware refuses any request that doesn’t carry the link’s signature, so the two query parameters the middleware reads are documented on it — no annotation needed:

Parameter Required Schema Why
signature yes string The middleware rejects a request without one.
expires no integer Checked only when present — URL::signedRoute() links carry none, URL::temporarySignedRoute() links do.

Every way of writing the middleware counts: signed, signed:relative, ValidateSignature::relative() or ::absolute(), your own subclass of ValidateSignature, an alias you register for either, and any of those inherited from a middleware group. If you point the signed alias at a different middleware, Docuccino follows it too. The same routes get the implicit 403 the middleware returns. Parameters you exclude from the signature change nothing here, since a client may add them but never has to.

If every link to a route expires, tighten expires with an attribute, which wins over what Docuccino infers:

#[QueryParameter('expires', required: true)]
public function __invoke(User $user, string $hash): Response

To leave a parameter out of the document, use #[IgnoreParam('expires', in: 'query')].

A form request’s rules() — or an inline $request->validate([...]) / Validator::make(...) in the action — becomes the request body schema.

app/Http/Requests/StoreInvoiceRequest.php
public function rules(): array
{
return [
'customer_id' => ['required', 'integer'],
'currency' => ['required', 'in:GBP,USD,EUR'],
'due_at' => ['nullable', 'date'],
];
}

Either way documents a body with customer_id, currency (as an enum), and due_at, with the right required fields and formats — and with an example on each field whose rules pin one, so the body is something a reader can send rather than an empty box. requestBody.required is true as soon as at least one field is required, and omitted when every field is optional.

Because a form request is a single named source class, its body is hoisted to a #/components/schemas/StoreInvoiceRequest component and the operation $refs it — so the same request class used across several endpoints is one shared, deduped component. Rename the component with #[SchemaName], and pin its diff identity across a class rename with #[SchemaId].

The component name says it is a body: a class named StoreInvoiceRequest already does, so it is left alone, and one that doesn’t — a Data class, an action — takes a Request suffix. That keeps the plain name free for the class’s own shape, so a class you both accept and return publishes two components neither of which can come to mean the other.

An inline validate() / Validator::make() body has no source class to name honestly, so it stays inline at the operation. To patch a single property of the inferred body — a description, an example — use #[BodyParameter]; an operation carrying one keeps its body inline for that operation, because the patch adjusts a property the shared component must not carry. Its name is a field path written the way a rule key is — meta.source patches source inside meta — so a nested field is named the same way in both places. To illustrate the body as a whole — the payload a reader would send — #[Example] takes request: true, and has a page of its own: Example payloads. To say something about the body in prose — “send only the fields you’re changing” — see Prose for the body itself.

The rules array is recovered without ever being executed, from any of these:

Source How it’s found
A form request type-hinted on the action Its rules() is analyzed as a constant array.
$request->validate([...]) in the action Read from the action body.
Validator::make($data, [...]) in the action The second argument is read.
Either of the above inside a helper the action calls Docuccino descends into your own code (bounded), so a Validator::make(...) built in a query or service class one or more hops away is still found. Vendor code is never entered.

Within a rules array, Docuccino reads pipe strings ('required|integer'), arrays of rules (['required', 'integer']), the Rule:: factories Rule::enum(), Rule::in(), Rule::exists() and Rule::unique(), and your own rule objects — new SortCode documents from the rule class’s #[RuleSchema]. A field whose rules are a closure, a rule object with no #[RuleSchema], or a values-at-runtime factory like Rule::in(MediaCollections::names()) can’t be read statically. It raises a validation.rule-unrecoverable info diagnostic naming the field, so nothing disappears silently: the field is omitted from the schema, or — when something else documents it, such as a Data property’s own type — kept without the constraints those rules stated.

A rule that writes some of its values and gets the rest from somewhere else — Rule::in('any', $this->fallback()), Rule::in('any', ...$this->statuses()), or an ->only([Draft, $this->extra()]) chain — publishes no value list at all rather than the half it can read, and raises validation.rule-values-unread. A short enum is worse than none: a client generated from it rejects a value your API accepts.

Dot and wildcard paths build a real schema tree. A * segment descends into the current node’s elements, so an items.*.sku rule documents items as an array of objects with a sku property:

public function rules(): array
{
return [
'reference' => ['required', 'string', 'max:32'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.qty' => ['required', 'integer', 'min:1'],
'billing.email' => ['required', 'email'],
];
}

Notice min:1 on items: size rules are applied type-aware, so the same rule bounds minItems on an array, minProperties on an object, minimum on a number, and minLength on anything else. Rule order in your array doesn’t matter — Docuccino sorts each field’s rules into Laravel’s own effect order first, so ['max:100', 'integer'] and ['integer', 'max:100'] produce identical output.

Laravel’s array rule covers both JSON arrays and JSON objects, so a named child key settles which one you meant: writing 'billing' => ['array'] beside billing.email still documents billing as an object, never { "type": "array", "properties": … } — a shape no document validates against. That also picks the size keywords: 'billing' => ['array', 'max:3'] bounds maxProperties, since Laravel counts an object’s keys.

A * child settles nothing, because Laravel applies a field.* rule to every value whatever the keys are. It constrains the value, and the container is whatever else established it — the named children above, or, for a Spatie Data property, the type its docblock states. With nothing else to go on, array plus a * child is a JSON array, which is all the information there is.

An array rule with no child rules at all — no *, no named keys — settles nothing either way, and a free-form map passes it exactly as a list does. Docuccino documents both, "type": ["array", "object"], and bounds both (max:3 writes maxItems and maxProperties, whichever the request turns out to send). The build says so with a validation.container-undecided notice naming the field: add field.* rules or dotted field.<key> rules and the document narrows to the one you meant.

Where the endpoint really does take a free-form map — no keys to enumerate, so no rules to add — say it with #[BodyParameter] instead of inventing rules to shape a schema:

#[BodyParameter(name: 'meta', type: 'object', description: 'Anything the client wants to keep with the order.')]

Naming any key inside the field settles the container the same way, and the notice stops. Naming the field itself settles it as far as its type: does — object and list<int> say which shape it is, array and mixed say nothing the notice wasn’t already saying, so it keeps naming the field.

A custom ValidationRule class does its work at runtime, so there’s nothing to read from its body. Say what it accepts once, on the class, with #[RuleSchema] — and every field validated by it is documented, in form requests, inline validate() calls, and Spatie Data’s #[Rule(new …)] alike:

use Docuccino\Attributes\RuleSchema;
#[RuleSchema(
type: 'string',
pattern: '[0-9]{2}-[0-9]{2}-[0-9]{2}',
min: 8,
max: 8,
description: 'A UK sort code, hyphenated.',
example: '20-15-55',
)]
final class SortCode implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void { /* … */ }
}

Every field is optional, and each one maps onto the same rule the vocabulary below already documents — so a min bounds minLength, minimum, minItems or minProperties by the resolved type, exactly like min:8 would:

Field Documented as
type A JSON Schema type name (string, integer, number, boolean, array) — or any type rule name, so 'email' and 'uuid' bring their formats along.
enum An enum of the allowed values, like in:….
pattern A bare ECMA-262 pattern, like regex:….
min / max The type-aware size keywords, like min: / max:.
format A format, unless the type rule already implied one.
description The property description (appended to any note the other rules produced).
example The property example, typed to match the field.

A property whose rules pin a value gets an example too, without you writing one. A write endpoint’s try-it panel and every generated snippet start filled in rather than blank:

The rules say The example
email / url / ip / uuid / ulid user@example.com · https://example.com · 192.0.2.1 · a sample UUID / ULID
date (and an ISO date_format) 2024-01-01
in:GBP,USD,EUR, Rule::enum(Currency::class) GBP — the first member, the one every viewer and generator shows
integer|min:18, numeric|between:0,10, gte:1 18, 1, 1 — the value 1, raised to any floor, dropped to any ceiling, stepped clear of an exclusive bound, and last of all moved onto a multiple_of
string|max:100, size:5, min:12 a sample of a length the bound allows
digits:5 12345 — a digit run, so leading zeros stay possible
alpha, alpha_dash, starts_with:ACME-, ends_with:.png a value the pattern accepts
boolean true
json · timezone {} · UTC

Two things are true of every one of them. They are constant: the same rules always produce the same bytes, and nothing is read from the clock, the machine’s timezone or its locale — a document rebuilt tomorrow is byte-identical. And they are valid: each candidate is checked against the property’s finished schema, by the same JSON Schema validator contract testing audits your own examples with, before it is published. An example that would fail the endpoint’s own validation is worse than no example, so it is simply not published.

That is also the whole story of when a property gets nothing:

  • the rules pin only a type. A bare string or integer gets no example: "type": "string" already tells a generator that much, and "string" as a value would be bytes without a fact.
  • the constraint lives outside the schema. decimal:2 cannot be met by any JSON number (they carry no trailing zeros); before:tomorrow and gt:other_field are settled at request time; a file upload’s bytes are not an illustration. Each of those withdraws the example rather than guessing.
  • the value contradicts the schema. date_format:d/m/Y publishes "format": "date", so the 01/01/2024 the endpoint actually wants would sit beside a keyword it fails. Nothing is published, and the description still names the format.
  • nothing fits. min:10|max:5, or an email under max:5.

The per-format samples in the first rows are defaults you can replace. They are documentation-reserved values by design (RFC 2606 / 5737 / 3849), so nothing there names a real resource — but if your API reads better with your own, set them per format in docuccino.yaml:

documents:
default:
representation:
examples:
formats: { email: 'jane@example.com', hostname: 'api.example.net' }

It is a merge: the two formats above are replaced and every other format keeps its default. A format the table doesn’t know can be added the same way. And a configured sample earns no exemption from the rule above — it is validated against each field’s finished schema like a derived one, and on a field whose rules reject it the built-in sample is used there in its place, with a config.format-sample-rejected warning naming the format, your value and the keyword it failed. A sample for a format no schema uses is not an error; examples are demand-driven.

Your samples reach every value Docuccino invents, not only the ones in the schema: a Postman collection fills its request bodies, saved response examples and URL variables from the same table, so the email in the body a teammate presses Send on is the email the document publishes.

Anything you write outranks all of it — the prose on the DTO’s own properties (below), a #[BodyParameter(example: …)], a #[RuleSchema(example: …)] on your own rule class, or an #[Example] for the body as a whole. See Example payloads for the full ladder.

The schema says what the body is. What it can’t say is how one endpoint wants it filled in — “send only the fields you’re changing” is true of your PATCH and false of the POST that accepts the same shape, so it belongs on the operation’s body rather than on the type. #[Description] takes request: true for that, and an action can carry one of those alongside its ordinary description:

app/Http/Controllers/InvoiceController.php
#[Description(text: 'Updates an invoice that has not been issued yet.')]
#[Description(text: 'Send only the fields you are changing; anything you leave out keeps its current value.', request: true)]
public function update(UpdateInvoiceRequest $request): InvoiceResource { /* … */ }
app/Http/Requests/UpdateInvoiceRequest.php
#[Description(text: 'The fields of an invoice the billing system will accept.')]
final class UpdateInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => 'required|integer',
'currency' => 'required|in:GBP,USD,EUR',
'due_at' => 'nullable|date',
];
}
}

Three sentences, three slots, and none of them repeats another: the operation says what the endpoint does, the body says how to fill it in, and the component says what the shape is — which every other endpoint accepting an UpdateInvoiceRequest reads too. file: works here as it does anywhere else, so a longer note can live in a Markdown file under your application root.

The prose rides on whatever built the body, so it lands the same way on a recovered form request, a Data class, or a body you assembled yourself out of #[BodyParameter]. An operation with no body has nothing for it to describe — a GET is the common case, where validation rules become query parameters instead — and that is reported as attribute.description-unusable rather than quietly re-pointed at the operation. That report is for a declaration on the action: put one on the controller to cover every action in it and the ones with a body take it, while the ones without stay silent — none of them is where you would go to change it.

A request body is recovered from rules, so its fields are named by rules rather than by the properties behind them. Whatever those properties say about themselves is matched back on: a docblock summary becomes the field’s description, an @example becomes its example, and the attribute forms outrank the docblock, exactly as they do on a response schema.

app/Data/CreateInvoiceData.php
final class CreateInvoiceData extends Data
{
public function __construct(
/**
* The customer's own reference for this invoice.
*
* @example INV-2291
*/
#[Required, StringType, Max(64)]
public readonly string $reference,
#[Description(text: 'Currency the totals are stated in.')]
#[Required, In(['GBP', 'USD', 'EUR'])]
public readonly string $currency,
) {}
}

Your own example replaces the one the rules would have derived — reference publishes INV-2291 rather than a sample of a length max:64 allows. Where a property says nothing, the derived example still stands: currency gets GBP from its own in: list.

None of this changes what the endpoint accepts. Prose rides on the fields the rules named; it never adds one, never drops one and never touches a constraint. A docblock is documentation, not validation.

This is the complete rule vocabulary, with the file rules covered under File uploads below. Anything not listed leaves the property permissive and raises a validation.rule-unhandled info diagnostic — and you can teach Docuccino a new rule by registering a RuleTransformer (see Writing an integration).

Rule Documented as
required, present The field joins the object’s required list.
nullable The type admits null (type: [string, null]); a blank string the middleware turns into null is noted beside it — see Blank values.
sometimes Keeps the field out of required — even alongside required, because sometimes|required means “required when present”.
filled No effect on the documented shape; a nullable field with it takes no blank — see Blank values.
required_if, required_unless, required_with, required_with_all, required_without, required_without_all A description note (“Required when status is paid.”) — a conditional requirement has no JSON Schema keyword.

Laravel’s default middleware trims every string in a request and turns an empty one into null before your rules run. So a nullable field takes "" and " " exactly as it takes null, whatever its other rules say about a value. The published schema doesn’t change for it: a client that wants to clear the field can send null, and a schema that also took a blank would cost every generated client the type it stands for. Docuccino states the reading beside the schema instead, as a fact tooling can act on:

'status' => ['nullable', 'in:draft,sent,paid'],
"status": {
"x-docuccino": {
"facts": { "blankAsNull": "^[\\x00\\t-\\r …]*$" }
},
"anyOf": [
{ "type": "string", "enum": ["draft", "sent", "paid"] },
{ "type": "null" }
]
}

The pattern matches the strings Laravel reads as null there, invisible characters included, read with code-point semantics. It is carried in the full artifact; the OpenAPI exports leave it out with the rest of x-docuccino, so your consumers see the schema alone. The contract assertions read it: a blank in a query string, a form field or a JSON body is checked as the null your rules saw, so a test that clears a form field or sends ?status= passes where your application accepts it.

A field only carries the fact where a blank really gets through. A required, filled, accepted or declined rule refuses the null a blank becomes, and so does a field that isn’t nullable. A conditional rule, like required_if, refuses it only while its condition holds, which the document can’t state — so the fact stands beside one, just as the field stays out of required. A rule this reference doesn’t list leaves the field as permissive as it already is, blank included.

A few keys keep their whitespace. TrimStrings leaves password, password_confirmation and current_password untrimmed by default, so only "" is read as null there, and the fact says so. A whitespace-only value on one of those reaches your rules as a string, and Laravel lets it through without running them; the assertions hold it to the field’s schema instead. Docuccino doesn’t read your middleware stack: keys you add with TrimStrings::except(), or an application without ConvertEmptyStringsToNull, are documented as the default stack treats them.

Laravel runs a field’s rules in the order you write them, and once an exclude rule fires it runs none of the rest. So where an exclude rule sits decides what the required beside it means:

'payment.card_number' => ['exclude_unless:payment.method,card', 'required', 'string'],
'reference' => ['required', 'exclude_if:payment.method,transfer', 'string'],

card_number is only required when payment.method is card, so it stays out of required and gets the note “Required when payment.method is card.” reference is checked before its exclude rule runs, so it’s required on every request.

Rule ahead of required Documented as
exclude_unless:field,value Optional, “Required when field is value.”
exclude_if:field,value Optional, “Required unless field is value.”
exclude_without:a,b Optional, “Required when a, b are all present.”
exclude Optional, and unconstrained: none of the rules after it ever run.
exclude_with:field, Rule::excludeIf(…), Rule::excludeUnless(…), or two exclude rules Optional, with no note: no single “Required when” sentence says exactly when Laravel requires it.

A present after an exclude rule leaves the field optional with no note, since present accepts an empty value that “required” would rule out. A prohibited after exclude_if / exclude_unless becomes the matching prohibited_unless / prohibited_if; after any other exclude rule it’s dropped. Either way the field stays documented, because the API accepts it whenever the exclude rule fires.

The rules after an exclude rule still document the field’s type and constraints, because that’s the value the API reads whenever it keeps the field. When the field is excluded, the API accepts any value there and discards it, so a request sending a wrong-typed value in that case is accepted even though the schema rejects it — unless the object is a tagged union, below, where each shape leaves out the fields it excludes.

When every conditional field of an object is switched on or off by the same field of that object, the object is a tagged union: the API accepts exactly one shape per value of that field. Docuccino documents it that way — one schema per value, told apart by the field — so a generated client gets a type per shape instead of one object with the rules in prose:

'payment' => ['required', 'array'],
'payment.method' => ['required', Rule::enum(PaymentMethod::class)],
'payment.card_number' => ['exclude_unless:payment.method,card', 'required', 'string'],
'payment.iban' => ['exclude_unless:payment.method,transfer', 'required', 'string'],
'payment.reference' => ['required', 'string', 'max:40'],

payment publishes as a oneOf of StoreOrderRequestPaymentCard and StoreOrderRequestPaymentTransfer, with a discriminator on method. Each shape pins method to its value, carries the fields that value keeps with their rules, and requires what that value requires — card_number in one, iban in the other, reference in both. A present field counts as required in the shapes that keep it, since the key has to be sent.

An enum case no exclude rule names still gets a shape, with just the fields every value keeps. The shapes are named after the request, the object’s path and the value’s enum case, so they stay the same however your routes change. If the object is nullable, null is allowed beside the union. If its tag is required_with the object itself, an empty object is allowed beside it too, because that’s what Laravel accepts — unless the object’s own rules refuse one, as required, filled or a min:1 do.

If the same shapes already exist as classes — a sealed interface the response side publishes, say — name it with #[BodyParameter] and the request uses that type instead of minting a second family of shapes a client would have to map by hand:

/** @phpstan-sealed CardPayment|TransferPayment */
interface Payment {}
#[BodyParameter(name: 'payment', type: Payment::class)]
public function store(StoreOrderRequest $request): OrderResource { /* … */ }

payment then publishes as a $ref to Payment. What your rules say that the classes don’t — a max:40, a min:1 on a list, a member the class doesn’t have — rides beside the $ref as one refinement per tag value, on the request only: the Payment component is the one your responses send too, and a limit the server enforces on input is no promise about output. The empty object your rules accept is kept beside it, since no tagged member can describe one. Where the class and the rules both limit a field, both limits hold, so a max:50 rule beside a class’s longer limit is published too; a null the type admits but the rules refuse isn’t offered. Where the declaration accepts less than the rules — an int where the rule is numeric, a shorter list of values than an in: rule allows — the declaration wins, and attribute.body-parameter-narrower tells you the published type refuses something the server accepts. A field the rules require that the class doesn’t have is published beside the $ref, and attribute.body-parameter-unlisted tells you a client built from the class alone can’t send it.

The declared type has to be the same partition: a union of classes that each fix the same field to a value of their own, with exactly the values your rules split by. One told apart by another field, or over other values, is published as you wrote it — without the rules’ limits — and attribute.body-parameter-union says what didn’t match. Name the interface or abstract class rather than spelling the union out (CardPayment|TransferPayment): the shared type is what a client already has a name for. An object that holds another tagged object, or sits inside one, isn’t adopted — the declaration is written over it as any declaration is.

The object stays a single schema with “Required when …” notes whenever the rules don’t prove one shape per value:

  • fields gated on two different fields, or a required_if, prohibited_if, missing_with or other conditional rule in the mix;
  • an exclude rule that isn’t the field’s first rule, or whose condition isn’t a literal list of values;
  • a tag that isn’t required, or isn’t limited to plain string values by in: or Rule::enum();
  • a tag required_with the object, where the object carries a rule other than array, nullable, present, sometimes, required, filled or a size bound, so whether it accepts an empty object isn’t known;
  • a tag, or a field it switches, that prepareForValidation() copies from a header, query value or route parameter, so it isn’t the body’s to send;
  • a request with no class to name the shapes after (an inline validate()), an action carrying its own #[BodyParameter], or rules that become query parameters — though a declaration naming the object can still adopt it, as above.
Rule Documented as
string string
integer, int integer
numeric number
boolean, bool boolean
array array where a * or named child key says which container the field is; [array, object] on its own, since Laravel’s array covers both.
email string, format: email
uuid string, format: uuid
ulid string, format: ulid
url string, format: uri
ip string, format: ip
date string, format: date — the reading of intent where nothing more specific is known.
date_format:Y-m-d H:i string with “Expected format: …” and an example rendered in that pattern, plus format: date/date-time where the pattern is an ISO one those words describe.
json string with contentMediaType: application/json — the value is a string carrying JSON.
timezone string, described as “Must be a valid timezone identifier.”

Laravel’s date accepts anything non-relative strtotime parses, so date on its own is a reading of intent rather than a wire format. Where the property’s own type says more — a Spatie Data property typed CarbonImmutable — that type names the format instead, and the request matches the response byte for byte. See Dates and timestamps.

Rule Documented as
min:n, max:n minimum/maximum on a number, minItems/maxItems on an array, minProperties/maxProperties on an object, minLength/maxLength otherwise.
between:a,b Both bounds, same type-aware keywords.
size:n Both bounds pinned to n.
gt:n, lt:n exclusiveMinimum / exclusiveMaximum (defaults the field to number).
gte:n, lte:n minimum / maximum.
gt:other_field (any comparison against a field) A description note — it’s a runtime relationship, not a bound.
multiple_of:n multipleOf
decimal:2, decimal:1,4 number, plus a note (“Must have 2 decimal places.” / “Must have between 1 and 4 decimal places.”) — JSON Schema has no decimal-places keyword.
digits:8, digits_between:2,4, min_digits:n, max_digits:n An anchored digit pattern — ^\d{8}$, ^\d{2,4}$, ^\d{n,}$, ^\d{1,n}$.
Rule Documented as
before:…, before_or_equal:… string, plus “Must be a date before ….” / “Must be a date on or before ….”
after:…, after_or_equal:… string, plus “Must be a date after ….” / “Must be a date on or after ….”

A date bound is a relationship OpenAPI has no keyword for, so it lands as a description the way the other comparisons do. Where the target is itself a date — after:2026-01-01, before:tomorrow — the field also takes format: date, or format: date-time when the target carries a time. A bare field reference like after:start_date is described but left unformatted: that field could be anything, and a format claim about it would be a guess.

Rule Documented as
in:draft,sent,paid / Rule::in(...) / Rule::enum(InvoiceStatus::class) An enum of the allowed values — integer-typed when every value is a whole number, else string. Rule::enum() reads the enum’s real cases and names the class in the description; a ->only([...]) / ->except([...]) chain narrows the documented values to match.
not_in:a,b not: { enum: ["a", "b"] }
regex:/^INV-\d+$/ pattern, with the PHP delimiters stripped so it’s a bare ECMA-262 regex, read the way route constraints are: under /u, \d and \w match every script, so they are published as the ASCII class or any non-ASCII character. PHP’s $ also matches before a final newline unless the regex carries D, so /^INV-\d+$/ is published as ^INV-\d+\n?$ and /^INV-\d+$/D as ^INV-\d+$. \s and a Unicode property such as \pL under /u are widened the same way, so /^[\pL\s\-]+$/u is published as ^(?:[A-Za-z\t-\r \-]|[^\x00-\x7F])+\n?$. Under /i every letter is spelled in both cases — /^[a-z]+$/iD is published as ^[a-zA-Z]+$ — and under /iu with the two non-ASCII characters PHP folds onto one, the Kelvin sign (U+212A) and the long s (U+017F). A modifier that changes what matches in a way no pattern spells — m, s, x — publishes no pattern, and a validation.regex-modifier notice says so. So does a body no pattern states truly — ., a lone \s or a count over a negated class, a Unicode-wide escape or a property, a widened escape or property beside a second unbounded quantifier or inside a repeated group, such as \pL+\s*\pL* or (?:\pL+-?)*, whose widened atoms would overlap on every non-ASCII character and send a validator backtracking far longer than PHP, inline flags, lookarounds, \B without /u, anything after a $ that could take that final newline — with a validation.regex-unportable notice.
alpha:ascii, alpha_num:ascii, alpha_dash:ascii The ASCII class as an anchored pattern — ^[a-zA-Z0-9_-]+$ for alpha_dash:ascii.
alpha, alpha_num, alpha_dash Laravel accepts letters, marks and numbers from any script here, so the pattern is the ASCII class plus any non-ASCII character — ^(?:[a-zA-Z0-9_-]|[^\x00-\x7F])+$. That’s exact for ASCII and wider beyond it, because the exact \p{L} form reads differently in validators that compile without the u flag.
starts_with:INV- / ends_with:.pdf An anchored pattern for a single value; a multi-value set becomes a description (“Must start with one of: …”), since there’s no single pattern for it.
accepted, accepted_if boolean with const: true (the _if form adds “Must be accepted when …”).
declined, declined_if boolean with const: false.
list array
distinct uniqueItems: true
exists, unique A type only (string if the field is otherwise untyped) — a foreign-key lookup isn’t a documentable shape.
confirmed Adds the implicit {field}_confirmation partner, mirroring the field’s type and required flag.
Rule Documented as
format:iban A format, unless a type rule already set one.
description:… The property description, appended to any note earlier rules produced.
example:… The property example, coerced to the field’s resolved type.

These three aren’t Laravel rules, so don’t put them in a rules array — Laravel would reject them. They exist so a #[RuleSchema] reaches the schema through the same chain as every other rule, and they’re applied last, so they see the finished property.

Rule Documented as
prohibited The field is omitted from the documented body. It can never be sent, so documenting it would invite exactly what the API rejects. Any nested key under it goes too.
prohibited_if:other,value / prohibited_unless:other,value The field stays documented and optional — it’s sendable in some states — with the condition as a description (“Must not be sent when …”).
prohibits:other Documented and optional: this rule constrains the other field, and says so in the description.

bail and current_password. These are consumed deliberately — they’re valid rules that say nothing about the wire shape, so they never raise an unhandled-rule diagnostic. The exclude rules raise none either; what they change is covered under Exclude rules.

A file or image rule documents the field as type: string, format: binary; a file, image, mimes, mimetypes, extensions, or dimensions rule also flips the request media type to multipart/form-data automatically, with the rest of the body carried alongside it. An image rule adds “An image file.” as the description.

mimes, mimetypes and extensions only mark the request as an upload — OpenAPI expresses an accepted file type through the media type, not a schema keyword, so nothing is invented on the property. dimensions likewise records its width/height constraints as a description note, since there is no keyword for pixel dimensions.

public function rules(): array
{
return [
'invoice_id' => ['required', 'integer'],
'attachment' => ['required', 'file', 'mimes:pdf'], // → binary string, multipart/form-data
];
}

A file size bound (max, min, size, between) on a file field is in kilobytes, so it is documented as a description note (e.g. “Maximum file size: 2048 KB.”) rather than a string-length keyword, which would be wrong.

You don’t always spell out a file rule. A Spatie Data property typed Illuminate\Http\UploadedFile is a file upload by its type alone — so it’s documented as a binary string and flips the body to multipart/form-data, even when the rules that constrain it are computed at runtime and can’t be read statically. ?UploadedFile stays nullable, a list of UploadedFile becomes a multipart array of binary items, and scalar properties alongside it are carried in the same multipart body:

final class CreateUploadData extends Data
{
public function __construct(
public readonly UploadedFile $file, // → binary string, multipart/form-data
public readonly string $collection = 'default', // carried alongside it
) {}
}

An explicit file/image rule or attribute is honored as before and never doubled.

A header your code reads by name is documented for you. $request->header('Idempotency-Key'), $request->hasHeader(…), $request->headers->get(…), request()->header(…) and the Request facade all count — in the action, in anything it calls, and in the FormRequest, including the methods Laravel runs while resolving it such as authorize() and prepareForValidation(). The methods laravel-actions calls on an action while it validates count the same way:

class StoreOrderRequest extends FormRequest
{
public function idempotencyKey(): ?string
{
return $this->header('Idempotency-Key');
}
}

A read proves the server looks at the header, not that it insists on it, so the parameter is optional. One header read under several spellings is one parameter. Some reads are never published this way:

  • Accept, Content-Type and Authorization — OpenAPI states them through the request body, the responses and the security scheme.
  • A header an apiKey security scheme or the API version already carries.
  • A header only the FormRequest’s, or the action’s, authorize() reads. That is a gate: without the header the request is refused, so it is not an optional input, and its name is often one you never meant to publish.
  • Headers the transport or a proxy sets rather than your clients: Host, Cookie, Content-Length, Connection, Transfer-Encoding, Forwarded, X-Forwarded-*, X-Real-IP and CF-Connecting-IP.
  • A name built at runtime, $request->header($name), which Docuccino cannot know.

A header your code reads anywhere else is published, so check what your document says about any header you use for access control outside authorize(). To keep one out, name it — as the document spells it — in #[IgnoreParam]:

#[IgnoreParam(name: 'X-Internal-Trace', in: 'header')]
public function show(Order $order): OrderResource { /* … */ }

The Laravel way to validate a header — or a query value, or a route parameter — with ordinary rules is to copy it into the input in prepareForValidation(). Docuccino follows the copy: the rules for that key describe the header, and the key leaves the request body, because merge() writes over whatever the body sent:

class StoreOrderRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'idempotency_key' => $this->header('Idempotency-Key'),
'per_page' => $this->query('per_page'),
]);
}
public function rules(): array
{
return [
'sku' => ['required', 'string'],
'idempotency_key' => ['required', 'uuid'],
'per_page' => ['nullable', 'integer', 'max:100'],
];
}
}

A header that was not sent is copied as null, and the key is there either way, so sometimes never skips its rules. The parameter is required unless the rules let a null through: nullable does, and so does a rule that only bounds a value, such as max:36 on its own. Which rules refuse one is asked of the Laravel you have installed, because that moves between versions: 13 refuses a null for ascii, and 12 lets it through. A rule that reads another field, a database or the network is never asked, so it never makes the parameter required. A route parameter is already required and typed by the route, so its copy only leaves the body. A #[HeaderParameter] still wins over what the rules say.

The same copy is followed in two other places it is written. A laravel-actions action’s prepareForValidation(ActionRequest $request) merges into the request the package then validates, and a controller action can merge before it validates inline:

public function store(Request $request): OrderResource
{
$request->merge(['idempotency_key' => $request->header('Idempotency-Key')]);
$validated = $request->validate([
'sku' => ['required', 'string'],
'idempotency_key' => ['required', 'uuid'],
]);
// ...
}

Only a copy Docuccino can be certain of counts: an unconditional merge([...]) on the request, with a literal key and a value read by a literal name — header('…'), headers->get('…'), query('…') or route('…') on that same request. A hook inherited from a parent class or taken from a trait counts like one written in the class. In a controller the merge has to come before the one $request->validate([...]) or Validator::make($request->all(), [...]) (with or without ->validate() after it) in the action’s own body. A copy inside an if, a key written twice, a computed value such as Str::slug($this->input('title')), or code that also calls your own methods, hands the request to other code, replace()s the input or returns early is documented as if the copy were not there. So is a FormRequest that overrides how its data reaches the validator — its own validationData(), all(), validator(), createDefaultValidator(), getValidatorInstance() or validateResolved() — and an action with its own getValidationData() or getValidator(), since what those validate need not be the merged input. A withValidator() that only adds rules or after() checks keeps the copy; one that calls setData() or hands the validator to other code does not, and neither does an inline validator given setData() before it runs.

To say more — that a header is required, its format, what it is for — declare it. #[HeaderParameter] and #[CookieParameter] are repeatable, take the same shape, and win over what was read:

#[HeaderParameter(name: 'Idempotency-Key', type: 'string', format: 'uuid', required: true, description: 'Replay guard for retries')]
#[CookieParameter(name: 'invoice_preview', type: 'string', description: 'Signed preview token')]
public function store(StoreInvoiceRequest $request): InvoiceResource { /* … */ }

Cookies are declared only: Laravel encrypts the cookies it sets, so a client cannot send one it was not given, and a cookie read is not an input a generated client could supply.

Parameters are emitted in a fixed order — path, then query, then header, then cookie, each group sorted by name — so adding one never reshuffles the rest of the list.

To drop a parameter Docuccino inferred but you don’t want published, use #[IgnoreParam] — optionally scoped to one location, #[IgnoreParam(name: 'trace', in: 'query')].

If a rule set can’t be resolved statically, Docuccino contributes what it can and leaves the rest to your annotations, recording an info diagnostic rather than guessing. A #[BodyParameter], #[QueryParameter], or a docblock always takes precedence over what’s inferred — you’re never fighting the tool.

Because Docuccino recovers your request’s validation rules, it also documents the 422 validation error that request produces — so a validated endpoint carries its 422 automatically. That holds for a read route too: its rules become query parameters rather than a body, and a value they refuse is the same 422. A query parameter you only declare with #[QueryParameter] has no validator behind it, so it adds none. See implicit responses.