Skip to content

Request & response bodies

The generator resolves every method parameter and return type in the abstract controllers from the spec. An object request body becomes a typed, validated Data param; an object response schema becomes a typed return. Where the spec cannot be mapped to a single Data class, the generator falls back to Illuminate\Http\Request (request side) or Illuminate\Http\JsonResponse (response side) and emits a warning.

The generator resolves the type of each parameter and return value from the spec:

Spec elementGenerated type
Request body referencing a component $ref (non-readOnly schema)Typed Data param, e.g. PetData $pet
Request body referencing a component $ref (schema with writeOnly fields)Write-variant Data param, e.g. PetWritableData $pet
Inline JSON object request body (no $ref)Synthesized per-operation Data param, e.g. CreatePetRequestData $body
multipart/form-data object request body (inline or schema $ref)Synthesized per-operation Data param with UploadedFile file parts, e.g. UploadPetImageRequestData $body
application/x-www-form-urlencoded object request body (inline, schema $ref, or component $ref)Synthesized per-operation Data param, e.g. UpdatePetRequestData $body
Request body referencing a component $ref (#/components/requestBodies/...)Resolved to the component and typed through the same content-type logic
Request body that is not an object shape (array, scalar, union, enum, free-form map) or a whole-body raw binary (octet-stream)Illuminate\Http\Request $request
in: query parameters on a body-less operationPer-operation query Data param, e.g. FindPetsByStatusQueryData $query (see Parameters)
in: query parameters on an operation with a request bodyNo extra param; the docblock points at <Operation>QueryData::fromQuery($request)
Response that is a single object $refData class, e.g. PetData
Inline object response schema (no $ref)Synthesized per-operation Data return, e.g. GetPetResponseData
Response that is an array of objectsSpatie\LaravelData\DataCollection with @return DataCollection<int, PetData> docblock
Selected success response is a 204void; the generated route sets the 204 and guarantees the empty body
Non-JSON success response (binary download, text/html)Symfony\Component\HttpFoundation\Response with a warning (return a BinaryFileResponse / StreamedResponse)
Success response with no declared contentIlluminate\Http\JsonResponse

An operation whose JSON request body is an inline object schema (declared directly under content, not a $ref to a component) gets a synthesized per-operation Data class, named from the operationId (or the method+path fallback) plus a RequestData suffix, mirroring the <Operation>QueryData naming. It lives next to the model Data classes, in the same namespace and output directory, and is drift-checked like every other generated file.

The class goes through the exact pipeline a component schema uses: spec-derived rules() (enums, bounds, formats, array element rules, defaults), nested inline objects spawning their own classes (CreatePetRequestHomeData for a nested home object), additionalProperties: false enforcement, and the readOnly/writeOnly write shape. The controller method receives the typed param, so an inline body validates before your code runs, exactly like a $ref body:

paths:
/pets:
post:
operationId: createPet
requestBody:
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, maxLength: 20 }
app/Http/Controllers/Api/AbstractPetController.php
abstract public function createPet(CreatePetRequestData $body): JsonResponse;

Naming is deterministic and collision-safe: when a component schema already claimed the name (a component literally called CreatePetRequest), the synthesized class takes a numeric suffix (CreatePetRequestData_2) through the same allocator that deduplicates component names.

Only an object schema synthesizes a class. An inline body that is an array, a scalar, a oneOf/anyOf union, an enum, or a free-form map (additionalProperties only) keeps the Illuminate\Http\Request fallback with a generator warning, because no single Data class can type it; see Request bodies that fall back to Request.

An operation whose request body declares multipart/form-data with an object schema gets the same synthesized <Operation>RequestData class as a JSON body, with one multipart-specific mapping: a root property of type: string, format: binary is an uploaded file, typed Illuminate\Http\UploadedFile and validated with Laravel’s file rule. When the part pins its media type via contentMediaType (an OpenAPI 3.1 / JSON Schema keyword), a mimetypes: rule is added; Laravel’s wildcard form (image/*) is supported. An array of binary items becomes a typed array<int, UploadedFile> with the spec’s minItems/maxItems bounds and a per-item field.* => file rule. Every non-binary part is validated exactly like a JSON body field, through the same rules pipeline.

paths:
/pets/upload:
post:
operationId: uploadPetImage
requestBody:
content:
multipart/form-data:
schema:
type: object
required: [image]
properties:
image: { type: string, format: binary, contentMediaType: image/png }
caption: { type: string, maxLength: 80 }
app/Data/UploadPetImageRequestData.php
final class UploadPetImageRequestData extends Data
{
public function __construct(
public readonly UploadedFile $image,
public readonly ?string $caption = null,
) {}
public static function rules(): array
{
return [
'image' => ['required', 'file', 'mimetypes:image/png'],
'caption' => ['sometimes', 'string', 'max:80'],
];
}
}
app/Http/Controllers/Api/AbstractPetController.php
abstract public function uploadPetImage(UploadPetImageRequestData $body): JsonResponse;

spatie/laravel-data hydrates the UploadedFile straight from the multipart request, so the implementation receives a ready $body->image to store.

Four boundaries to know about:

  • JSON wins on a tie. An operation declaring BOTH application/json and multipart/form-data keeps the JSON typing: the scaffold validates one body shape, and JSON is the established, richer mapping. The multipart variant of such an operation is the implementer’s responsibility.
  • Schema $ref bodies are re-emitted, not reused. A multipart body whose schema is a $ref to an object component gets a fresh per-operation class instead of the component’s Data class: the component was emitted with JSON semantics, where a binary string is a plain string whose string rule would false-reject every actual upload.
  • Only root properties are file parts. A format: binary string nested inside an object property sits in a JSON-serialized part and keeps its plain string typing.
  • No file-size rule is derived. OpenAPI has no standard keyword for a file’s byte size (maxLength bounds a string’s character length, and Laravel’s file max: counts kilobytes), so no max: is invented. Add size limits in your concrete controller (or middleware) if you need them.

A multipart body that is not an object shape (a bare binary schema, an array, a union) keeps the Illuminate\Http\Request fallback with a generator warning, exactly like the non-object inline JSON bodies.

An operation whose request body declares application/x-www-form-urlencoded with an object schema gets the same synthesized <Operation>RequestData class as a JSON body, validated by the same spec-derived rules(). Laravel parses urlencoded input into $request->all() exactly like a JSON body, so no special handling is needed and the controller receives the typed, validated param. This works for an inline schema, a schema $ref, and a component $ref (#/components/requestBodies/...).

When an operation declares several media types, precedence is JSON > multipart > form-urlencoded: JSON always wins, so a body declaring both application/json and form-urlencoded is typed against the JSON shape. A form-urlencoded body that is not an object shape keeps the Illuminate\Http\Request fallback with a generator warning.

When an operation’s selected success response declares an inline object schema (directly under content, not a $ref to a component), the generator synthesizes a per-operation <Operation>ResponseData class and types it as the abstract method’s return, symmetric with the inline request body. It is the read variant: readOnly properties stay and writeOnly properties drop, because a response is server output. The class goes through the same model pipeline as any component schema (nested objects, rules, the tag-group placement).

paths:
/pets/{petId}:
get:
operationId: getPet
responses:
'200':
content:
application/json:
schema:
type: object
properties:
id: { type: integer }
name: { type: string }
app/Http/Controllers/Api/AbstractPetController.php
abstract public function getPet(int $petId): GetPetResponseData;

A component $ref response (#/components/responses/...) resolves the same way: a wrapped schema $ref reuses that component’s Data class, and an inline object component response emits one shared <Component>ResponseData class for every referencing operation. The response status codes semantics are unchanged: an inline 204 stays void, and a non-200 inline success keeps its RespondsWithStatus middleware. An inline success response that is not an object shape (an array, a scalar, a oneOf/anyOf union, an enum, a free-form map) keeps the JsonResponse fallback with a warning, exactly like the non-object inline request bodies; see Responses that fall back to JsonResponse.

The success status your spec declares is the status the scaffold produces. For each operation the generator selects the success response that also drives the return type (the numerically smallest 2xx status); when that status is not 200, the generated route attaches the RespondsWithStatus middleware with the declared code as its parameter:

use App\Data\Support\RespondsWithStatus;
Route::post('/pets', [PetController::class, 'createPet'])->name('createPet')->middleware(RespondsWithStatus::class.':201');

Your concrete controller keeps returning the plain Data object; no response() helpers, no status constants. RespondsWithStatus is inlined into your own <output>/Support/ directory alongside the rule classes, so the generated output stays self-contained with no runtime dependency on the generator. The middleware rewrites the framework-default success status (any 2xx) to the declared one. That default is not always 200: spatie/laravel-data serializes a Data object returned from a POST as 201 Created, so a declared 202 (or any other non-201 success) on a mutating Data-returning operation is honored even though the framework produced a 201. An error response (404, 422, 500, …) or a redirect passes through untouched, and a handler that explicitly set its own non-2xx status keeps it.

A POST that declares 200 is the one gap, and it warns

Section titled “A POST that declares 200 is the one gap, and it warns”

Because the framework default is verb-derived, an exactly-200 declaration is the one case the middleware gate does not cover. POST /pets declaring 200 and returning PetData gets no middleware (200 looks like the default), yet spatie answers 201. The generator reports it:

Document: 2 POST operation(s) declare a 200 success response and return a Data object, so
spatie/laravel-data answers 201 Created (it derives the response status from the HTTP verb) while
the contract promises 200, and a declared 200 attaches no status middleware to reconcile them:
POST /pets, POST /store/order. Declare 201 instead of the 200 so the generated route enforces it,
or keep the 200 and enforce it with your own middleware, applied from outside the generated routes
file (editing that file fails the drift gate).

One warning per run, listing every affected operation, rather than one warning per operation: the predicate matches the majority shape of a real-world spec (193 operations in Plaid’s, 288 in Google Compute’s), and repeating the explanation that many times would bury the scarcer diagnostics that share the same channel.

Declaring 201 is the fix, and it makes the contract match what the runtime already sends. If the operation declares both a 200 and a 201, remove the 200: the smallest 2xx is the one selected, so today the method is typed against the 200’s schema while the runtime sends the 201, whose schema may differ.

If you genuinely want 200, the enforcement is yours and it must live outside the generated files. Do not hand-edit routes.php (it is drift-checked, so the next openapi:check reports it), and do not use the routes.middleware config key for this: it wraps the entire route table in one group, so a RespondsWithStatus:200 there would sit outside the per-route middleware and rewrite your real 201 and 204 operations back to 200. Use controller middleware instead, via controllers.base_class and Laravel’s HasMiddleware scoped with only:, or generate with --no-routes and own the route table. Be aware that RespondsWithStatus is inlined only when some operation declares a non-200 status, so in an all-200 spec you write that middleware yourself.

The warning is scoped to exactly that combination: only POST, only an exactly-200 declaration, and only a return type that serializes through spatie (a Data class, a DataCollection, or a union of Data classes). A JsonResponse, base Response, or void return is silent, because you set the status there. Where the return is a Data class, though, you cannot set the status from the handler: that class is the declared return type and generated Data classes are final, so the signature cannot widen to a JsonResponse and calculateResponseStatus() cannot be overridden.

204 No Content gets dedicated treatment. A 204 must not carry a body (RFC 9110), so the abstract method for an operation whose selected success response is a 204 is typed void: the implementation returns nothing, and the route middleware sets the 204 and guarantees the empty body.

/**
* DELETE /pets/{petId}
*
* Responds with HTTP 204: return nothing, the generated route sets the status.
*/
abstract public function destroy(int $petId): void;

Only the selected success response drives this. An operation that declares several 2xx responses (say a 201 and a 202) is typed and status-enforced against the smallest one; producing one of the other declared statuses remains your code’s responsibility (return a response object with the status set explicitly, the middleware never overrides a non-200). See limitations.

Request bodies that fall back to Request, with a warning

Section titled “Request bodies that fall back to Request, with a warning”

Object request bodies no longer fall back, across every form. An inline JSON object body synthesizes a per-operation <Operation>RequestData class with the full rules() pipeline and a typed controller param (issue #76, see inline request bodies). A multipart/form-data object body synthesizes the same class with UploadedFile typing and file / mimetypes: rules for its binary parts (issue #75, see multipart bodies). An application/x-www-form-urlencoded object body routes through the same synthesizer (issue #130: Laravel parses urlencoded input into $request->all() exactly like JSON, so the same spec-derived rules validate it). A body that is a $ref into components.requestBodies resolves to the referenced component and routes through the same content-type logic (issue #110). When several media types are declared, precedence is JSON > multipart > form-urlencoded. The remaining fallbacks:

  • A body that is not an object shape: an array, a scalar, a oneOf/anyOf union, an enum, or a free-form map (additionalProperties only), in any media type. No single Data class can type these; for multipart this includes the whole-body binary form (schema: {type: string, format: binary}).
  • A whole-body raw binary body (application/octet-stream): there is no object schema to map to a Data class, so read and validate it from the Request yourself (issue #119).
  • A body schema $ref that is external or does not resolve to a generated Data class.

Multipart residuals on the bodies that ARE generated (issue #75): no file-size rule is derived (OpenAPI has no standard keyword for a file’s byte size; maxLength bounds a string’s character length and Laravel’s file max: counts kilobytes, so no clean mapping exists), the encoding.contentType map of a multipart media type is not read (only the schema-level contentMediaType keyword feeds mimetypes:), a format: binary string nested below the body root stays a plain string (it sits inside a JSON-serialized part), and an operation declaring BOTH application/json and multipart/form-data keeps the JSON typing (documented precedence).

A response that is a $ref into components.responses resolves to the referenced component and types the return (issue #116), and an inline object response schema synthesizes a per-operation <Operation>ResponseData return (issue #129). The remaining fallbacks:

  • A response schema that names no typable shape: a scalar, an enum, a free-form map, or an array of scalars. An array whose items is a $ref to a generated Data class types as DataCollection<int, XData>, and a oneOf/anyOf whose members are all Data classes types as a Data-class union.
  • An unresolvable response $ref (external, #/paths/..., missing, or ref-to-ref), or a success response that does not resolve at all.
  • A response with no JSON content: a non-JSON-only success (a binary download, text/html) is typed as the base Symfony\Component\HttpFoundation\Response with a warning (issue #118), so your method can return a BinaryFileResponse or StreamedResponse with no type error; a response with no declared content at all keeps the JsonResponse default.

Since issue #171 it makes no difference whether the schema is written inline or named as a component. A component that is a bare type: array (or a scalar, or a union) is a type ALIAS rather than a Data class, so it never enters the registry; the response path now resolves such a $ref to the schema at the end of its alias chain (following an allOf: [{$ref}] wrapper) and applies the same shape checks. A response referencing PetList: {type: array, items: {$ref: Pet}} therefore returns DataCollection<int, PetData>, where it previously degraded to JsonResponse.

Every fallback above warns, except a $ref selected for a 204 (the method is void either way, so nothing degrades) and a response that declares no schema at all (the spec promised no shape, so nothing is lost and there is nothing to enforce). Issue #171 closed the last silent cases: a JsonResponse return enforces nothing about the body, so a degradation that went unreported let a non-conforming implementation look conformant to the generator.

An unresolvable parameter $ref (external or dangling) is dropped from its operation, also with a warning naming the pointer.