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 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/mime: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
- twig/twig: ^3.0
Requires (Dev)
- ergebnis/phpstan-rules: ^2.13
- friendsofphp/php-cs-fixer: ^3.95
- illuminate/config: ^11.1 || ^12 || ^13
- illuminate/container: ^11.1 || ^12 || ^13
- illuminate/events: ^11.1 || ^12 || ^13
- illuminate/filesystem: ^11.1 || ^12 || ^13
- illuminate/http: ^11.1 || ^12 || ^13
- illuminate/routing: ^11.1 || ^12 || ^13
- illuminate/translation: ^11.1 || ^12 || ^13
- illuminate/validation: ^11.1 || ^12 || ^13
- 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
- spatie/laravel-data: ^4.23
- squizlabs/php_codesniffer: ^4.0.2
- symfony/property-access: ^7.4 || ^8.0
- symfony/property-info: ^7.4.6 || ^8.0
- symfony/psr-http-message-bridge: ^7.4 || ^8.0
- symfony/serializer: ^7.4.6 || ^8.0
- symfony/validator: ^7.4 || ^8.0
- yiisoft/hydrator: ^1.6
- yiisoft/hydrator-validator: ^2.0
- yiisoft/input-http: ^1.0
- yiisoft/request-provider: ^1.3
- yiisoft/validator: ^2.6
Suggests
- ext-intl: Required by yiisoft/validator's Date, DateTime and Time rules, which yii3 mode emits for temporal formats.
- spatie/laravel-data: To use the generated Data classes in laravel-data mode (--attributes=laravel-data).
- 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).
- yiisoft/input-http: To have yii3 hydrate the generated inputs from the request (--attributes=yii3).
- yiisoft/validator: To use the generated inputs in yii3 mode (--attributes=yii3).
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 2.15.118
- 2.15.117
- 2.15.116
- 2.15.115
- 2.15.114
- 2.15.113
- 2.15.112
- 2.15.111
- 2.15.110
- 2.15.109
- 2.15.108
- 2.15.107
- 2.15.106
- 2.15.105
- 2.15.104
- 2.15.103
- 2.15.102
- 2.15.101
- 2.15.100
- 2.15.99
- 2.15.98
- 2.15.97
- 2.15.96
- 2.15.95
- 2.15.94
- 2.15.93
- 2.15.92
- 2.15.91
- 2.15.90
- 2.15.89
- 2.15.88
- 2.15.87
- 2.15.86
- 2.15.85
- 2.15.84
- 2.15.83
- 2.15.82
- 2.15.81
- 2.15.80
- 2.15.79
- 2.15.78
- 2.15.77
- 2.15.76
- 2.15.75
- 2.15.74
- 2.15.73
- 2.15.72
- 2.15.71
- 2.15.70
- 2.15.69
- 2.15.68
- 2.15.67
- 2.15.66
- 2.15.65
- 2.15.64
- 2.15.63
- 2.15.62
- 2.15.61
- 2.15.60
- 2.15.59
- 2.15.58
- 2.15.57
- 2.15.56
- 2.15.55
- 2.15.54
- 2.15.53
- 2.15.52
- 2.15.51
- 2.15.50
- 2.15.49
- 2.15.48
- 2.15.47
- 2.15.46
- 2.15.45
- 2.15.44
- 2.15.43
- 2.15.42
- 2.15.41
- 2.15.40
- 2.15.39
- 2.15.38
- 2.15.37
- 2.15.36
- 2.15.35
- 2.15.34
- 2.15.33
- 2.15.32
- 2.15.31
- 2.15.30
- 2.15.29
- 2.15.28
- 2.15.27
- 2.15.26
- 2.15.25
- 2.15.24
- 2.15.23
- 2.15.22
- 2.15.21
- 2.15.20
- 2.15.19
- 2.15.18
- 2.15.17
- 2.15.16
- 2.15.15
- 2.15.14
- 2.15.13
- 2.15.12
- 2.15.11
- 2.15.10
- 2.15.9
- 2.15.8
- 2.15.7
- 2.15.6
- 2.15.5
- 2.15.4
- 2.15.3
- 2.15.2
- 2.15.1
- 2.15.0
- 2.14.0
- 2.13.0
- 2.12.0
- 2.11.0
- 2.10.0
- 2.9.0
- 2.8.18
- 2.8.17
- 2.8.16
- 2.8.15
- 2.8.14
- 2.8.13
- 2.8.12
- 2.8.11
- 2.8.10
- 2.8.9
- 2.8.8
- 2.8.7
- 2.8.6
- 2.8.5
- 2.8.4
- 2.8.3
- 2.8.2
- 2.8.1
- 2.8.0
- 2.7.2
- 2.7.1
- 2.7.0
- 2.6.1
- 2.6.0
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.0
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.13
- 2.2.12
- 2.2.11
- 2.2.10
- 2.2.9
- 2.2.8
- 2.2.7
- 2.2.6
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.14
- 2.1.13
- 2.1.12
- 2.1.11
- 2.1.10
- 2.1.9
- 2.1.8
- 2.1.7
- 2.1.6
- 2.1.5
- 2.1.4
- 2.1.3
- 2.1.2
- 2.1.1
- 2.1.0
- 2.0.29
- 2.0.28
- 2.0.27
- 2.0.26
- 2.0.25
- 2.0.24
- 2.0.23
- 2.0.22
- 2.0.21
- 2.0.20
- 2.0.19
- 2.0.18
- 2.0.17
- 2.0.16
- 2.0.15
- 2.0.14
- 2.0.13
- 2.0.12
- 2.0.11
- 2.0.10
- 2.0.9
- 2.0.8
- 2.0.7
- 2.0.6
- 2.0.5
- 2.0.4
- 2.0.3
- 2.0.2
- 2.0.1
- 2.0.0
- 1.2.3
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.7
- 1.1.6
- 1.1.5
- 1.1.4
- 1.1.3
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
This package is auto-updated.
Last update: 2026-11-05 11:27:55 UTC
README
Your OpenAPI document, enforced by the PHP it generates.
Point it at an OpenAPI 3.0 / 3.1 spec. Get immutable, strictly-typed PHP 8.3 DTOs whose generated code
enforces the schema, not just the types — oneOf, minimum, pattern, format,
unevaluatedProperties and the rest of a broad, tested vocabulary — for Symfony, Laravel,
spatie/laravel-data, Yii3, or standalone.
Generate DTOs from OpenAPI, deserialize a Symfony Request or any PSR-7 request straight into them,
validate incoming HTTP requests against the OpenAPI schema, and normalize back to arrays or JSON — one
package, and no spec parsing at runtime.
- In: an OpenAPI 3.0 / 3.1 document, YAML or JSON.
- Out: one PHP class per schema — from
components.schemasand from each operation's request body, parameters (path, query, header, cookie) and inline response bodies. - Use it for: server-side request DTOs, PATCH-safe presence, response normalization.
- Not for: generating an API client or SDK — see what it does not do.
- Start with:
runtimemode, the default. Reach for a framework mode only when you want that framework's own validation output.
composer require michaelalexeevweb/openapi-php-dto-generator:^2.15.118
60 seconds
This document:
components: schemas: UserPostRequest: type: object required: [email] properties: email: {type: string, format: email} age: {type: integer, minimum: 18} nickname: {type: [string, 'null']}
Through this command:
php vendor/michaelalexeevweb/openapi-php-dto-generator/bin/console openapi:generate-dto \
--file=openapi.yaml --directory=src/Generated --namespace='App\Generated'
Becomes this class — an optional property defaults to a sentinel, not to null, which is what keeps
"absent" and "sent as null" apart:
final class UserPostRequest implements GeneratedDtoInterface, Stringable { public function __construct( private readonly string $email, private readonly int|UnsetValue|null $age = UnsetValue::UNSET, private readonly string|UnsetValue|null $nickname = UnsetValue::UNSET, ) { /* … */ } public function getEmail(): string { /* … */ } public function getAge(): ?int { /* … */ } public function getNickname(): ?string { /* … */ } public function isNicknameInRequest(): bool { /* … */ } // was the key sent at all? }
Which you use like this:
// IN — the HTTP request becomes a typed DTO, or this throws with every problem at once. $input = (new DtoDeserializer())->deserialize($request, UserPostRequest::class); $user = $users->find($id); $user->setEmail($input->getEmail()); // required by the document, so always there // A PATCH must touch only what the client actually sent, and `null` IS something the client can // send — it clears the nickname. `!== null` cannot tell those two apart; the presence check can. if ($input->isNicknameInRequest()) { $user->setNickname($input->getNickname()); // may be null, and that is the point } // OUT — the response DTO is checked against its own schema before it is serialized, so a broken // response fails here rather than at the client. $responseBody = (new DtoNormalizer())->validateAndNormalizeToArray( new UserResponse(email: $user->getEmail(), nickname: $user->getNickname()), );
And which answers like this — the messages below are the real output, not a paraphrase:
| request body | getNickname() |
isNicknameInRequest() |
|---|---|---|
{"email":"a@b.test"} |
null |
false — the key never came |
{"email":"a@b.test","nickname":null} |
null |
true — the client sent null on purpose |
{"email":"a@b.test","nickname":"neo"} |
"neo" |
true |
Same getter, different answer: that distinction is what a PATCH endpoint needs and what a plain ?string
cannot express. And when the document is not satisfied:
| request body | error |
|---|---|
{"age":30} |
Required parameter "email" not found in request. |
{"email":"nope","age":12} |
param "email" must match format email.param "age" must be greater than or equal to 18. |
format: email and minimum: 18 are enforced by code the generator wrote, and both problems are
reported together rather than one at a time.
The presence accessor is named for its mode: isNicknameInRequest() in runtime, isNicknameProvided()
in symfony, laravel and yii3. laravel-data needs neither — there the property's own type is
string|null|Optional.
Add --attributes=symfony|laravel|laravel-data|yii3 to get the same enforcement in your framework's own
shape instead.
Every CLI option, and how to vendor the runtime services →
Why this one
Seven PHP tools that read an OpenAPI document were downloaded and run on the same spec with the
same payloads — an eighth, php-collective/dto, generates from its own XML config and never saw the
spec, so it is reported separately
and holds no cell here. Nothing below is quoted from anyone's README — every cell is a verdict the tool
actually returned, on the versions and date named there.
| enforced on the payload | minimum / maximum |
pattern |
format |
oneOf |
builds a typed object |
|---|---|---|---|---|---|
JanePHP open-api-3 8.1.1, validation: true |
❌ | ❌ | ❌ | ❌ | ✅ |
OpenAPI Generator 7.26 — php-symfony, php-dt, php-nextgen |
❌ | ❌ | ❌ | ❌ | ✅³ |
maxbeckers/php-openapi-generator 1.0.0 |
❌ | ❌ | ❌ | ❌ | ✅ |
ensi/laravel-openapi-server-generator 4.0.5 |
❌ | ❌ | partly⁴ | ❌ | ❌ |
wol-soft/php-json-schema-model-generator 0.26.2 |
✅ | ✅ | ❌⁵ | ✅ | ✅ |
onmoon/openapi-server-bundle 6.0.0² |
✅ | ✅ | ✅¹ | ✅ | not run⁶ |
league/openapi-psr7-validator 0.24² |
✅ | ✅ | ✅¹ | ✅ | ❌ |
| this library | ✅ | ✅ | ✅ | ✅ | ✅ |
¹ everything except uri-template. ² at runtime: league generates no code at all, and onmoon's
generated server hands every request to league 0.22. Every other row is enforced by the code the tool
generated. ³ not php-symfony on a oneOf property: it becomes an empty class, so the object cannot be
built from the payload and even the baseline does not get through. ⁴ uuid, email, date and a few
more become Laravel rules; uri does not — ensi knows only its own spelling, url. ⁵ none built in: on
the first format it meets, generation stops with Unsupported format, and each one has to be written by
the user. ⁶ the DTOs need a booted Symfony kernel to generate and were not built for this comparison.
Most generators in the set check the type at most, and stop there — and two do not even hold
presence: maxbeckers fills a missing required key with '' or 0, and php-symfony cannot tell an absent
key from null. Two do more, each with a catch. wol-soft emits bounds, pattern and oneOf as code, as
this library does, but ships no format and refuses to generate a schema that uses one. onmoon enforces
what league enforces because it is league at runtime — it caches the parsed document rather than
re-reading it, but every check is still league walking the spec, with league's uri-template gap and
league's messages. This is the only one in the set that emits every check as code,
format included.
On real payloads that means code: 5 against minimum: 10 is refused here and accepted by Jane; a
malformed uri-template is refused here and accepted by both Jane and league; and where league answers
Keyword validation failed: Data must match exactly one schema, this answers
param "code" must match format uuid.
Every payload, every verdict, the versions, and where we are behind →
Three things that only fall out of generating the checks
- Nothing is parsed at boot. The spec is compiled into PHP array literals inside the generated
class —
getConstraints()in runtime, aprivate constin the framework modes.leaguere-reads the YAML in every PHP process — 21.8 ms before it answers the first request. Here that cost does not exist. Performance → - PATCH is expressible. "Absent" and "sent as
null" stay different values — a sentinel, anOptionalmember, or an uninitialised typed property, depending on the mode. OpenAPI Generator makes every property nullable with= null, so the two collapse into one and a partial update cannot be written correctly. - An undiscriminated
anyOfstays honest. You get an interface plus one class per branch, and a warning at generation time saying it will not be hydrated. Others flatten the branches into a single class — where a cat carryingbarkpasses — or drop the property tomixed.
Fast, because the checks are emitted code
bin/benchmark, PHP 8.4, opcache off, 20 000 iterations, mean of two runs, same schema and payload in
every mode (round trip = bind + validate + normalize):
| bind | validate | normalize | round trip | |
|---|---|---|---|---|
| runtime | 0.1082 ms | 0.0966 ms | 0.0124 ms | 0.2172 ms |
| symfony | 0.2343 ms | 0.0815 ms | 0.1119 ms | 0.4279 ms |
laravel (FormRequest) |
0.0033 ms | 1.5528 ms | 0.0020 ms | 1.5583 ms |
| laravel-data | 0.0922 ms | 1.7787 ms | 0.0475 ms | 1.9185 ms |
| yii3 | 0.1307 ms | 0.2085 ms | 0.0106 ms | 0.3498 ms |
The millisecond in both Laravel modes is illuminate/validation walking the rule array: the generated
interpreter — the part this library actually writes — is 2.6% of the Laravel validate step. Runtime,
symfony and yii3 stay within ~2× of each other, and the same ordering holds on seven request bodies of a
production application.
Full numbers, and the benchmark to re-run them →
Five modes
All five enforce the same schema validation vocabulary, except where a framework's serializer or
hydrator decides first — a closed object in symfony and yii3, a loose date-time in symfony; the
support matrix
lists each one.
What differs is everything around it: who validates, what the errors look like, what you install, and
how much of the REQUEST the document still governs.
| Mode | What it emits | Needs | Errors arrive as |
|---|---|---|---|
| runtime (default) | immutable DTO + this library's validator / normalizer / deserializer | this package | one aggregated exception |
| symfony | plain DTO with #[Assert\*], #[SerializedName], #[Groups] |
symfony/validator + symfony/serializer |
ConstraintViolationList, 422 via #[MapRequestPayload] |
| laravel | plain DTO + a FormRequest carrying rules() |
nothing beyond the framework | the framework's own 422 error bag |
| laravel-data | one Data class per schema, Optional for presence |
spatie/laravel-data |
the framework's own 422 error bag |
| yii3 | one AbstractInput per schema, yiisoft/validator attributes |
yiisoft/validator + yiisoft/hydrator + yiisoft/hydrator-validator + yiisoft/input-http |
a Result your action reads |
Request binding is not the same in every mode, and that is the one difference worth knowing before you choose. A framework mode hands binding to its own serializer or hydrator, which has already decided what the payload is before any generated code runs:
| runtime | symfony | laravel | laravel-data | yii3 | |
|---|---|---|---|---|---|
parameter sources — path / query / header / cookie |
✅ | ➖ query; path via --symfony-path-params |
➖ query, path, header, cookie via the FormRequest | ➖ query; path via #[FromRouteParameter] |
➖ body, query, path only |
style / explode / allowReserved / allowEmptyValue |
✅ | ➖ a query list's style / explode; not a repeated key |
➖ form, pipe, space delimiters for query and path | ➖ form, pipe, space delimiters for query | ❌ |
multipart encoding |
✅ | ❌ | ❌ | ❌ | ❌ |
Rule of thumb. Not sure? Take runtime — it is the default, it needs no framework, and it is the only mode where the REQUEST itself follows the document. Take symfony or laravel when you want plain DTOs your framework owns and errors in the shape it already speaks. Take laravel-data or yii3 when your application already runs that package.
There are further divergences, most deliberate and a few limits of the framework component that owns the step, each pinned by a test that names its cause. They are written down rather than left to be discovered: the support matrix gives the keyword-by-keyword answer per mode and lists what is not generated in any of them. It is derived from the parity test suites, so a row that stops being true fails a test.
Documentation
| what is in it | |
|---|---|
| How it compares | eight tools downloaded and run, the seven OpenAPI ones on the same spec and payloads |
| Support matrix | every keyword per mode, every divergence, what is out of scope |
| Performance | bind / validate / normalize per mode, measured, with the benchmark |
| Validation notes | where a careless reading of the spec and a correct one disagree |
| CLI reference | every option, vendoring the runtime, external $ref mapping |
| runtime · symfony · laravel · laravel-data · yii3 | one guide per mode: what it does, how to wire it, where it stops |
| CHANGELOG | every release, what it breaks, and what to do about it |
Requirements
PHP 8.3+ and the Symfony 7.4 or 8.x components console, http-foundation, mime, yaml (8.x
needs PHP 8.4.1+). Each mode's own
dependencies are in its guide.
What it does not do
It generates the server side: the classes an incoming request becomes, and the checks that request must pass. It does not generate an HTTP client or an SDK for calling someone else's API — if that is what you need, JanePHP and OpenAPI Generator do it and this does not.
Property names in any script survive: PHP identifiers accept non-ASCII letters, so юникод becomes
$юникод, and the wire name is carried as written. Before 2.15.41 such a name collapsed to $value.
Punctuation and casing ARE handled — kebab-case, dot.name, UPPER, 123numeric, with space,
$dollar and PHP's own reserved words all become clean identifiers with an alias back to the original.
Laravel's validator reads . and * in a key as path syntax. Laravel mode escapes a dot (invoice\.note)
and validates the literal key, and rejects only *. laravel-data rejects both at generation time — its
#[MapName('a.b')] is a nested path too — so a document with "invoice.note" does not generate there:
rename the property or use Laravel mode for that document. Other modes keep such names as they are.
Upgrading
composer update, then regenerate. Every release says what changed in the emitted code, what breaks and
what to do about it: CHANGELOG.md.
Changes that touch your own code:
- a class of your own implementing
DtoDeserializerInterface— 2.12.0 addeddeserializeValue(), 2.13.0 gave two methods optional parameters; - positional construction of a class with a
readOnlyproperty — 2.15.29 moved it behind the required ones; use named arguments; - a reference to an alias class (
X: {$ref: Y}) — 2.15.63 stopped generating it; - a property named in
requiredbeside anallOf— 2.15.115 made it required and non-nullable.
Each entry has its migration note.
License
MIT.