Resources, models & enums
These three always-on integrations produce the reusable component schemas your responses reference.
Every one of them is hoisted to #/components/schemas and shared by reference, so a resource, model or
enum is defined exactly once no matter how many operations use it.
API Resources
Section titled “API Resources”Docuccino documents any JsonResource from the shape of its toArray() method. Conditional fields —
whenLoaded, when, whenNotNull — become optional properties, so clients know they may be
absent. A resource returned as Resource::collection(...) is documented as an array of that schema.
public function toArray(Request $request): array{ return [ 'id' => $this->id, 'total' => $this->total, 'status' => $this->status, 'customer' => $this->whenLoaded('customer', fn () => new CustomerResource($this->customer)), // optional ];}"InvoiceResource": { "type": "object", "properties": { "id": { "type": "integer" }, "total": { "type": "number" }, "status": { "$ref": "#/components/schemas/InvoiceStatus" }, "customer": { "$ref": "#/components/schemas/CustomerResource" } }, "required": ["id", "total", "status"]}The conditional customer isn’t in required — whenLoaded made it optional — and it stays a plain
$ref: an unloaded relation is simply omitted from the response, never sent as null. Give whenLoaded
its second closure argument (as above) so the optionality is recovered — passing a bare
$this->whenLoaded('customer') straight into a resource constructor hides the conditional, and the
property comes out required. status is a $ref to the shared InvoiceStatus enum component (see
Enums) — each enum is hoisted once and referenced everywhere it appears.
Merged values. merge(), mergeWhen() and mergeUnless() splice their array’s keys into the
parent shape, exactly as Laravel serializes them — never nested under a numeric key. Keys spliced
by a conditional merge are optional; keys from a plain merge() are not.
Several return paths. A toArray() that branches on the request and returns different arrays has
every branch merged into one schema, rather than the first branch winning and the others’ keys
vanishing.
Naming. A component takes the resource’s short class name by default. Override the published name
with #[SchemaName], and pin the identity the diff engine
tracks — so a class rename isn’t reported as a removal plus an addition — with
#[SchemaId].
Wrapping. A top-level resource is wrapped under data (Laravel’s default); nested resources are
not, so they can be shared by reference. JsonResource::withoutWrapping() is a global runtime call —
made in a service provider, not on the class — so Docuccino can’t see it, and api_resources.wrap is
the escape hatch:
wrap |
Result |
|---|---|
| omit (default) | Each resource’s own $wrap (data unless overridden). |
false |
Never wrap. |
true |
Wrap under data. |
'result' (any string) |
Wrap under that key. |
Spatie Data classes are the exception: there, withoutWrapping() is written on the class, so it’s
read statically and needs no config — see
classes that unwrap themselves.
Laravel 13’s first-party JSON:API resources are supported too: toAttributes(),
toRelationships(), toLinks() and toMeta() build the resource object, and the include and
fields[TYPE] query parameters those routes accept are added automatically. Pre-13 apps get identical
output from the timacdonald/json-api integration.
Eloquent models
Section titled “Eloquent models”When a model appears in your output, Docuccino documents it from its columns and casts, refined by
the model’s own conventions: $hidden and class-level #[Hidden] remove properties, $visible
restricts to an allow-list, and $casts (whether the $casts property or the casts() method) set
each column’s shape. Those two lists reach everything the model serializes — a column, an appended
attribute, an eager-loaded relation — exactly as Eloquent applies them, so a name you hide is a name
the document doesn’t publish. Columns come from the model’s @property / @property-read docblock tags — the
ide-helper convention — so a model whose attributes are magic still documents fully. Even without those
tags there’s a floor: a $casts key is a column (typed from its cast), a $dates entry is a date-time
column, and a $fillable-only name is a permissive column. Every serialized column is marked required (a
nullable one as required with a null-admitting type).
A cast pins its column’s shape. Native casts:
| Cast | Documented as |
|---|---|
datetime, immutable_datetime |
string, format: date-time |
date, immutable_date |
string, format: date-time in a response body; format: date in a filter or a bound path segment |
datetime:Y-m-d, date:Y-m-d |
string, format: date in a response body (a date-only pattern) |
datetime:<custom format>, date:<custom format> |
string, with the format named in the description — a bespoke format is neither date nor date-time, so claiming one would be wrong |
timestamp |
integer (a unix timestamp, not a date string) |
boolean, bool |
boolean |
integer, int |
integer |
real, float, double |
number |
decimal:2, hashed, string |
string |
array, collection, json |
type: ["array", "object"] — a JSON column decodes to whatever it stored |
object |
object |
encrypted:<inner> |
The inner cast’s shape — it decrypts then casts, so it’s never an opaque string |
| A backed enum class | A $ref to the shared enum schema |
And the built-in As* class casts:
| Cast | Documented as |
|---|---|
AsStringable, AsUri, AsHtmlString |
string |
AsArrayObject, AsFluent, AsEncryptedArrayObject |
object |
AsCollection, AsEncryptedCollection |
array |
AsEnumCollection:InvoiceStatus, AsEnumArrayObject:InvoiceStatus |
An array of that enum’s values |
A custom CastsAttributes caster is typed from its get() return type — so a Money or value-object
cast documents as whatever get() returns, not an opaque string. An unrecognized caster falls back to
the column’s inferred type.
Accessors, appends and relations
Section titled “Accessors, appends and relations”Accessors. A classic getFullNameAttribute() and an Attribute::make(get: …) accessor both flow
their return type into the schema: an accessor for an $appends attribute types that appended property,
and an accessor that shadows a real column overrides its type (the cast is skipped, exactly as Eloquent
serializes it). An accessor whose return type can’t be determined statically leaves the property
permissive rather than guessing.
Default eager loads. Relations listed in $with serialize on every response, so each is documented
as a nested schema under its snake-cased key — a to-many relation as an array of the related model, a
to-one relation as a nullable reference. Nesting is bounded by the same component-sharing that hoists
every model to a reusable #/components/schemas entry: a relation back to a model already being
expanded (a cycle, or a self-relation) resolves to a $ref rather than recursing forever, so deeply
related models stay a graph of references, never an infinite inline expansion. A relation you hide is
left out, under the name it’s loaded by — $hidden = ['latestPost'], the relation’s own name, rather
than the snake-cased key it serializes as, which is the name Eloquent matches too.
Polymorphic relations. A morphTo becomes a oneOf of the possible models, with a
discriminator when every variant is registered in a morph map — so clients can tell the variants
apart reliably:
"schema": { "oneOf": [ { "$ref": "#/components/schemas/Invoice" }, { "$ref": "#/components/schemas/Subscription" } ], "discriminator": { "propertyName": "type", "mapping": { "invoice": "#/components/schemas/Invoice", "subscription": "#/components/schemas/Subscription" } }}Dates. Eloquent hands every date attribute — a date cast, a $dates entry, the framework
timestamps, a soft-delete column — to serializeDate() before it goes out, so the column is documented
as the string that writes: string, format: date-time. That holds whatever typed the name, which
matters because php artisan ide-helper:models tags each date column with the Carbon class the
attribute holds, and a Carbon is a PHP object rather than anything a response carries.
A date cast is no exception: Eloquent rounds the value to the start of the day and then serialises it
through the same hook, so a response sends 2024-01-01T00:00:00.000000Z and the body documents a
date-time. Where the same column is something a client sends — a filter[...] value, or a segment a
route binds on (/almanacs/{almanac:observed_on}) — it is the date the column stores, and the parameter
documents format: date. The two answers differ because the questions do: one describes what the server
wrote, the other what it will match.
A cast that names its own format (datetime:d/m/Y, date:d/m/Y) never reaches the hook — Eloquent
formats it with the parameter — so the response body is documented from that pattern: format: date or
format: date-time where the pattern really writes one, and otherwise a plain string with the pattern
named in the description. A request still accepts the domain the cast names, because the value a client
sends is matched against the stored column and not against the serialised attribute.
The same reading applies wherever else a date-time reaches the document — a DTO property, an accessor’s
return type, a webhook payload. A Carbon is documented as the RFC 3339 string it sends, not as the object
its properties would reflect into. Your own date class is another matter: implementing JsonSerializable
says the class chooses its own wire format, not what that format is, so a value typed at one — or at the
bare DateTimeInterface, which any of them satisfies — is documented as an unconstrained schema rather
than as a guess a client would validate against and fail. Pin it with #[Response], a return docblock,
or an overlay, and the document says what your class really sends.
Custom date serialization. A model that overrides serializeDate() chooses its own wire format for
every date attribute the hook reaches, which can’t be known statically — so the shape recovered for those
date and datetime columns is a plain string (no format), with an info diagnostic noting the format is
unknowable. That covers every such column the model has, a @property-tagged one included, and a path
parameter bound on one (/journals/{journal:filed_on}) drops its format the same way and is reported
against its route. A column whose cast names its own format is untouched by the override and keeps that
format.
The notice follows the document, not the method: an override normally lives on a base model every class
extends, and a subclass that publishes no date attribute — none at all, or only hidden ones — is not
reported. Nor is a column an accessor publishes instead of the override, since Laravel sends a mutated
attribute exactly as the accessor returned it. Nothing puts a COLUMN’s format back — no attribute
carries one for a column, and a docblock type has no format to state — so if your clients need an exact
one for a response column, state it in an
overlay. A bound path segment is the
one place an annotation reaches: name it in a
#[PathParameter] with a format:. Either way the
notice keeps naming what could not state the format.
A backed enum is documented as an enum schema from its cases (integer- or string-typed to match
its backing). Add per-case descriptions with
#[CaseDescription]:
enum InvoiceStatus: string{ #[CaseDescription('Created but not yet sent to the customer')] case Draft = 'draft';
#[CaseDescription('Sent and awaiting payment')] case Sent = 'sent';
#[CaseDescription('Paid in full')] case Paid = 'paid';}"InvoiceStatus": { "type": "string", "enum": ["draft", "sent", "paid"], "x-enumDescriptions": { "draft": "Created but not yet sent to the customer", "sent": "Sent and awaiting payment", "paid": "Paid in full" }, "x-enum-varnames": ["Draft", "Sent", "Paid"]}The enum is hoisted once to a shared #/components/schemas/InvoiceStatus component, and every place it
appears — a property, a query-parameter item schema, an enum-cast column — $refs it. So its cases and
descriptions live in exactly one place. A nullable enum can’t carry null on a $ref, so it composes
as anyOf: [{ "$ref": … }, { "type": "null" }]. Prefer the old inline expression (the enum’s
type/enum repeated at every use site)? Set representation.enums.components to false.
A case with no #[CaseDescription] falls back to its docblock summary — the first prose line of the
case’s doc comment — and the attribute wins when both are present. The value-keyed
x-enumDescriptions map appears only when every case is described (some renderers hide cases
missing from it); the same prose always also ships as the parallel x-enum-descriptions array —
the spelling code generators read — with an empty slot for an undescribed case, so partial prose is
never lost. Codegen name hints are on by default — x-enum-varnames as in the excerpt, plus
x-enumNames carrying the same names for NSwag — governed by
representation.enums.naming: names emits
both, a single-key keyword pins one tool’s, none keeps documents lean. The same hints, from the
same switch, decorate the
Query Builder value enums.
Where a reader actually sees that prose depends on the viewer: the bundled reference shows it beside each value, and the two shipped drivers differ in which spelling they read — see what each driver shows from an enum.
A pure enum — one with no backing type — has no values to publish, so it’s documented as a string schema listing its case names.
Arrays: lists and maps
Section titled “Arrays: lists and maps”PHP spells both with array; the emitted schema keeps them apart.
| The resolved type | Documented as |
|---|---|
list<string>, or anything else known to be a list |
{ "type": "array", "items": { "type": "string" } } |
array<string, list<string>> — a keyed collection, like validation errors |
{ "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } |
A literal shape, ['id' => int, 'total' => float] |
An object with those named properties |
A bare array with nothing to refine it |
Permissive — no type claimed |
Since array says nothing about its elements, Docuccino reads the tag that does:
/** @property list<string> $tags */ // an ide-helper model column/** @var list<LineItem> */ // a declared property/** @param list<string> $codes */ // a promoted constructor propertyThe declaration wins wherever it’s specific, so a native string is never second-guessed and a vague
@var array never displaces a precise type. The one thing a tag may add to a specific declaration is
its missing generics: DataCollection $lines plus @param DataCollection<int, LineItemData> $lines
recovers the element type, and nothing else — a tag can’t swap the class or make it nullable. Short
class names resolve through that file’s use statements, and an accessor’s @return list<string>
types its property the same way. The @phpstan-/@psalm- prefixed forms of each tag are read too,
and win over the plain one where both are present.
Describing a schema
Section titled “Describing a schema”A component says what its fields are; #[Description] on the class says what the thing is, once, for
every operation that references it:
#[Description(text: 'A single retention policy, as the billing system holds it.')]final class RetentionPolicyData extends Data { /* … */ }That sentence becomes the schema’s description on both sides of the document — the response component
and, for a DTO you accept, the request body component too.
Docuccino does not read the class docblock for this, and that is deliberate. A class docblock is where you explain a class to whoever maintains it next, so it tends to name properties, casts, attributes and internals that the consumer of your document cannot see. A description that misinforms costs a reader more than an absent one, so the attribute — which says publish this sentence — is the one that publishes. Your docblock stays yours.
Per-field prose works the same way, from either form: see
#[Description].
Mock data hints
Section titled “Mock data hints”A schema says what a value is; it rarely says what a plausible one looks like. #[Mock] records
that separately, as machine-facing metadata a mock server reads — never as prose in your document.
final readonly class CustomerData{ public function __construct( #[Mock(faker: 'uuid')] public string $id, #[Mock(faker: 'safeEmail', seedGroup: 'customer')] public string $email, #[Mock(faker: 'name', seedGroup: 'customer')] public string $fullName, ) {}}"CustomerData": { "type": "object", "properties": { "id": { "x-docuccino": { "mock": { "faker": "uuid" } }, "type": "string" }, "email": { "x-docuccino": { "mock": { "faker": "safeEmail", "seedGroup": "customer" } }, "type": "string" }, "fullName": { "x-docuccino": { "mock": { "faker": "name", "seedGroup": "customer" } }, "type": "string" } }}"CustomerData": { "type": "object", "properties": { "id": { "type": "string", "x-faker": "uuid" }, "email": { "type": "string", "x-faker": "safeEmail" }, "fullName": { "type": "string", "x-faker": "name" } }}faker is the expression the mock server evaluates. seedGroup names properties whose values should
belong together, so the email and fullName above describe one imaginary customer rather than two.
Either parameter alone is a complete hint.
The full document always carries them, under x-docuccino.mock. The OpenAPI tab above is what
export.mock_faker_key produces when you name a member to
publish under — x-faker is the convention most mock servers read — and leaving the key out keeps the
exported artifacts pure OpenAPI. Only the expression projects: seedGroup is a relationship between
properties, which a single member on one of them can’t carry, so it stays in the extension.
Columns, toArray() keys and validated fields have no PHP property to annotate, so on a class the
attribute names the member instead and repeats as needed:
#[Mock(faker: 'safeEmail', property: 'email')]#[Mock(faker: 'dateTimeThisYear', property: 'created_at')]final class Customer extends Model { /* … */ }
#[Mock(faker: 'safeEmail', property: 'email')]final class StoreCustomerRequest extends FormRequest { /* … */ }Docuccino stores the expression and evaluates nothing, so no generated data ever lands in your
document — and nothing checks that a formatter by that name exists, because the tool reading the hint
is the one that defines the vocabulary. An attribute that would publish nothing is reported instead:
an empty one as attribute.mock-invalid, and one naming a member the schema hasn’t got as
attribute.mock-unknown-property.
When it can’t tell
Section titled “When it can’t tell”If a resource, model, or enum can’t be fully resolved, Docuccino falls back to a permissive schema (for example a bare object) rather than failing, and records lower confidence. Annotations always take precedence, so you can pin anything the inference couldn’t determine.
When one of these models is bound to a path parameter through route-model binding, its route key also types that path parameter — see path parameters.