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. - An optional segment (
{param?}) is documented as optional. - 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.
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.
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]). |
sometimes |
Keeps the field out of required — even alongside required, because sometimes|required means “required when present”. |
filled |
Recognized; no effect on the documented shape. |
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. |
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 and flags stripped so it’s a bare ECMA-262 regex. |
alpha, alpha_num, alpha_dash |
The equivalent anchored pattern. |
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, exclude, exclude_if, exclude_unless, exclude_with, exclude_without,
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.
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”Headers and cookies aren’t inferable — nothing in a route or a rules array names them — so declare
them where they matter. #[HeaderParameter] and
#[CookieParameter] are repeatable and take the same
shape:
#[HeaderParameter(name: 'X-Idempotency-Key', type: 'string', 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": "X-Idempotency-Key", "in": "header", "description": "Replay guard for retries", "required": true, "schema": { "type": "string" } }, { "name": "invoice_preview", "in": "cookie", "description": "Signed preview token", "required": false, "schema": { "type": "string" } }]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 write endpoint carries its 422 automatically. A read
route’s rules become query parameters rather than a body, so it gets no implicit 422. See
implicit responses.