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.
Nothing to send. When a toArray() can return no key at all — a branch returning [], or every
key conditional — Laravel sends an empty JSON array, [], not {}. The component is documented as
the object or that empty array, so a client doesn’t reject the response.
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.
A resource whose $forceWrapping is on is wrapped even when its toArray() already returns the wrap
key, as Laravel does. That includes a first-party JSON:API resource, whose document then arrives under
a second data.
Laravel’s first-party JSON:API resources are supported too: toAttributes(), toLinks() and
toMeta() build the resource object, each published as the method returns it, and the include and
fields[TYPE] query parameters those routes accept are added automatically. Every document also lists
the included array of related resource objects, and the jsonapi object when you set one at boot:
JsonApiResource::configure(version: '1.1', meta: ['copyright' => 'Acme Inc.']);While it is configured, jsonapi is published as a required member with exactly the keys you passed,
on single resources and collections alike. Changing it rebuilds the cached documentation on the next
run. The first-party resources arrived in Laravel 12.45; on an earlier release, the
timacdonald/json-api integration documents that package’s
resources the same way.
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 an anyOf of the possible models. There is no
discriminator: the morph alias lives in the parent’s *_type column, not in the related model’s own
body, so no field of the payload names the variant for a client to read. A model whose body does fix
such a field is discriminated like any tagged union.
Polymorphic type columns. The *_type column beside a morphTo holds what getMorphClass() writes
for the related model: its alias in your morph map, or its class name when it has none. Where that makes
the column’s values a closed set, it is documented as an enum, so a generated client can name every
related type and tell which one a row points at:
Relation::enforceMorphMap([ 'post' => Post::class, 'video' => Video::class, 'user' => User::class,]);
/** * @property string $commentable_type */class Comment extends Model{ /** @return MorphTo<Post|Video, $this> */ public function commentable(): MorphTo { return $this->morphTo(); }}Here commentable_type is documented as enum: [post, video] — the aliases of the models the
relation’s generic names, not the whole map. The set is closed when:
- the map is enforced, since an unmapped model cannot be written at all. A relation typed
MorphTo<Model, $this>names no models, so every mapped alias is documented; - or the map is not enforced but every model the generic names is
final, so each one writes its alias or, unmapped, its class name, and no subclass can write a third value.
Anywhere else the column keeps its declared type: an unenforced map with a relation that names no models
or names one open to subclasses, a related model that overrides getMorphClass(), an accessor over the
column, or a morphTo() whose relation name is computed at runtime. A nullable type column
(@property string|null) documents null beside the set.
The enum describes what your application writes under its current morph map. Rows stored before you introduced an alias still hold the class name that was written then, so migrate them when you add a map.
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.
Plain PHP objects
Section titled “Plain PHP objects”A class that is none of the above — a value object nested in a resource’s toArray(), or returned in a
response()->json() body — is documented from its public properties and hoisted to a component named
after the class. PHP’s json_encode writes every public property that has a value, null included, so
a property is required whenever it always has one: it’s promoted in the constructor, has a default,
or is untyped. A nullable property like that is required with a null-admitting type, because the key
is always there:
final readonly class InvoiceLine{ public function __construct( public string $sku, public int $quantity, public ?string $note, // required, as `string | null` ) {}}A typed property with no default that isn’t promoted has a value only if something assigns it, and
json_encode leaves out a property that has no value. So Docuccino reads the constructor. A property it
assigns on every path is required, even if the value can be null. One it assigns on only some paths is
optional, and doesn’t become nullable:
final class ProblemDetails{ public string $title; // required public string $detail; // optional: sent only when there is something to say
public function __construct(string $title, ?string $detail = null) { $this->title = $title;
if ($detail !== null) { $this->detail = $detail; } }}A path that throws doesn’t count, because it builds no object, and readonly properties work the same
way. A $this->method() the constructor calls counts as part of it, when the class (or one of its traits)
declares that method and the class being documented doesn’t override it. A subclass whose constructor
calls parent::__construct() as a plain statement — not inside an if, and with no return before it —
gets the parent’s answer for the parent’s properties, through every level of the hierarchy, and a
property it assigns itself after that call is required:
final class ApiProblem extends ProblemDetails{ public string $instance; // required
public function __construct(string $title, ?string $detail, string $instance) { parent::__construct($title, $detail); // `detail` stays optional
$this->instance = $instance; }}Where the constructor can’t answer, a non-nullable property is required and a nullable one is optional.
That happens when there’s no constructor, when a subclass’s constructor may skip parent::__construct(),
and when something else may assign the property: a parent constructor assigning the subclass’s own
properties, a method that assigns $this->{$name}, passes $this on or is overridden, or the named
constructor of a class whose constructor is private. A class that implements JsonSerializable decides its own keys, so its nullable properties
stay optional too.
Named as a request body type — #[BodyParameter(type: InvoiceLine::class)] — the same class is published
as its own InvoiceLineRequest component, because what a client may send differs from what the server
writes: a property with a default can be left out, so it’s optional there, nullable or not.
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 |
A Laravel collection is sent as the array it holds, keys included. Its keys are a list only while they
run 0, 1, 2…, and calls like ->filter() and ->sortBy() keep the keys of the items they drop or move,
which Laravel then sends as an object. String keys prove no object either: a ->keyBy() over no rows is
sent as [], and PHP turns a numeric-string key such as "0" back into an integer. So a collection your
action returns is documented as type: ["array", "object"] of its items, whatever built it:
// [{...}, {...}] or {"1": {...}, "0": {...}} — documented as an array or an object of usersreturn User::all()->sortBy('name');When clients should get a list, return a resource collection,
which Laravel renumbers before sending. A collection class that overrides how it is sent —
jsonSerialize(), toJson() or all() — sends whatever the override returns, so nothing is claimed about
its body; for an Eloquent collection that is the class its model names in $collectionClass or
#[CollectedBy], and a model with its own newCollection() claims nothing either.
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.
Tagged unions
Section titled “Tagged unions”A payload that can be one of several classes usually carries a field saying which — a type, a
kind. When each class fixes that field itself, the response is published as a oneOf with a
discriminator, so a generated client gets a union it can narrow on the field instead of a bag of
optional properties:
enum AttachmentKind: string{ case Image = 'image'; case Link = 'link';}
final readonly class ImageAttachment{ public AttachmentKind $kind;
public function __construct(public string $url, public int $width, public int $height) { $this->kind = AttachmentKind::Image; }}
// LinkAttachment likewise, with AttachmentKind::Link
public function show(Attachment $attachment): ImageAttachment|LinkAttachment"schema": { "oneOf": [ { "$ref": "#/components/schemas/ImageAttachment" }, { "$ref": "#/components/schemas/LinkAttachment" } ], "discriminator": { "propertyName": "kind", "mapping": { "image": "#/components/schemas/ImageAttachment", "link": "#/components/schemas/LinkAttachment" } }}Each class publishes its tag as the one value it can hold — "kind": { "type": "string", "const": "image" }, and required — which is what makes the members mutually exclusive. A property counts as
fixed when it is readonly, the class is final, and the constructor it runs assigns it once, as a
plain statement, from a string or integer literal, a backed enum case, or a class constant holding one.
That constructor may be inherited, or reach the assignment through a parent::__construct() it calls
as a plain statement too. Anything looser — a value passed in, chosen by an if, or on a class a subclass could construct
differently — could hold another member’s value, so the union stays an anyOf with the property typed
as declared. So does a class that can re-tag a copy of itself — a clone($this, ['kind' => …]) in its
own body, a parent class, or a trait either uses, or a public(set) property anyone may re-initialise
that way. Where the
members all publish the field and some of them fix it, an info diagnostic names the member that doesn’t.
A union that may also be null keeps the null outside the tagged members, since it carries no tag to
dispatch on: anyOf: [{ oneOf: [...], discriminator: {...} }, { type: "null" }]. The same rule holds
for any union of components — models, resources, data objects — whichever produced the members, and a
member that refers back to the union (a tree, a thread of replies) is read like any other.
Interfaces and base classes. A value typed by an interface could be any class that implements it, and nothing stops the next class from joining — so an interface on its own is documented as an open object. Seal it with the tag PHPStan and Psalm already enforce, and it’s documented as exactly the classes it lists, under its own name:
/** * @phpstan-sealed ImageAttachment|LinkAttachment */interface Attachment {}
/** @return list<Attachment> */public function index(): arrayAttachment becomes a component holding the oneOf and its discriminator, and the response is an
array of $refs to it. An abstract class sealed the same way — or with @psalm-inheritors — works
alike. When exporting OpenAPI 3.0, which has no const, each tag goes out as a one-value enum and the
discriminator is kept; a nullable union becomes the tagged oneOf with nullable: true beside it,
which is how 3.0 code generators read a nullable tagged union. OpenAPI 3.0.3 lets nullable add null
only beside a type, so a strict 3.0 validator reads that as the oneOf alone — the
downlevel.nullable-composition info says so, and the 3.1 export has no such gap.
A request body gets the same union from its validation rules when one field’s value decides which of the others it carries — see Tagged unions on the requests page.
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.