Skip to content

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.

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():

app/Data/CreateInvoiceData.php
class CreateInvoiceData extends Data
{
public function __construct(
public int $customerId,
public string $currency,
public ?string $dueAt,
#[HiddenFromRequest]
public ?string $internalNote,
) {}
}
// app/Http/Controllers/InvoiceController.php
public function store(CreateInvoiceData $data): InvoiceResource { /* … */ }

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 becomes multipart/form-data with a format: binary schema — see File uploads.

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.

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.

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.

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

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

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.

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

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.

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.

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.

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.

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

app/Exceptions/ProblemRenderer.php
public function __invoke(Throwable $e, Request $request): ?JsonResponse
{
return match (true) {
$e instanceof ModelNotFoundException => ProblemDocumentData::notFound($e)->toProblemResponse($request),
default => null,
};
}
// app/Data/ProblemDocumentData.php
public function toProblemResponse(Request $request): JsonResponse
{
$response = $this->withoutWrapping()->toResponse($request);
$response->headers->set('Content-Type', 'application/problem+json');
return $response;
}

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.

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-data
serialises as {"data": [ … ]} because `data.wrap` is set — the schema recovered for the property is a
bare 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 no meta or links beside 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.

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.

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.

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