Spatie Data
Activates automatically when spatie/laravel-data is installed.
A Data class is documented from its properties and validation attributes — statically, without
constructing it — and works in both directions: as a request body when it’s an action parameter, and as
a reusable schema when it’s returned.
All three of Spatie’s base classes are recognized: Data, the output-only Resource, and the
input-only Dto.
Request bodies from Data classes
Section titled “Request bodies from Data classes”Type-hint a Data class as your action’s parameter and Docuccino documents the request body from it,
the same way it would from a form request’s rules():
class CreateInvoiceData extends Data{ public function __construct( public int $customerId, public string $currency, public ?string $dueAt, #[HiddenFromRequest] public ?string $internalNote, ) {}}
// app/Http/Controllers/InvoiceController.phppublic function store(CreateInvoiceData $data): InvoiceResource { /* … */ }// A Data class is a single named source class, so the body hoists to a component the operation $refs."requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateInvoiceDataRequest" } } }},// components.schemas"CreateInvoiceDataRequest": { "type": "object", "properties": { "customerId": { "type": "integer" }, "currency": { "type": "string" }, "dueAt": { "type": ["string", "null"] } }, "required": ["customerId", "currency"]}The body carries customerId, currency, and dueAt — internalNote is dropped from the request by
#[HiddenFromRequest], and the nullable dueAt isn’t required (its type admits null). A property
with a constructor default isn’t required either, and a scalar default is documented as the schema
default.
An array property keeps the shape its docblock tag states, even
though Laravel’s rule vocabulary has one word — array — for all of them:
| Property type | Documented as |
|---|---|
list<string> |
{ "type": "array", "items": { "type": "string" } } |
array<string, mixed> |
{ "type": "object", "additionalProperties": {} } — a keyed array is a JSON object, which would fail type: array outright |
array<string, array<string, string|null>> |
Nested additionalProperties, matching the response side exactly |
array{width: int, label?: string} |
An object with properties, the optional key left out of required |
A static rules() still wins per field, so a dotted key inside one of these (metadata.retention.mode)
documents that key and settles the container as an object. An override that only restates array
doesn’t undo the table above — array is the vaguer way of saying what the docblock already said, not a
contradiction of it — and neither does a field.* rule, which Laravel applies to every value whatever
the keys are. So ['nullable', 'array', 'max:100'] over an array<string, …> property still publishes
an object, bounds maxProperties, and lets the field.* rules constrain the values.
Because a Data class is a single named source class, the body is hoisted to a #/components/schemas
component and the operation $refs it — so the same Data class accepted by several endpoints becomes
one shared, deduped component. The hoisting rules are the same as for a form request, including the
#[BodyParameter] deviation that keeps one operation’s body inline; see
Request bodies for the full detail.
The component is named CreateInvoiceDataRequest, not CreateInvoiceData: a Data class returned as a
response is a different shape from the same class accepted as a body — different names, different
fields, different optionality — so each gets a name of its own and the plain name belongs to the
class’s own shape. See #[SchemaName] for the naming
rules. A name that already ends in Request is left alone.
Two shapes follow from the HTTP verb, exactly as they do for a form request:
- On a read verb (
GET/HEAD) there is no body, so the recovered fields become query parameters instead. - A property typed
Illuminate\Http\UploadedFile(or a list of them) is a file upload, so the body becomesmultipart/form-datawith aformat: binaryschema — see File uploads.
Nested Data and collections
Section titled “Nested Data and collections”A Data property that is itself a Data class recurses into dotted rules, so nested objects validate and
document all the way down. A collection property does the same through .*:
class CreateInvoiceData extends Data{ public function __construct( public CustomerData $customer, // → customer.name, customer.email … #[DataCollectionOf(LineItemData::class)] public DataCollection $lines, // → lines is an array; lines.*.sku … ) {}}#[DataCollectionOf] is how you name the item class when the collection carries no generic. A cycle
(A holding a B holding an A) stops at the repeat rather than recursing forever.
Overriding the rules with rules()
Section titled “Overriding the rules with rules()”Define a static rules() on the Data class and Docuccino reads it the same way it reads a Form
Request’s — literal string rules and Rule::* factory descriptors alike. A field you override there
wins over what its property type would infer, matching Spatie’s own resolver:
class CreateInvoiceData extends Data{ public function __construct( public int $customerId, public string $currency, ) {}
public static function rules(ValidationContext $context): array { return [ 'currency' => ['required', Rule::enum(Currency::class)], ]; }}currency is now documented with the enum’s backing values instead of a bare string. Fields you
don’t mention keep their inferred rules, and a field you declare that no property matches is added.
Validation attributes
Section titled “Validation attributes”Spatie Data’s validation attributes are read too — statically, never executed — and map to exactly
the same schema constraints as their string-rule equivalents. A #[Max(100)] documents identically to
'max:100' on a form request, so you don’t annotate anything twice:
class CreateInvoiceData extends Data{ public function __construct( #[Required, Max(120)] public string $reference, #[Email] public string $billingEmail, #[In(['GBP', 'USD', 'EUR'])] public string $currency, #[Min(0)] public int $amount, ) {}}#[Required] marks the property required, #[Max] / #[Min] become length or range keywords (by the
property’s type), #[Email] a format: email, and #[In] / #[Enum] an enum — #[Enum(Currency::class)]
expands to the enum’s backing values, never the class name. Presence, type, size, format, and pattern
attributes are all covered, and a property’s own type contributes the base type where no attribute
states one.
The escape hatches work as you’d hope:
#[Rule('max:10|min:1')]passes its strings straight through as Laravel rules.#[Rule(new SortCode)]documents from the rule class’s own#[RuleSchema]— describe a custom rule once at the class and every property using it picks it up.- An attribute Docuccino doesn’t recognize degrades like an unknown string rule — permissive, with an info diagnostic — rather than inventing a constraint.
Response schemas from Data returns
Section titled “Response schemas from Data returns”Return a Data class (or a DataCollection) and it’s documented as a reusable component schema,
referenced everywhere it appears — so the same object is defined once and shared:
public function show(Invoice $invoice): InvoiceData{ return InvoiceData::from($invoice); // → a reusable InvoiceData schema}The component is named after the class, or after
#[SchemaName] when you’d rather choose; pin its diff
identity across a rename with #[SchemaId].
Element types from the docblock
Section titled “Element types from the docblock”PHP can’t type an array’s elements, so Docuccino reads the tag that can. A plain property is typed by
its own @var; a promoted constructor property by either the constructor’s @param or its own @var,
whichever you write — the @param wins where both are present.
class InvoiceData extends Data{ /** @var list<string> */ public array $tags = [];
/** * @param list<LineItemData>|Optional $lines */ public function __construct( public int $id, public array|Optional $lines = new Optional, ) {}}"tags": { "type": "array", "items": { "type": "string" } },"lines": { "type": "array", "items": { "$ref": "#/components/schemas/LineItemData" } }The tag replaces the declaration only where that declaration is vague — a bare array, a mixed,
nothing at all — and only if the tag itself is precise, so a native string is never second-guessed and
a @var array never displaces a real declaration. Where the declaration is specific but generic-less,
the tag may add its type arguments and nothing else: DataCollection $lines with
@param DataCollection<int, LineItemData> $lines recovers the element type, while the class itself and
its nullability stay exactly as declared. Short class names resolve through that file’s use
statements, and the @phpstan-/@psalm- prefixed forms of each tag are read too, winning over the
plain one where both are present. The
same types feed the request rules, so lines.* validates as
LineItemData too.
Success statuses
Section titled “Success statuses”| The class | Documented status |
|---|---|
Takes Spatie’s concern, POST route |
201 |
| Takes Spatie’s concern, any other verb | 200 |
Overrides calculateResponseStatus() |
Whatever that returns — an override answering 200 on a POST documents 200 |
| Overrides it with a decision on the route name | The one status that route takes — 201 on invoices.store, 200 on the endpoints the pattern doesn’t name |
| Satisfies the response contract by hand | 200; there’s no vendor default to inherit, and assuming one would invent a status |
An override folds a single constant (return 201;, Response::HTTP_CREATED, an enum constant) and a
conditional whose arms are all constants — $recent ? 201 : 200 documents both statuses, each carrying
the response body.
When the conditional turns on the route name, only the status that route takes is documented:
protected function calculateResponseStatus(Request $request): int{ return $request->routeIs('*invoices.store') ? Response::HTTP_CREATED : Response::HTTP_OK;}"/invoices": { "post": { "responses": { "201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvoiceData" } } } } } }},"/invoices/{invoice}": { "get": { "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvoiceData" } } } } } }}The create route documents 201; the read route returning the same class documents 200 alone, rather
than a 201 it can never send. Patterns match as they do at runtime — wildcards included, and an unnamed
route matches nothing — and $request->route()->named(...) reads identically.
Only a single return of that ternary narrows. Write the same decision as a guard clause, or leave a
second return anywhere in the method, and every folded status stays documented — as it does for a
condition on anything else, the HTTP method and the URI included.
A union of Data classes (AuthSuccessData|MfaChallengeData) resolves each member’s
status separately. A computed status falls back to 200 with a
spatie-data.response-status-unresolved info diagnostic — see
Responses.
Dates and timestamps
Section titled “Dates and timestamps”A DateTimeInterface property is documented as a string, with the format following your app’s
data.date_format: an ISO format gives date-time or date, and anything else — d/m/Y H:i — gives a
plain string naming the format, because date-time means RFC 3339 and a claim your values fail is worse
than none. The one property that isn’t a string is a Unix timestamp:
#[WithCast(DateTimeInterfaceCast::class, format: 'U')]public CarbonImmutable $issuedAt; // → { "type": "integer", "description": "Unix timestamp (seconds)." }That holds in both directions. A date property in a request body is documented from the most specific source the class gives, not from a validation rule that says less:
| On the property | The request documents |
|---|---|
#[DateFormat('d/m/Y')] |
“Expected format: d/m/Y” and example: 01/01/2024 — you stated the wire format outright, and no format word describes it. |
#[WithCast(DateTimeInterfaceCast::class, format: 'U')] |
type: integer — the cast parses a timestamp, so that is what the endpoint takes. |
#[WithCast(DateTimeInterfaceCast::class, format: 'Y-m-d')] |
format: date — the cast’s format is the one it accepts. |
A DateTimeInterface type, nothing else |
The format your data.date_format gives, exactly as the response side reads it. |
The example is always rendered with the format itself, so it is a value your endpoint accepts rather than an ISO one it would reject.
So a #[Date] (or a 'date' in a rules() override) beside a CarbonImmutable property does not narrow
the field to format: date. Laravel’s date rule accepts anything non-relative strtotime parses —
2024-01-01, 2024-01-01T10:00:00Z, January 1 2024 — so date would mark a working request invalid,
and a client generated from it would truncate the time on the way back:
#[Nullable, Date]public ?CarbonImmutable $expectedUpdatedAt;// request → { "type": ["string", "null"], "format": "date-time" }// response → { "type": ["string", "null"], "format": "date-time" }Where your app really is asymmetric the two sides say so: a d/m/Y cast with an ISO data.date_format
documents a d/m/Y string in and date-time out, because that is what the endpoint does.
Wrapping
Section titled “Wrapping”If your app wraps responses — a class-level defaultWrap() or the global data.wrap config —
Docuccino nests the response schema under that key at the top level, exactly as the JSON your app
returns:
{ "data": { "$ref": "#/components/schemas/InvoiceData" } }defaultWrap() on the class takes precedence over the global key, and it’s read as a literal string
return — a computed wrap key falls back to the global one and is reported, since Spatie uses whatever
the override returns and never consults the global. Only the response root is wrapped: a nested
Data property stays an unwrapped, shared $ref.
Classes that unwrap themselves
Section titled “Classes that unwrap themselves”A class that strips the envelope on its own way out is documented unwrapped, whatever
config('data.wrap') says — the RFC 9457 case, since a problem document has to sit at the root. Both of
Spatie’s spellings are read, statically:
$this->withoutWrapping()->toResponse($request);
$this->transform( TransformationContextFactory::create()->withWrapExecutionType(WrapExecutionType::Disabled));The receiver decides it, in both spellings: $this->authors->withoutWrapping() unwraps that nested
collection, and a WrapExecutionType handed to $this->authors->transform(…) travels with that
transformation — neither says anything about this class’s own root. The read follows the context into
a local it’s built in, and reads only the class’s own declarations, so a neighbour sharing the file
answers for itself. Precedence is an explicit unwrap, then defaultWrap(), then data.wrap, then
unwrapped.
Where the vocabulary is there and no receiver can be named for it — a hop through a helper, say — the
envelope stays as data.wrap resolves it, because that’s what the framework does to every root Data
response and unwrapping is the override. The half that couldn’t be read reaches you as
spatie-data.root-wrap-unsettled rather than
as a silent guess.
Collections and paginators
Section titled “Collections and paginators”| Return type | Documented as |
|---|---|
DataCollection<InvoiceData> |
An array of the item schema — wrapped under the global key at the response root only |
PaginatedDataCollection<int, InvoiceData> |
Spatie’s length-aware envelope |
CursorPaginatedDataCollection<int, InvoiceData> |
Spatie’s cursor envelope |
Spatie’s paginator envelope is not Laravel’s resource envelope, and Docuccino documents Spatie’s:
links is an array of { url, label, active } objects, and meta carries the *_page_url members
alongside the counters (or the cursor tokens, with no total). All three members are always
serialized, so all three are required:
"InvoiceDataPage": { "description": "One page of results, with links to the pages around it and totals for the whole result set.", "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceData" } }, "links": { "type": "array", "items": { "$ref": "#/components/schemas/PaginationLink" } }, "meta": { "$ref": "#/components/schemas/DataPaginationMeta" } }, "required": ["data", "links", "meta"]}data is the only member restated per Data class — OpenAPI has no generics, so a page of invoices and
a page of contacts are two components. The members that depend on the paginator and not on what you
paginated are shared, named for the shapes they are:
"PaginationLink": { "description": "One entry in a page link list: the URL of that page, the label to show for it, and whether it is the page you are on.", "type": "object", "properties": { "url": { "type": ["string", "null"] }, "label": { "type": "string" }, "active": { "type": "boolean" } }},"DataPaginationMeta": { "description": "Where this page sits in the result set: the page number and size, the number of the last page, the record total, the index of the first and last record on this page, the base URL its page links are built from, and a URL for the first, last, previous and next pages.", "type": "object", "properties": { "current_page": { "type": "integer" }, "first_page_url": { "type": ["string", "null"] }, "from": { "type": ["integer", "null"] }, "last_page": { "type": "integer" }, "last_page_url": { "type": ["string", "null"] }, "next_page_url": { "type": ["string", "null"] }, "path": { "type": ["string", "null"] }, "per_page": { "type": "integer" }, "prev_page_url": { "type": ["string", "null"] }, "to": { "type": ["integer", "null"] }, "total": { "type": "integer" } }}The cursor envelope carries the same PaginationLink list and its own DataCursorPaginationMeta —
cursor tokens and neighbouring page URLs, with no total. Both are separate from the components a
Laravel resource page uses, because the shapes are genuinely different; see
pagination. None of them has a class of yours behind it,
so Docuccino describes each one for you.
A paginated collection is always wrapped by Spatie, so its wrap key simply names the envelope’s items key — never a second layer around it.
include / exclude / only / except query parameters
Section titled “include / exclude / only / except query parameters”When a Data class opts into Spatie’s request partials — by overriding allowedRequestIncludes(),
allowedRequestExcludes(), allowedRequestOnly(), or allowedRequestExcept() — the matching query
parameter is documented on every operation that returns that class. Only the methods you override are
surfaced, because Spatie’s own base implementations allow nothing:
| Parameter | Description |
|---|---|
include |
Comma-separated list of lazy/optional properties to include in the response. |
exclude |
Comma-separated list of properties to exclude from the response. |
only |
Comma-separated allow-list of the only properties to return. |
except |
Comma-separated deny-list of properties to omit from the response. |
The allow-list itself isn’t enumerated — reading it would mean running your method — so each parameter is documented as a free comma-separated string.
Data classes as error bodies
Section titled “Data classes as error bodies”Data::toResponse() declares a bare JsonResponse and transform() a bare array, so a renderer that
hands its error body to Spatie would lose the whole schema on the way out. Both are modeled, so it
doesn’t:
public function __invoke(Throwable $e, Request $request): ?JsonResponse{ return match (true) { $e instanceof ModelNotFoundException => ProblemDocumentData::notFound($e)->toProblemResponse($request), default => null, };}
// app/Data/ProblemDocumentData.phppublic function toProblemResponse(Request $request): JsonResponse{ $response = $this->withoutWrapping()->toResponse($request);
$response->headers->set('Content-Type', 'application/problem+json');
return $response;}"404": { "description": "Not Found", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/ProblemDocumentData" } } }}The withoutWrapping() keeps the body at the root, and the media type comes from the header write —
a mutation, not a constructor argument.
Where the class overrides toResponse() or transform(), Docuccino reads your own
new JsonResponse($payload, $status, $headers) instead: your status, headers and Content-Type win. The
payload is still the Data class — a transformed array and the documented schema are the same body.
Constructor arguments decide the example:
a member passed at that call site is in that body even where the schema marks it Optional, and an
Optional one nobody passed is left out. A member the schema requires is illustrated either way — an
example missing one would fail validation against the very schema printed beside it.
Nested collections
Section titled “Nested collections”A data.wrap key wraps the response root, and Docuccino documents that envelope. It does not document
one around a nested collection, and that is a decision rather than an oversight — but the runtime does
put one there, so the two can disagree.
Laravel Data unwraps a nested single Data object and re-wraps a nested collection. With
'wrap' => 'data' set, a property declared as a list of Data objects goes out like this:
{ "data": { "label": "Tags", "tags": { "data": [ { "label": "urgent" } ] } } }The schema keeps tags as a bare array. Two things make the wrapper impossible to publish honestly: a
#[WithTransformer] replaces serialization outright and its output cannot be read from the source, and
the property publishes a shared $ref that must not carry one caller’s envelope to every other use of
that class.
So Docuccino tells you instead. Where a global wrap is set and a nested data collection carries no
transformer, the build raises
spatie-data.nested-collection-wrap:
App\Data\FieldData::$tags is a nested collection of App\Data\FieldTagData, which laravel-dataserialises as {"data": [ … ]} because `data.wrap` is set — the schema recovered for the property is abare array, with no envelope.Resolve it whichever way your API actually behaves:
- Send the bare array — give the property a
#[WithTransformer]that returns the plain list. This is the common choice: a nested envelope has nometaorlinksbeside it to justify it, and it makes the same field asymmetric with the one your request side accepts. - Keep the envelope — state the wrapped shape in an Overlay, so the document says what you send.
The diagnostic stays quiet where nothing will be wrapped: no global data.wrap, a property carrying any
transformer, a paginated collection (whose data/links/meta envelope the schema already publishes),
or a class that turns wrapping off for its whole transformation with
WrapExecutionType::Disabled — the shape a problem-details carrier usually takes, where Disabled
propagates into the nested values too.
It also stays quiet where the disabling is there and no receiver can be named for it. Which of the two switches was thrown decides whether anything nested goes bare, and an unattributed one could be either — so rather than name a divergence that may not be on the wire, the build reports the root envelope as unsettled and leaves it at that.
withoutWrapping() is not one of those. Spatie’s two switches are different axes: it writes the
object’s own wrap, which decides the response root and nothing under it, so a class that strips its own
envelope still sends {"data": [ … ]} for a nested collection — and still gets the report. Nor is a
WrapExecutionType::Disabled handed to a value the class holds: it rides that one transformation and
never reaches the ordinary serialization of the collection.
The wrap is read from the class that declares the property, because that class publishes one shared
schema wherever it is used. A class returned directly would be wrapped, so the diagnostic reports it
even where some other root nests it under a Disabled transformation.
Conventions Docuccino honors
Section titled “Conventions Docuccino honors”Docuccino respects Spatie Data’s own attributes and conventions, so the documented schema matches the JSON your app actually produces and accepts:
| Convention | Effect on the schema |
|---|---|
#[Hidden] (Spatie) |
Drops the property from the output schema only; it stays in the request body. |
#[HiddenFromRequest] (Docuccino) |
Drops the property from the request body only; the response schema is untouched. |
#[Computed] / #[WithoutValidation] |
Excluded from the request body — a computed/server-derived value is never a sendable field. |
#[FromRouteParameter] |
Excluded from the request body — populated from the route binding, not the payload. |
#[Prohibited] (Spatie validation) |
Excluded from the request body — documenting a field the API refuses would invite exactly that. |
Optional / Lazy |
Makes the property non-required. |
| A constructor default | Makes the property non-required, and documents the value as default. |
#[MapName] / #[MapInputName] / #[MapOutputName] |
Renames the property key to the mapped name, per direction. |
A mapper class (SnakeCaseMapper, CamelCaseMapper, StudlyCaseMapper, KebabCaseMapper, LowerCaseMapper, UpperCaseMapper) |
Applies that transform to the key. |
| Global name-mapping strategy | data.name_mapping_strategy renames every un-mapped key, independently for input and output. |
Nested Data |
Recurses into a nested (referenced) schema, and into dotted request rules. |
DataCollection / #[DataCollectionOf] |
Becomes an array of the item schema. |
| Paginated Data collections | Produce Spatie’s own paginator envelope (above), not Laravel’s. |
defaultWrap() / data.wrap |
Nests the top-level response under the wrap key ({ "data": … }). |
$this->withoutWrapping() / WrapExecutionType::Disabled |
Documents this class’s response root unwrapped, whatever data.wrap says — when the receiver is the class itself. Aimed at a value it holds, it says nothing about the root. |
A constructor @param / a property @var |
Supplies the element type PHP can’t declare (list<T>), wherever the native type is vague. |
calculateResponseStatus(), or Spatie’s inherited default |
Documents the success status: 201 on POST, 200 otherwise, unless the class overrides it — and an override deciding on the route name documents the one status each route takes. |
An overridden toResponse() / transform() |
Docuccino reads your own construction instead — its status, headers and media type win. |
#[WithCast(DateTimeInterfaceCast::class, format: 'U')] |
Documents the property as an integer Unix timestamp. |
rules() |
Overrides the inferred request rules per field. |
allowedRequest*() |
Surfaces the include/exclude/only/except query parameters. |
#[SchemaName] / #[SchemaId] |
Names the component / pins its diff identity. |
Name mapping resolves in Spatie’s own order: a property-level attribute beats a class-level one, a
directional #[MapInputName]/#[MapOutputName] beats a symmetric #[MapName], and the global
strategy applies only where no map attribute governs the property.
Whatever a property declares travels with it to the mapped key, per direction. A class mapped
snake_case in and camelCase out has two different maps, so a #[Description], an #[Example] or a
#[Mock] on $blueprintId lands on blueprint_id in the request body and on blueprintId in the
response — the property is the same one, and each side publishes it under the name that side uses.
Add examples with #[Example] on a property, and pin
anything inference can’t determine with the usual attributes — they
always take precedence.
Configuration
Section titled “Configuration”None required. Like every integration, the Data support accepts an enabled opt-out
(integrations.spatie_data.enabled => false) if you ever want to turn it off for a document — see the
configuration reference. It’s a no-op when the package
isn’t installed.
Three of your app’s own config/data.php settings do reshape the output, and Docuccino reads all
three: wrap, name_mapping_strategy, and date_format. Change one and every documented Data class
is rebuilt.
When it can’t tell
Section titled “When it can’t tell”If a property’s type can’t be resolved statically, Docuccino contributes what it can and records lower
confidence rather than failing — then your annotations fill the gap. A Data class it can’t expand at
all is documented as a bare object.
One case is worth naming: a custom name mapper Docuccino doesn’t recognize can’t be applied
without running it, so the names recovered for those keys are the properties’ own and the build emits a
spatie-data.unknown-mapper info diagnostic naming the class. Rename the keys with an explicit
#[MapName('…')] if it matters.
The other is the request side of pagination. A PaginatedDataCollection or
CursorPaginatedDataCollection return documents the whole envelope from the type alone — you never
annotate a paginated response. It says nothing about the request, though: the type tells Docuccino
what comes back, not which query keys the code that built it read. When the paginating happens inside
a query builder, Spatie Query Builder recovers the page key from
the trace; when it happens anywhere else — a helper paginating an in-memory
collection, say — declare the parameters with
#[QueryParameter].