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. 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.
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. 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, 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.
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';}"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" }}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.
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.