ufo-tech / dto-transformer
The library provides tools for two-way transformation of DTO objects ⇄ arrays, respecting typing, contracts, and flexible transformation logic.
Requires
- php: >=8.4
- ext-intl: *
- phpdocumentor/reflection-docblock: ^5.6
- phpdocumentor/type-resolver: ^1.10
- symfony/cache-contracts: ^3.5
- symfony/serializer: ^7.2|^8.0
- symfony/validator: ^7.2|^8.0
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
ufo-tech/dto-transformer is a PHP 8.4+ library for converting DTO objects to arrays and hydrating DTO objects from arrays.
The current implementation is built around replaceable services:
DTOTransformeris the main facade/service.DTOFromArrayTransformerhydrates arrays into DTO objects.DTOToArrayTransformernormalizes DTO objects into arrays.HydratorInterfaceimplementations resolve typed values during hydration.NormalizerInterfaceimplementations normalize values during serialization.- Reflection metadata, DocBlocks, strict mode and type schemas are centralized and cacheable.
Installation
composer require ufo-tech/dto-transformer
Requirements:
- PHP
>=8.4 ext-intlsymfony/serializersymfony/validatorsymfony/cache-contractsphpdocumentor/reflection-docblockphpdocumentor/type-resolver
Quick Start
use Ufo\DTO\DTOTransformer; use Ufo\DTO\Factory\DefaultDTOTransformerFactory; final class UserDto { public function __construct( public string $name, public string $email, ) {} } DTOTransformer::boot(DefaultDTOTransformerFactory::default()->create()); $user = DTOTransformer::fromArray(UserDto::class, [ 'name' => 'Alex', 'email' => 'alex@example.com', ]); $array = DTOTransformer::toArray($user);
Boot the transformer once per process. DTOTransformer::fromArray(), DTOTransformer::toArray() and DTOTransformer::transformFromArray() are static and use the booted instance; before DTOTransformer::boot() they throw NotInitializeException. transformToArray() is an instance method and works on any created transformer.
Default Factories
Use DefaultDTOTransformerFactory::default() for the standard composition:
use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Ufo\DTO\Factory\DefaultDTOTransformerFactory; $cache = new FilesystemAdapter(namespace: 'dto_transformer'); $transformer = DefaultDTOTransformerFactory::default( persistentCache: $cache, )->create();
The default factory wires:
RuntimeReflectionCacheReflectionMetadataProviderStrictModeResolverReflectionTypeSchemaResolver- one class schema provider (
DefaultSchemaFactory::createClassSchemaProvider()) shared by both directions DefaultDTOTransformerFromArrayFactoryDefaultDTOTransformerToArrayFactoryDefaultNormalizerChainFactory
Transformers created by one factory share one transformer graph and its caches, so DTOTransformer and ServiceTransformer should come from the same factory:
$factory = DefaultDTOTransformerFactory::default(persistentCache: $cache); DTOTransformer::boot($factory->create()); ServiceTransformer::boot($factory->create(ServiceTransformer::class));
For custom wiring, instantiate DefaultDTOTransformerFactory with your own DTOTransformerFromArrayFactoryInterface and DTOTransformerToArrayFactoryInterface implementations.
Optional arguments of default():
persistentCache— a Symfony cache for reflection metadata and class schemas;runtimeCacheLimit— the in-process metadata cache size (default2048entries;0disables the limit and lets the cache grow without bound);validator— a SymfonyValidatorInterfaceforAttrAssertions(see below).
default() builds a new composition with empty in-process caches on every call. Create the transformer once and reuse it: boot it once or register it as a shared service.
Object To Array
$array = $transformer->transformToArray( dto: $dto, renameKey: ['name' => 'full_name'], asSmartArray: false, publicOnly: true, context: [], );
The default normalizer chain is created by DefaultNormalizerChainFactory:
new NormalizerChain([ new ScalarNormalizer(), new EnumNormalizer(), new DateTimeNormalizer($dateTimeValueConverter), new ArrayNormalizer(), new DTONormalizer($schemaProvider, new PropertyContextCache()), ]);
Normalizers implement:
use Ufo\DTO\Transformer\Context\NormalizationContext; use Ufo\DTO\Transformer\Normalizer\NormalizerInterface; interface NormalizerInterface { public function supports(mixed $data, NormalizationContext $context): bool; public function normalize(mixed $data, NormalizationContext $context): mixed; }
DTONormalizer reads DTO properties through metadata, applies renameKey, publicOnly, SerializationContext, smart-array output and recursively delegates nested values back to the chain.
Serialization Context
use DateTimeInterface; use Ufo\DTO\Attributes\SerializationContext; use Ufo\DTO\Transformer\Context\NormalizationContext; final class EventDto { public function __construct( #[SerializationContext([ NormalizationContext::DATE_FORMAT => DateTimeInterface::ATOM, NormalizationContext::DATE_TIMEZONE => 'UTC', ])] public DateTimeImmutable $createdAt, ) {} }
Default enum output:
- backed enums are normalized to their backing value;
- unit enums are normalized to the case name.
Default DateTime output format is Y-m-d H:i:s. Use SerializationContext or call-level context to change date format, timezone or timestamp output.
Array To Object
$dto = DTOTransformer::fromArray( UserDto::class, data: ['full_name' => 'Alex', 'email' => 'alex@example.com'], renameKey: ['name' => 'full_name'], namespaces: [], context: [], );
renameKey maps a property to the payload key it comes from. Renamed properties are hydrated exactly like the others: a missing renamed key falls back to the default value, #[Ignore] still applies, and renaming to null means the key is missing. Error messages about a missing required key name the payload key.
When the payload has no key for a property, the default value is used: a property default, a constructor parameter default (including objects such as new Address(), created per hydrated object), otherwise the hydration fails with Missing required key for property.
The default hydration chain is created by DefaultDTOTransformerFromArrayFactory:
new UnionHydrator(); new EnumHydrator(); new ScalarHydrator(); new ReflectionClassHydrator(); new ReflectionParameterHydrator(); new ReflectionPropertyHydrator(); new DateTimeHydrator($dateTimeValueConverter); new DTOHydrator($transformer, $customTransformers); new ArrayItemsHydrator(); new AdditionalHydrator(); new MixedHydrator();
Hydrators implement:
use Ufo\DTO\Transformer\Context\TransformationContext; use Ufo\DTO\Transformer\Hydrator\HydratorInterface; interface HydratorInterface { public function supports(array $schema): bool; public function resolve( array $schema, mixed $value, TransformationContext $context, ): mixed; }
Schemas are compiled once into SchemaNode trees: each node keeps the selected hydrator and the compiled items, additionalProperties and oneOf children. A hydrator of nested schemas can implement SchemaNodeHydratorInterface::resolveNode() to use these children instead of selecting a hydrator for every value; ArrayItemsHydrator, AdditionalHydrator and UnionHydrator do so.
ReflectionTypeSchemaResolver builds schemas from native PHP types, DocBlocks, AttrDTO, namespaces and strict-mode metadata. If no schema is available, the original value is kept.
Native Types
final class ProfileDto { public function __construct( public string $name, public int $age, public bool $active, ) {} }
Nested DTOs
final class OrderDto { public function __construct( public UserDto $user, ) {} } $order = DTOTransformer::fromArray(OrderDto::class, [ 'user' => [ 'name' => 'Alex', 'email' => 'alex@example.com', ], ]);
Collections
Collections can be described with DocBlocks:
final class TeamDto { /** * @param UserDto[] $users */ public function __construct( public array $users, ) {} }
Or with AttrDTO:
use Ufo\DTO\Attributes\AttrDTO; final class TeamDto { public function __construct( #[AttrDTO(UserDto::class, context: [ AttrDTO::C_COLLECTION => true, ])] public array $users, ) {} }
Smart Arrays
Smart arrays include the DTO class name in the payload:
$array = DTOTransformer::toArray($dto, asSmartArray: true); // [ // 'name' => 'Alex', // '$className' => App\Dto\UserDto::class, // ]
They can be restored with namespace aliases:
$dto = DTOTransformer::fromSmartArray($array, namespaces: [ DTOTransformer::DTO_NS_KEY => App\Dto::class, ]);
Namespaces
namespaces maps short class names from DocBlocks, smart arrays and fromArray('A|B') to classes or namespaces: ['Item' => App\Dto\Item::class], [DTOTransformer::DTO_NS_KEY => 'App\Dto']. Namespaces of the class being hydrated take precedence over the ones passed by the caller.
Pass namespaces from a finite set defined in code or configuration, never from the payload or per-request data. For classes whose types depend on namespaces (short class names in DocBlocks, non-class native object types), hydration schemas are cached per combination of namespace values: in-process caches are bounded, but every new combination is resolved again and adds entries to the persistent cache.
DateTime
Hydration supports DateTimeImmutable, DateTime, DateTimeInterface, strings, integer timestamps and float timestamps with microseconds.
final class EventDto { public function __construct( public DateTimeImmutable $createdAt, ) {} } $dto = DTOTransformer::fromArray(EventDto::class, [ 'createdAt' => '2026-05-08 14:30:00', ]);
Enums
Backed enums use tryFrom(). Integer-backed enums also accept numeric strings. Unit enums are resolved by case name, case-insensitively.
enum Status: string { case Active = 'active'; } final class UserDto { public function __construct( public Status $status, ) {} }
Attributes
AttrDTO
AttrDTO gives explicit denormalization hints:
use Ufo\DTO\Attributes\AttrDTO; final class WrapperDto { public function __construct( #[AttrDTO(UserDto::class, context: [ AttrDTO::C_COLLECTION => true, AttrDTO::C_STRICT => true, ])] public array $users, ) {} }
Supported context keys:
AttrDTO::C_COLLECTIONAttrDTO::C_STRICTAttrDTO::C_NSAttrDTO::C_RENAME_KEYSAttrDTO::C_TRANSFORMERAttrDTO::C_IS_ENUMAttrDTO::C_PROPERTY
A class passed in AttrDTO::C_TRANSFORMER must implement StaticDTOFromArrayTransformerInterface.
AttrAssertions
AttrAssertions validates the raw payload value with Symfony Validator constraints before hydration; a violation throws BadParamException. Constraints apply only when the key is present in the payload: a missing key falls back to the default value (or fails as a missing required key), an explicit null is validated as null:
use Symfony\Component\Validator\Constraints as Assert; use Ufo\DTO\Attributes\AttrAssertions; final class UserDto { public function __construct( #[AttrAssertions([new Assert\NotBlank(), new Assert\Length(min: 3)])] public string $name, ) {} }
Pass your application validator (with custom constraint validators and translations) through the factory:
$transformer = DefaultDTOTransformerFactory::default(validator: $validator)->create();
Without it, default() provides one lazy default validator (Validation::createValidator()) shared by all transformers of the factory; it is built on the first AttrAssertions check.
StrictMode
Strict mode turns hydration failures into BadParamException. Without strict mode, invalid nested values may be kept as-is.
use Ufo\DTO\Attributes\StrictMode; final class UserDto { public function __construct( #[StrictMode] public int $age, ) {} }
DocBlock strict mode is also supported:
/** * @strictMode true */ final class UserDto { }
Extensibility
Add custom object-to-array behavior with NormalizerInterface:
use Ufo\DTO\Transformer\Context\NormalizationContext; use Ufo\DTO\Transformer\Normalizer\NormalizerInterface; final class MoneyNormalizer implements NormalizerInterface { public function supports(mixed $data, NormalizationContext $context): bool { return $data instanceof Money; } public function normalize(mixed $data, NormalizationContext $context): string { return $data->currency . ' ' . number_format($data->amount / 100, 2); } }
Add custom array-to-object behavior with HydratorInterface:
use Ufo\DTO\Transformer\Context\TransformationContext; use Ufo\DTO\Transformer\Hydrator\HydratorInterface; final class UuidHydrator implements HydratorInterface { public function supports(array $schema): bool { return ($schema['format'] ?? null) === 'uuid'; } public function resolve(array $schema, mixed $value, TransformationContext $context): Uuid { return Uuid::fromString((string) $value); } }
For full control, provide your own factory implementations or construct DTOFromArrayTransformer / DTOToArrayTransformer with custom chains.
Optional interfaces for a custom DTOFromArrayTransformerInterface implementation:
DTOFromContextTransformerInterface— receives theTransformationContextobject for nested DTOs instead of a rebuilt array;DTOCandidateFilterInterface— cheaply rejects union candidates (fromArray('A|B'),A|Bproperties) whose hydration is guaranteed to fail, so they are not hydrated speculatively. Returntrueonly when failure is certain.
Symfony Integration
Install the package:
composer require ufo-tech/dto-transformer
Build the transformers with DefaultDTOTransformerFactory, the same composition as outside Symfony:
services: ufo.dto.cache: class: Symfony\Component\Cache\Adapter\FilesystemAdapter arguments: $namespace: 'dto_transformer' $directory: '%kernel.cache_dir%/dto-transformer' Ufo\DTO\Factory\DefaultDTOTransformerFactory: factory: [ 'Ufo\DTO\Factory\DefaultDTOTransformerFactory', 'default' ] arguments: $persistentCache: '@ufo.dto.cache' $validator: '@validator' Ufo\DTO\DTOTransformer: public: true factory: [ '@Ufo\DTO\Factory\DefaultDTOTransformerFactory', 'create' ] Ufo\DTO\ServiceTransformer: public: true factory: [ '@Ufo\DTO\Factory\DefaultDTOTransformerFactory', 'create' ] arguments: [ 'Ufo\DTO\ServiceTransformer' ]
$persistentCacheis optional: drop the argument and theufo.dto.cacheservice to keep the cache in memory only. Clear the persistent cache on deploy.$validator: '@validator'makesAttrAssertionsuse the application validator (requiresframework.validation). Without it the factory creates its own default validator.- One factory service means
DTOTransformerandServiceTransformershare one object graph and its caches. DropServiceTransformerif you do not use it.
public: true is required if the transformer is accessed through:
$container->get(DTOTransformer::class)
or inside Bundle::boot(). The static methods (fromArray(), toArray()) work only after the container service is booted once, for example in Bundle::boot():
if (!DTOTransformer::isInitialized()) { DTOTransformer::boot($this->container->get(DTOTransformer::class)); } if (!ServiceTransformer::isInitialized()) { ServiceTransformer::boot($this->container->get(ServiceTransformer::class)); }
Example with an injected transformer — call the instance methods:
use Ufo\DTO\DTOTransformer; final class UserService { public function __construct( private readonly DTOTransformer $transformer, ) { } public function handle(array $payload): array { $user = $this->transformer->transformFromArray(UserDTO::class, $payload); return $this->transformer->transformToArray($user); } }
The static DTOTransformer::fromArray() / toArray() always use the booted instance, not the injected one.
Performance
Recommendations:
- reuse one
DTOTransformerservice instance:DefaultDTOTransformerFactory::default()builds new caches on every call; - create
DTOTransformerandServiceTransformerfrom one factory: they share class schemas; - pass a Symfony cache adapter to
DefaultDTOTransformerFactory::default(); - prefer native types where possible: properties with native types only do not depend on caller namespaces;
- use DocBlocks for collections and unions where native types are not enough;
- pass namespaces from a fixed set (see Namespaces);
- avoid rebuilding custom hydrator or normalizer chains per request.
In-process caches are bounded, so long-running workers keep a stable memory footprint: class schemas hold up to 3000 classes, other per-class caches up to 1024, the metadata cache holds runtimeCacheLimit entries.
Testing
docker exec php_dto php vendor/bin/phpunit
Benchmarks (time, CPU, disk I/O, allocated and retained memory, memory growth):
docker exec php_dto composer bench # warm, table docker exec php_dto composer bench -- --mode=reset # fresh transformer graph before each sample docker exec php_dto composer bench -- --mode=cold-process # first call in a new PHP process docker exec php_dto composer bench -- --mode=cold-persistent # same, with a filled persistent cache docker exec php_dto composer bench -- --mode=cold-persistent-empty # same, persistent cache empty (first run after deploy) docker exec php_dto composer bench -- --mode=growth # memory of a long-running process docker exec php_dto composer bench -- --filter=fromArray --note="what and why" docker exec php_dto php benchmarks/compare.php benchmarks/results/<run-a> benchmarks/results/<run-b>
Every run is saved to benchmarks/results/<date>_<mode>_php<version>_opcache-…_jit-…_xdebug-…_<commit>[-dirty]/
with report.md (command, note, environment, tables) and results.json (raw samples). The folder is not tracked by
git; --no-save skips saving.
Methodology: the ufo-tech/benchmark-author skill and the ufo-tech/php-benchmarks rule of the UFO Tech catalog.
I/O columns need Linux (/proc/self/io); elsewhere the report lists them under "Not measured".
License
MIT