Skip to content

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.

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.

app/Http/Resources/InvoiceResource.php
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
];
}

The conditional customer isn’t in requiredwhenLoaded 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. withoutWrapping() is a runtime call Docuccino can’t see, so 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.

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.

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. 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
datetime:Y-m-d string, format: date (a date-only pattern)
datetime:<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. 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.

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"
}
}
}

Custom date serialization. A model that overrides serializeDate() chooses its own wire format for every date attribute, which can’t be known statically — so its date and datetime columns are documented as a plain string (no format), with an info diagnostic noting the format is unknowable. Pin an exact format with an annotation if your clients need one.

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';
}

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.

Descriptions are emitted as x-enumDescriptions. 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. A case with neither is simply absent from the map. To also emit codegen name hints alongside (never instead of) the values, set representation.enums.naming to x-enumNames or x-enum-varnames.

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.

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.