Skip to content

Requests

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

Path parameters

From the route template and route-model bindings.

Query parameters

From validation on read routes — and your query builders.

Request bodies

From form requests and inline validation.

File uploads

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

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

  • A parameter bound to a model through route-model binding is typed from the model’s route key: a uuid- or ulid-formatted string for a HasUuids / HasUlids model, an integer for the default incrementing key, or a plain string for a non-incrementing string key — so a UUID- or ULID-keyed model is documented correctly with no annotation. A model that overrides getRouteKeyName() binds on a column its method body picks, which nothing static can read, so that segment is a plain string and the build reports route-binding.untyped rather than typing it off a key it does not use.
  • A binding that names its own column — {post:slug} — is typed from that column instead: its $casts entry, or the @property type the analyzer recovered for it.
  • An unbound segment is a required string.
  • 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rule Documented as
required, present The field joins the object’s required list.
nullable The type admits null (type: [string, null]).
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.
Rule Documented as
string string
integer, int integer
numeric number
boolean, bool boolean
array array where a * or named child key says which container the field is; [array, object] on its own, since Laravel’s array covers both.
email string, format: email
uuid string, format: uuid
ulid string, format: ulid
url string, format: uri
ip string, format: ip
date string, format: date — the reading of intent where nothing more specific is known.
date_format:Y-m-d H:i string with “Expected format: …” and an example rendered in that pattern, plus format: date/date-time where the pattern is an ISO one those words describe.
json string with contentMediaType: application/json — the value is a string carrying JSON.
timezone string, described as “Must be a valid timezone identifier.”

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

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

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

Rule Documented as
in:draft,sent,paid / Rule::in(...) / Rule::enum(InvoiceStatus::class) An enum of the allowed values — integer-typed when every value is a whole number, else string. Rule::enum() reads the enum’s real cases and names the class in the description; a ->only([...]) / ->except([...]) chain narrows the documented values to match.
not_in:a,b not: { enum: ["a", "b"] }
regex:/^INV-\d+$/ pattern, with the PHP delimiters 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.
Rule Documented as
format:iban A format, unless a type rule already set one.
description:… The property description, appended to any note earlier rules produced.
example:… The property example, coerced to the field’s resolved type.

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

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

bail, 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.

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

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

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

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

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

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

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

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 are emitted in a fixed order — path, then query, then header, then cookie, each group sorted by name — so adding one never reshuffles the rest of the list.

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

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

Because Docuccino recovers your request’s validation rules, it also documents the 422 validation error that request produces — so a validated 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.