Path parameters
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.
Query parameters
Request bodies
File uploads
multipart/form-data automatically.Path parameters
Section titled “Path parameters”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- orulid-formatted string for aHasUuids/HasUlidsmodel, anintegerfor the default incrementing key, or a plainstringfor a non-incrementing string key — so a UUID- or ULID-keyed model is documented correctly with no annotation. A model that overridesgetRouteKeyName()binds on a column its method body picks, which nothing static can read, so that segment is a plainstringand the build reportsroute-binding.untypedrather 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$castsentry, or the@propertytype 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.
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']); // {invoice} typed from Invoice's route keyRoute::get('/posts/{post:slug}', [PostController::class, 'show']); // {post} typed from Post::$slugWhen 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.
Route constraints
Section titled “Route constraints”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.
Optional segments
Section titled “Optional segments”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.
What the binding tells a consumer
Section titled “What the binding tells a consumer”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 { /* … */ }Fallback routes
Section titled “Fallback routes”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.
Query parameters
Section titled “Query parameters”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].
Pagination
Section titled “Pagination”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 |
public function index(): AnonymousResourceCollection{ return InvoiceResource::collection(Invoice::query()->cursorPaginate(25));}"parameters": [ { "name": "cursor", "in": "query", "required": false, "description": "Opaque cursor for the next/previous page.", "schema": { "type": "string" } }]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.
The page-size key
Section titled “The page-size key”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.
Pagination Docuccino can’t see
Section titled “Pagination Docuccino can’t see”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 { /* … */ }"parameters": [ { "name": "page", "in": "query", "description": "Page number.", "required": false, "schema": { "type": "integer", "default": 1 }, "example": 2 }, { "name": "per_page", "in": "query", "description": "Results per page (max 100).", "required": false, "schema": { "type": "integer", "default": 25 } }]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.
Signed URLs
Section titled “Signed URLs”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): ResponseTo leave a parameter out of the document, use #[IgnoreParam('expires', in: 'query')].
Request bodies
Section titled “Request bodies”A form request’s rules() — or an inline $request->validate([...]) / Validator::make(...) in the
action — becomes the request body schema.
public function rules(): array{ return [ 'customer_id' => ['required', 'integer'], 'currency' => ['required', 'in:GBP,USD,EUR'], 'due_at' => ['nullable', 'date'], ];}public function store(Request $request): InvoiceResource{ $request->validate([ 'customer_id' => ['required', 'integer'], 'currency' => ['required', 'in:GBP,USD,EUR'], 'due_at' => ['nullable', 'date'], ]); // …}// Form request: the body hoists to a named component the operation $refs."requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoreInvoiceRequest" } } }},// components.schemas"StoreInvoiceRequest": { "type": "object", "properties": { "customer_id": { "type": "integer" }, "currency": { "type": "string", "enum": ["GBP", "USD", "EUR"], "example": "GBP" }, "due_at": { "type": ["string", "null"], "format": "date", "example": "2024-01-01" } }, "required": ["customer_id", "currency"]}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.
Where the rules can come from
Section titled “Where the rules can come from”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.
Nested and array fields
Section titled “Nested and array fields”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'], ];}{ "type": "object", "properties": { "reference": { "type": "string", "maxLength": 32 }, "items": { "type": "array", "minItems": 1, "items": { "type": "object", "properties": { "sku": { "type": "string" }, "qty": { "type": "integer", "minimum": 1 } }, "required": ["sku", "qty"] } }, "billing": { "type": "object", "properties": { "email": { "type": "string", "format": "email" } }, "required": ["email"] } }, "required": ["reference", "items", "billing"]}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.
Documenting a custom rule
Section titled “Documenting a custom rule”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 { /* … */ }}public function rules(): array{ return [ 'amount' => ['required', 'integer', 'min:1'], 'sort_code' => ['required', new SortCode], ];}{ "sort_code": { "type": "string", "pattern": "[0-9]{2}-[0-9]{2}-[0-9]{2}", "minLength": 8, "maxLength": 8, "description": "A UK sort code, hyphenated.", "example": "20-15-55" }}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. |
Examples come with the rules
Section titled “Examples come with the rules”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
stringorintegergets 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:2cannot be met by any JSON number (they carry no trailing zeros);before:tomorrowandgt:other_fieldare settled at request time; afileupload’s bytes are not an illustration. Each of those withdraws the example rather than guessing. - the value contradicts the schema.
date_format:d/m/Ypublishes"format": "date", so the01/01/2024the 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 anemailundermax: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.
Prose for the body itself
Section titled “Prose for the body itself”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:
#[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 { /* … */ }#[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', ]; }}"description": "Updates an invoice that has not been issued yet.","requestBody": { "description": "Send only the fields you are changing; anything you leave out keeps its current value.", "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateInvoiceRequest" } } }},// components.schemas"UpdateInvoiceRequest": { "description": "The fields of an invoice the billing system will accept.", "type": "object", "properties": { "currency": { "type": "string", "enum": ["GBP", "USD", "EUR"], "example": "GBP" }, "customer_id": { "type": "integer" }, "due_at": { "type": ["string", "null"], "format": "date", "example": "2024-01-01" } }, "required": ["customer_id", "currency"]}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.
Prose from the DTO itself
Section titled “Prose from the DTO itself”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.
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, ) {}}"properties": { "reference": { "type": "string", "maxLength": 64, "description": "The customer's own reference for this invoice.", "example": "INV-2291" }, "currency": { "type": "string", "enum": ["GBP", "USD", "EUR"], "description": "Currency the totals are stated in.", "example": "GBP" }}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.
Validation rule reference
Section titled “Validation rule reference”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).
Presence
Section titled “Presence”| 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. |
Blank values
Section titled “Blank values”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.
Exclude rules
Section titled “Exclude rules”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.
Tagged unions
Section titled “Tagged unions”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.
When you already have the type
Section titled “When you already have the type”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_withor 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:orRule::enum(); - a tag
required_withthe object, where the object carries a rule other thanarray,nullable,present,sometimes,required,filledor 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.
Types and formats
Section titled “Types and formats”| 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.
Sizes and ranges
Section titled “Sizes and ranges”| 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}$. |
Dates and comparisons
Section titled “Dates and comparisons”| 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.
Choices and patterns
Section titled “Choices and patterns”| 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. |
Schema annotations
Section titled “Schema annotations”| 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.
Prohibitions
Section titled “Prohibitions”| 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. |
Recognized with no schema effect
Section titled “Recognized with no schema effect”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.
File uploads
Section titled “File uploads”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.
UploadedFile-typed Data properties
Section titled “UploadedFile-typed Data properties”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.
Headers and cookies
Section titled “Headers and cookies”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'); }}"parameters": [ { "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string" } }]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-TypeandAuthorization— OpenAPI states them through the request body, the responses and the security scheme.- A header an
apiKeysecurity 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-IPandCF-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 { /* … */ }Validating a header with your rules
Section titled “Validating a header with your rules”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'], ]; }}"parameters": [ { "name": "per_page", "in": "query", "required": false, "schema": { "type": "integer", "maximum": 100 } }, { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "format": "uuid" } }]The request body documents sku alone.
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 { /* … */ }"parameters": [ { "name": "Idempotency-Key", "in": "header", "description": "Replay guard for retries", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "invoice_preview", "in": "cookie", "description": "Signed preview token", "required": false, "schema": { "type": "string" } }]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')].
When it can’t tell
Section titled “When it can’t tell”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.
Implicit 422
Section titled “Implicit 422”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.