https://github.com/michaelalexeevweb/openapi-php-dto-generator
Generate PHP DTOs from OpenAPI and validate incoming HTTP requests against OpenAPI schema
https://github.com/michaelalexeevweb/openapi-php-dto-generator
api deserialization dto dto-generator openapi openapi-validation php request-validation serializer symfony validation
Last synced: 4 days ago
JSON representation
Generate PHP DTOs from OpenAPI and validate incoming HTTP requests against OpenAPI schema
- Host: GitHub
- URL: https://github.com/michaelalexeevweb/openapi-php-dto-generator
- Owner: michaelalexeevweb
- License: mit
- Created: 2026-03-09T20:22:36.000Z (4 months ago)
- Default Branch: master
- Last Pushed: 2026-06-24T08:11:21.000Z (7 days ago)
- Last Synced: 2026-06-24T08:29:18.928Z (7 days ago)
- Topics: api, deserialization, dto, dto-generator, openapi, openapi-validation, php, request-validation, serializer, symfony, validation
- Language: PHP
- Homepage:
- Size: 597 KB
- Stars: 5
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
# OpenAPI PHP DTO Generator
[](https://github.com/michaelalexeevweb/openapi-php-dto-generator/blob/master/LICENSE)
[](https://github.com/michaelalexeevweb/openapi-php-dto-generator/actions/workflows/ci.yml)
[](https://packagist.org/packages/michaelalexeevweb/openapi-php-dto-generator)
[](https://packagist.org/packages/michaelalexeevweb/openapi-php-dto-generator)
[](https://packagist.org/packages/michaelalexeevweb/openapi-php-dto-generator)
**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
- 🎯 **Two generation modes** — **runtime** (DTOs backed by this library's validator/normalizer/deserializer) or **symfony** (plain DTOs decorated with Symfony `#[Assert\*]` / `#[SerializedName]` / `#[Groups]` attributes, validated and (de)serialized by Symfony itself)
- ✅ **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 `Request` objects directly into typed PHP DTOs
- 🔌 **Framework-agnostic (PSR-7)** — deserialize any PSR-7 `ServerRequestInterface` via `DtoDeserializerPsr7` (Slim, Mezzio, Laminas, Yii3, …); Symfony `Request` covers Symfony + Laravel
- 🔒 **Immutable by design** — all generated classes are read-only value objects
- ⚡ **Supports OpenAPI 3.0.x and 3.1.x**
## Table of Contents
- [Installation](#installation)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Generate DTOs](#generate-dto-classes-from-yaml-openapi-spec)
- [Generation Modes: Runtime vs Symfony](#generation-modes-runtime-vs-symfony)
- [Validate & Normalize](#validate-and-normalize-generated-dtos)
- [Framework-Agnostic Deserialization (PSR-7)](#framework-agnostic-deserialization-psr-7)
- [CLI Commands](#cli-commands)
## Installation
```bash
composer require michaelalexeevweb/openapi-php-dto-generator:^2.8.7
```
## Requirements
- PHP 8.3+
- Symfony 7.4 components (`console`, `http-foundation`, `mime`, `yaml`)
## Quick Start
1. **Generate DTOs** from your OpenAPI YAML spec
2. **Deserialize** and **validate** an incoming HTTP request into a generated DTO
3. **Validate** and **normalize** the DTO for response
```php
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`
```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`:
```bash
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/`:
```bash
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) or `symfony` (DTOs decorated with Symfony Validator/Serializer attributes). See [Generation Modes](#generation-modes-runtime-vs-symfony). |
| `--ref` | | | Explicit output directory for an external `$ref` spec file **or 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**: `=`. Repeatable. Requires a matching `--ref`. |
## Generation Modes: Runtime vs Symfony
The generator emits DTOs in one of two modes, selected with `--attributes` (default: `runtime`).
### Runtime mode (default)
DTOs implement `GeneratedDtoInterface` and carry the metadata methods (`toArray()`,
`getNormalizationMap()`, `getConstraints()`, …). They are validated, normalized and deserialized
by **this library's own services** — `DtoValidator`, `DtoNormalizer`, `DtoDeserializer` — which
enforce the full OpenAPI vocabulary (including `oneOf`/`anyOf`/`allOf`, `if/then/else`, `not`,
`prefixItems`, object/map constraints) and track which optional fields were actually provided
(PATCH-friendly presence tracking via the `UnsetValue` sentinel).
```bash
composer openapi:generate-dto -- \
--file=OpenApiExamples/test.yaml \
--directory=generated/test \
--namespace=Generated\\Test
# --attributes=runtime is the default
```
```php
// generated in runtime mode (excerpt)
final class User implements GeneratedDtoInterface, Stringable
{
// presence flags per property: $nameInRequest, $emailInRequest, … (what was actually sent)
/**
* @param string $name
* Constraints: minLength=2, maxLength=50
* @param string|UnsetValue|null $email
* Constraints: format=email
*/
public function __construct(
private readonly string $name,
private readonly string|UnsetValue|null $email = UnsetValue::UNSET,
private readonly Address|UnsetValue|null $address = UnsetValue::UNSET,
) {
$this->emailInRequest = $email !== UnsetValue::UNSET; // presence tracking (PATCH-friendly)
// …
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): ?string
{
return $this->email !== UnsetValue::UNSET ? $this->email : null;
}
// + isNameInRequest()/isNameRequired()/…, toArray(), jsonSerialize(),
// getNormalizationMap(), getAliases(), getConstraints() — consumed by the runtime services
}
```
### Symfony mode (`--attributes=symfony`)
DTOs are plain, immutable data classes with promoted `public readonly` constructor properties
decorated with **Symfony Validator / Serializer attributes**. There is no library runtime: the DTOs
are validated by `symfony/validator` and (de)serialized by `symfony/serializer` (or auto-mapped in a
controller with `#[MapRequestPayload]` / `#[MapQueryString]`).
```bash
composer openapi:generate-dto -- \
--file=OpenApiExamples/test.yaml \
--directory=generated/test \
--namespace=Generated\\Test \
--attributes=symfony
```
```php
// generated in symfony mode
class User
{
public function __construct(
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
public readonly string $name,
#[Assert\Email]
public readonly ?string $email = null,
#[SerializedName('created_at')]
public readonly ?DateTimeImmutable $createdAt = null,
#[Assert\Valid]
public readonly ?Address $address = null,
) {
}
}
```
In a Symfony controller the DTO is validated and populated automatically:
```php
public function create(#[MapRequestPayload] User $user): Response { /* ... */ }
```
**OpenAPI → Symfony attribute mapping:**
| OpenAPI | Symfony attribute |
|---|---|
| `required` (non-nullable) | `#[Assert\NotNull]` |
| `minLength` / `maxLength` | `#[Assert\Length(min:, max:)]` |
| `minimum` / `maximum` | `#[Assert\Range(min:, max:)]` |
| `exclusiveMinimum` / `exclusiveMaximum` | `#[Assert\GreaterThan]` / `#[Assert\LessThan]` |
| `multipleOf` | `#[Assert\DivisibleBy]` |
| `pattern` | `#[Assert\Regex]` |
| `minItems` / `maxItems`, `minProperties` / `maxProperties` | `#[Assert\Count]` |
| `uniqueItems` | `#[Assert\Unique]` |
| `const` | `#[Assert\EqualTo]` |
| `enum` | generated PHP backed `enum` (type-enforced) |
| `format: email` / `uuid` / `uri` / `ipv4`,`ipv6` / `hostname` | `#[Assert\Email]` / `Uuid` / `Url` / `Ip` / `Hostname` |
| `format: int32` / `uint32` / `uint64` | `#[Assert\Range]` (bounds) |
| `format: date` / `date-time` | `DateTimeImmutable` type |
| `format: binary` | `UploadedFile` type |
| `items` (scalar) / `additionalProperties` | `#[Assert\All([...])]` |
| `anyOf` | `#[Assert\AtLeastOneOf([...])]` |
| nested DTO / array of DTOs | `#[Assert\Valid]` (cascade) |
| property name ≠ OpenAPI name | `#[SerializedName('…')]` |
| `readOnly` / `writeOnly` | `#[Groups(['read'])]` / `#[Groups(['write'])]` |
**Symfony-mode limitations** (no clean Symfony Validator equivalent — these keywords are skipped):
`oneOf`/`discriminator` polymorphism, `not`, `if/then/else`, `prefixItems` (tuples),
`patternProperties`, `propertyNames`, `dependentRequired`/`dependentSchemas`, `contains`. Optional
fields become `?T = null` (no `UnsetValue` presence tracking — use runtime mode if you need
PATCH/partial-update semantics). Note also: `format: uri`/`iri` maps to `#[Assert\Url]`, which
expects an absolute URL (relative URIs would fail); and an `anyOf` branch that is purely
`{type: null}` causes the whole `#[Assert\AtLeastOneOf]` to be dropped (the field stays nullable).
> Requires `symfony/validator` and `symfony/serializer` in the consuming project.
## Framework-Agnostic Deserialization (PSR-7)
`deserialize()` accepts a Symfony `Request` — which also covers **Laravel** (its
`Illuminate\Http\Request` extends the Symfony one). Laravel route parameters
(`/users/{id}`) are bridged automatically: `deserialize()` reads them from
`$request->route()->parameters()` when present, so path params resolve with no extra wiring. For any other stack (Slim, Mezzio, Laminas,
Yii3, …) that speaks **PSR-7**, use `DtoDeserializerPsr7`: it converts a PSR-7
`ServerRequestInterface` into a Symfony `Request` via the official
[`symfony/psr-http-message-bridge`](https://github.com/symfony/psr-http-message-bridge) and
delegates to the core deserializer.
```php
use OpenapiPhpDtoGenerator\Service\DtoDeserializerPsr7;
use Psr\Http\Message\ServerRequestInterface;
/** @var ServerRequestInterface $request */
$deserializer = new DtoDeserializerPsr7();
// Single object body:
$dto = $deserializer->deserializePsr7($request, UserPostRequest::class);
// Top-level JSON array body (bulk endpoints):
$items = $deserializer->deserializeCollectionPsr7($request, Item::class);
```
Path parameters are read from PSR-7 request attributes (`$request->withAttribute('id', …)`), where
routers typically place them — the bridge carries them over to the Symfony request.
PSR-7 support requires the bridge in your project:
```bash
composer require symfony/psr-http-message-bridge
```
When vendoring the runtime into your project (`--dto-generator-directory`), pass `--with-psr7` to
also copy `DtoDeserializerPsr7` alongside the other runtime services.
### Laravel
`Illuminate\Http\Request` is a Symfony `Request`, so the core `DtoDeserializer` takes it directly —
body, query, headers, cookies and uploaded files all work, and `/users/{id}` route parameters are
bridged automatically. No PSR-7 conversion or extra package needed.
```php
use Illuminate\Http\Request;
use OpenapiPhpDtoGenerator\Service\DtoDeserializer;
class UserController
{
public function store(Request $request)
{
// route params (/users/{id}), query, JSON body, headers, cookies and files all resolve.
$dto = (new DtoDeserializer())->deserialize($request, UserPostRequest::class);
// ... use $dto
}
}
```
## Validation Notes
A few behaviours worth knowing when validating against the schema:
- **`type: array` means a JSON array (list).** A value passes only when it is a PHP list (sequential integer keys from `0`). An associative array is treated as a JSON object, not an array — so a getter returning `array_filter(...)` (which may leave non-contiguous keys) should wrap the result in `array_values(...)`.
- **`oneOf` / `anyOf` pick 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.