michaelalexeevweb / openapi-php-dto-generator
Generate PHP DTOs from OpenAPI and validate incoming HTTP requests against OpenAPI schema.
Package info
github.com/michaelalexeevweb/openapi-php-dto-generator
pkg:composer/michaelalexeevweb/openapi-php-dto-generator
Fund package maintenance!
Requires
- php: ^8.3
- symfony/console: ^7.4
- symfony/http-foundation: ^7.4
- symfony/mime: ^7.4
- symfony/yaml: ^7.4
- twig/twig: ^3.0
Requires (Dev)
- ergebnis/phpstan-rules: ^2.13
- friendsofphp/php-cs-fixer: ^3.95
- illuminate/http: ^11 || ^12
- illuminate/routing: ^11 || ^12
- illuminate/translation: ^11 || ^12
- illuminate/validation: ^11 || ^12
- kubawerlos/php-cs-fixer-custom-fixers: ^3.37
- nyholm/psr7: ^1.8
- phpdocumentor/reflection-docblock: ^5.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^10.5
- slevomat/coding-standard: ^8.29
- squizlabs/php_codesniffer: ^4.0
- symfony/property-access: ^7.4
- symfony/property-info: ^7.4
- symfony/psr-http-message-bridge: ^7.4
- symfony/serializer: ^7.4
- symfony/validator: ^7.4
Suggests
- symfony/psr-http-message-bridge: To deserialize PSR-7 ServerRequest objects via DtoDeserializerPsr7 (non-Symfony stacks: Slim, Mezzio, Laminas, Yii3, โฆ).
- symfony/serializer: To (de)serialize Symfony attribute-mode DTOs via the Symfony serializer.
- symfony/validator: To use the generated DTOs in Symfony attribute mode (--attributes=symfony).
This package is auto-updated.
Last update: 2026-08-11 08:42:34 UTC
README
Generate PHP DTOs from OpenAPI and validate incoming HTTP requests against OpenAPI schema.
Stop writing boilerplate PHP data transfer objects by hand. This library reads your OpenAPI 3.x YAML specification and automatically generates strictly-typed, immutable PHP 8.3 DTO classes. On top of that, it provides runtime services to deserialize Symfony Request objects into those DTOs, validate HTTP requests against the original OpenAPI schema rules (OpenAPI request validation), and normalize them back to arrays or JSON โ all in one package.
Features
- ๐ Code generation โ generate immutable PHP DTO classes directly from OpenAPI 3.0 / 3.1 YAML specs
- ๐ฏ Three generation modes โ runtime (DTOs backed by this library's validator/normalizer/deserializer), symfony (plain DTOs decorated with Symfony
#[Assert\*]/#[SerializedName]/#[Groups]attributes) or laravel (a plain DTO plus aFormRequestcarryingrules()โ nothing to install beyond the framework) - โ OpenAPI request validation โ validate HTTP requests against OpenAPI constraints (required fields, types, enums, formats, etc.)
- ๐ Normalization โ convert DTOs to plain arrays or JSON, with or without validation
- ๐ฆ Symfony Request support โ deserialize Symfony
Requestobjects directly into typed PHP DTOs - ๐ Framework-agnostic (PSR-7) โ deserialize any PSR-7
ServerRequestInterfaceviaDtoDeserializerPsr7(Slim, Mezzio, Laminas, Yii3, โฆ); SymfonyRequestcovers Symfony + Laravel - ๐ Immutable by design โ runtime-mode DTOs are read-only value objects; in Symfony mode the required half is
readonlyand the optional half has setters, which is what powersisXxxProvided() - โก Supports OpenAPI 3.0.x and 3.1.x
Table of Contents
- Installation
- Requirements
- Quick Start
- Generate DTOs
- Generation Modes
- Runtime mode guide โ request binding, presence tracking, PSR-7
- Symfony mode guide โ attribute mapping, serialization groups, error codes
- Laravel mode guide โ FormRequest, rules(), what the interpreter adds
- Support matrix โ every keyword per mode, the six divergences, what is not generated at all
- Performance โ bind / validate / normalize per mode, measured, with the benchmark to re-run it
- Validation Notes
- Upgrading
Installation
composer require michaelalexeevweb/openapi-php-dto-generator:^2.10.0
Requirements
- PHP 8.3+
- Symfony 7.4 components (
console,http-foundation,mime,yaml)
Quick Start
- Generate DTOs from your OpenAPI YAML spec
- Deserialize and validate an incoming HTTP request into a generated DTO
- Validate and normalize the DTO for response
use OpenapiPhpDtoGenerator\Service\DtoDeserializer; use OpenapiPhpDtoGenerator\Service\DtoNormalizer; use Symfony\Component\HttpFoundation\Request; use YourApp\Generated\UserPostRequest; // generated DTO from OpenAPI spec use YourApp\Generated\UserViewResponse; // generated DTO from OpenAPI spec $deserializer = new DtoDeserializer(); $normalizer = new DtoNormalizer(); /** @var Request $request */ // request: deserialize -> validate $requestDto = $deserializer->deserialize($request, UserPostRequest::class); // response: validate -> normalize $responseData = $normalizer->validateAndNormalizeToArray($requestDto); // response: normalize without validation for faster response $responseData = $normalizer->toArray(new UserViewResponse(name: 'John', surname: 'Doe'));
Usage
Add script in your project composer.json
{
"scripts": {
"openapi:generate-dto": "php vendor/michaelalexeevweb/openapi-php-dto-generator/bin/console openapi:generate-dto"
}
}
Generate DTO classes from YAML OpenAPI spec
Default โ use the runtime services straight from the installed package. Omit the
--dto-generator-* options: the generated DTOs reference the runtime classes from
vendor/ (OpenapiPhpDtoGenerator\Contract\โฆ), so nothing is copied and updates come
through composer update:
composer openapi:generate-dto -- \
--file=OpenApiExamples/test.yaml \
--directory=generated/test \
--namespace=Generated\\Test
Optional โ vendor a private copy of the runtime services into your project (e.g. to
commit them or decouple from the package). Pass --dto-generator-directory; the generated
DTOs then reference that copied namespace instead of vendor/:
composer openapi:generate-dto -- \ --file=OpenApiExamples/test.yaml \ --directory=generated/test \ --namespace=Generated\\Test \ --dto-generator-directory=Common \ --dto-generator-namespace=Generated\\Common
Parameters:
| Option | Alias | Required | Description |
|---|---|---|---|
--file |
-f |
โ | Path to OpenAPI spec file (YAML or JSON) |
--directory |
-d |
โ | Output directory for generated DTOs |
--namespace |
Explicit DTO namespace (derived from --directory if omitted) |
||
--dto-generator-directory |
Omit to use the runtime services from vendor/ (no copy โ the default). Pass it to copy them into the given directory instead; the flag without a value defaults to Common. |
||
--dto-generator-namespace |
Namespace for the copied runtime services. Only has effect together with --dto-generator-directory. |
||
--attributes |
Generation mode: runtime (default โ DTOs use this library's runtime), symfony (DTOs decorated with Symfony Validator/Serializer attributes) or laravel (a plain DTO plus a FormRequest with rules()). See Generation Modes. |
||
--with-psr7 |
Also copy the PSR-7 deserializer (DtoDeserializerPsr7) when vendoring the runtime via --dto-generator-directory. Requires symfony/psr-http-message-bridge in the consuming project. |
||
--ref |
Explicit output directory for an external $ref spec file or directory: <refFileOrDir>=<directory>. A directory key maps every ref'd file inside it. Repeatable. Requires a matching --ref-namespace. Unmatched ref files are ignored. |
||
--ref-namespace |
Explicit namespace for an external $ref spec file or directory: <refFileOrDir>=<namespace>. Repeatable. Requires a matching --ref. |
Generation Modes
The generator emits DTOs in one of three modes, selected with --attributes (default: runtime).
All three enforce the same OpenAPI vocabulary on a payload โ they differ in what surrounds it.
| Runtime (default) | Symfony (--attributes=symfony) |
Laravel (--attributes=laravel) |
|
|---|---|---|---|
| Generated class | implements GeneratedDtoInterface, getters, metadata methods |
plain class with getters, #[Assert\*] attributes |
plain class with getters, rules(), fromValidated(), plus a FormRequest for every request payload |
| Depends on | this package (or a vendored copy of its services) | symfony/validator + symfony/serializer |
nothing to install โ FormRequest and the validator ship with Laravel |
| Validation runs in | DtoValidator |
Symfony constraints + a generated #[Assert\Callback] |
Laravel rules + a generated withValidator() |
| Errors come out as | one aggregated exception | ConstraintViolationList (422 through #[MapRequestPayload]) |
the framework's own 422 with its error bag |
| Validated before the controller runs | you call the deserializer | yes, via #[MapRequestPayload] |
yes, the FormRequest is resolved first |
| Request binding | done here: sources, style/explode, allowReserved, multipart Encoding |
done by Symfony, so those OpenAPI rules do not apply | done by Laravel, same limitation |
| PATCH / partial updates | yes โ UnsetValue presence tracking |
yes โ isXxxProvided(), recorded by the setter |
yes โ isXxxProvided(), from the validated keys |
readOnly / writeOnly |
enforced | serialization groups you have to pass | enforced |
additionalProperties: false on a DTO-shaped schema |
not enforced (the payload is bound first) | not enforced | enforced โ the interpreter sees the raw payload |
Rule of thumb: runtime when the request itself must follow the spec (parameter styles, partial updates, one library end to end); symfony or laravel when you want plain DTOs your framework owns, validated by the framework, with errors in the shape it already speaks.
Each mode has its own guide โ what it can do, how to wire it, where it stops:
For the keyword-by-keyword answer โ what every mode enforces, the six places they differ and why, and what is not generated in any of them โ see the support matrix. It is derived from the parity suites, so a row that stops being true fails a test.
Validation Notes
A few behaviours worth knowing when validating against the schema:
-
type: arraymeans a JSON array (list). A value passes only when it is a PHP list (sequential integer keys from0). An associative array is treated as a JSON object, not an array โ so a getter returningarray_filter(...)(which may leave non-contiguous keys) should wrap the result inarray_values(...). -
oneOf/anyOfpick the first matching branch. Branches are tried in declaration order and the first one that validates wins. When several branches accept the same input (e.g.oneOf: [string, integer]given"123"), order your schema branches from most specific to least specific. -
unevaluatedProperties/unevaluatedItems(JSON Schema 2019-09/2020-12, OpenAPI 3.1). LikeadditionalProperties: false/ a suffixitems, but annotation-aware: a key or index counts as "evaluated" when it is covered by this schema or by any in-place applicator that actually applies (allOf, a passinganyOf/oneOfbranch, the takenif/then/elsearm, a triggereddependentSchemas) โ recursively, to any nesting depth. Only what is left over is checked. They are enforced on the non-materialized paths (raw lists, inline maps); a composed object with named properties is materialized into a dedicated nested DTO where unknown keys are impossible by construction. -
contentEncoding/contentMediaType/contentSchema(JSON Schema 2019-09/2020-12, OpenAPI 3.1). Enforced as assertions on strings: the value must decode undercontentEncoding(base64,base16,quoted-printable,7bit/8bit/binary; an unknown codec such asbase32is accepted leniently), the decoded bytes must parse whencontentMediaTypeis a JSON type (application/jsonor any+json), and the parsed document must satisfycontentSchema. -
$defs(JSON Schema) is folded intocomponents.schemas. A$defsmap (in the root document or an external file) and any#/$defs/Xpointer โ local#/$defs/Xor cross-fileother.yaml#/$defs/Xโ are normalized tocomponents.schemasat load time, so$defs-style specs generate the same ascomponents-style ones. (Subschema-local$defs, e.g.#/components/schemas/Foo/$defs/Bar, is not folded โ prefer top-levelcomponents.schemas/$defsfor shared types.) -
Parameters serialized via
content. A parameter that usescontent: {application/json: {schema}}instead of a plainschemais supported: the schema is extracted and its JSON-string value is decoded before validation and casting (malformed JSON is a clear error). -
type: integeraccepts a number with a zero fractional part (JSON Schema 2020-12 ยง6.1.1), so a payload of42.0is a valid integer while42.5is not. Runtime and Laravel mode follow this end to end, hydration included. Symfony mode cannot: its serializer type-checks theintproperty before any generated constraint runs, and rejects42.0with a denormalization error. -
type: objectrefuses a JSON array.{"tags":[1,2]}where the schema says object is rejected โ it used to be accepted and read as a map keyed0..n-1. The distinction lives in the RAW body: once PHP decodes it, a JSON object whose keys are exactly0..n-1and a JSON array are the same value. So the check runs where the raw body is still reachable โ the runtime deserializer decodes it itself, and the generated LaravelFormRequesthandswithValidator()the undecoded body. Symfony mode cannot:#[MapRequestPayload]denormalizes first, so there it stays accepted. -
Error messages are the same sentence in every mode, differing only in how the subject is named:
Mode Sentence runtime param "tags" must contain unique itemssymfony field "tags" must contain unique itemslaravel tags must contain unique itemsโ keyed bytagsin the error bagThis holds for every keyword the interpreter owns (
oneOf,anyOf,not,contains,if/then,propertyNames,unevaluated*, โฆ) and is pinned bytests/Parity/InterpreterMessageParityTest. A keyword the framework has its own rule for keeps the FRAMEWORK's message โexclusiveMinimumreads "This value should be greater than 3." in Symfony mode andmultipleOfresolvesvalidation.multiple_ofin Laravel mode โ so your own translations still apply. -
Extended string formats. Beyond the common set, these are validated:
uri-reference/iri-reference,uri-template(RFC 6570),idn-hostname,relative-json-pointer. Unknown formats are accepted (per spec, an unknownformatis an annotation, not an assertion).
Upgrading
The library itself is a drop-in replacement: DTOs generated by 2.8.x keep working unchanged against 2.10.0 services (measured on 55 specs โ same accept/reject verdicts, same normalized output; the two metadata methods added in 2.9.0 are simply absent on old DTOs and the services fall back).
2.9.0 โ 2.10.0 adds a mode and changes nothing else for existing users: runtime-mode output is
byte-identical, Symfony-mode output differs in two lines of the emitted interpreter, and the whole 2.9.0
test suite passes against 2.10.0 services with one intentional difference โ type: integer now accepts
42.0, which the spec always called an integer. Code that string-matches an error message should read
the three message changes in the CHANGELOG.
What changes is the code the generator EMITS โ a bare type: object property becomes a map, a named
scalar schema becomes a type alias, and Symfony-mode DTOs expose accessors instead of public
properties. Every change, what it breaks and what to do about it:
CHANGELOG.md.