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 dueAtinternalNote 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 its value is documented as the schema default.

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.

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

Success statuses from calculateResponseStatus()

Section titled “Success statuses from calculateResponseStatus()”

A Data response class that overrides calculateResponseStatus() has its status documented in place of the default 200. A single constant folds — a plain return 201;, a class constant like Response::HTTP_CREATED, or an enum constant. A conditional whose arms are all constants folds too and documents every status (a $recent ? 201 : 200 yields both a 201 and a 200, each carrying the response body) — matching what the endpoint actually returns.

When an action returns a union of Data classesAuthSuccessData|MfaChallengeData from one method — each member is documented under its own calculateResponseStatus() (the challenge member at 422, the success member at 200), so a single action correctly documents several statuses. A genuinely computed status can’t be read statically, so that member stays 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: a time-bearing format gives date-time, a date-only one gives date. 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)." }

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. Only the response root is wrapped: a nested Data property stays an unwrapped, shared $ref.

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:

{
"type": "object",
"properties": {
"data": { "type": "array", "items": { "$ref": "#/components/schemas/InvoiceData" } },
"links": { "type": "array", "items": { "type": "object", "properties": {
"url": { "type": ["string", "null"] }, "label": { "type": "string" }, "active": { "type": "boolean" }
} } },
"meta": { "type": "object", "properties": {
"current_page": { "type": "integer" }, "last_page": { "type": "integer" },
"per_page": { "type": "integer" }, "total": { "type": "integer" },
"first_page_url": { "type": ["string", "null"] }, "last_page_url": { "type": ["string", "null"] },
"next_page_url": { "type": ["string", "null"] }, "prev_page_url": { "type": ["string", "null"] }
} }
},
"required": ["data", "links", "meta"]
}

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.

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) Documented as never sendable.
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, 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": … }).
#[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.

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 those keys are documented unmapped and the build emits a spatie-data.unknown-mapper info diagnostic naming the class. Rename the keys with an explicit #[MapName('…')] if it matters.