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

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

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.

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 property

The 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.

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].

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,
) {}
}

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.

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.