danilovl / object-dto-mapper
Mapper that converts an object to a DTO.
Installs: 3
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Type:package
Requires
- php: ^8.3
- symfony/property-access: ^7.0
- symfony/serializer: ^7.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.86
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.0.1
- phpstan/phpstan-symfony: ^2.0.0
- phpunit/phpunit: ^10.2
README
Object to DTO mapper
Description
A lightweight mapper that converts an object (Entity/model) to a DTO based on the DTO constructor signature.
Mapping is controlled via the #[Map] attribute on DTO properties (constructor-promoted properties are supported).
It supports conditional mapping (if), value transformations (transform), collections and nested objects, as well as filtering by Symfony Serializer Groups.
Key features
- Map by matching a property name or from another path via Map(source)
- Conditional mapping: bool, callable, or container service (Map(if) / Map(ifContainer))
- Transformations: callable/class or container service (Map(transform)/Map(transformContainer))
- Auto-format DateTimeInterface -> ATOM if no transform is specified
- Map collections into target DTO via Map(target)
- Nested objects are mapped recursively based on the constructor parameter type
- Filter DTO properties by groups (allowGroups/ignoreGroups) using Symfony\Component\Serializer\Attribute\Groups on DTO
Requirements
- PHP 8.3+
- symfony/property-access 7.0+
- symfony/serializer 7.0+
Installation
Install the package via Composer:
composer require danilovl/object-dto-mapper
Quick start
Example: we have an entity and a DTO.
<?php declare(strict_types=1) namespace App\Entity; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[ORM\Table(name: 'user')] final class UserEntity { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: Types::INTEGER)] public int $id; #[ORM\Column(type: Types::STRING, length: 255)] public string $name; #[ORM\Column(type: Types::STRING, length: 255)] public string $gender; #[ORM\Column(type: Types::STRING, length: 255)] public string $username; #[ORM\Column(type: Types::STRING, length: 255)] public string $password; #[ORM\Column(type: Types::INTEGER)] public int $age; #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] public ?DateTimeImmutable $registeredAt = null; #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] public ?string $email = null; #[ORM\Column(type: Types::SIMPLE_ARRAY)] public array $roles; }
<?php declare(strict_types=1) namespace App\DTO final class UserDto { public function __construct( public string $name, public int $age, public ?string $registeredAt, public ?string $email, ) {} }
<?php declare(strict_types=1); namespace App\Controller; use App\Entity\UserEntity; use App\Dto\UserDto; use Danilovl\ObjectDtoMapper\Service\ObjectToDtoMapperInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; final class UserController { public function __construct(private readonly ObjectToDtoMapperInterface $mapper) {} #[Route('/api/users/{id}', name: 'api_users', methods: ['GET'])] public function show(UserEntity $userEntity): JsonResponse { $dto = $this->mapper->map($userEntity, UserDto::class); return new JsonResponse($dto); } }
#[Map] attribute
The attribute is applied to DTO properties (typically constructor-promoted properties).
Signature:
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] final class Map { public function __construct( public ?string $target = null, public ?string $source = null, public mixed $if = null, // bool | callable | string (see below) public ?string $ifContainer = null, // service id in container public mixed $transform = null, // callable | class-string | [class-string, method] public ?string $transformContainer = null // transformer service id ) { /* guard against mutually exclusive options */ } }
Configuration constraints:
- You cannot set both if and ifContainer — MappingIfException will be thrown
- You cannot set both transform and transformContainer — MappingTransformException will be thrown
Parameters
- source: path/property name in the source object (PropertyAccess is supported, e.g. "profile.email" or "["email"]")
- target: target DTO class for collection items. If the source value is iterable and a target is provided, each item will be mapped to that DTO
- if: mapping condition. Allowed forms:
- true/false — simply include/exclude mapping
- callable: static callback [Class, 'method'] or invokable class name. Various signatures supported: (value), (value, source), (value, source, target). Note: due to PHP attribute limitations, closures/objects cannot be passed directly in attributes
- string: class name of an invokable class (must implement __invoke). Must return bool, otherwise MappingIfException is thrown
- ifContainer: string service id from the container. The service must be invokable (implement MapIfCallableInterface) and return bool
- transform: value transformation. Supported forms:
- callable (class-string with __invoke), or array [ClassName, 'method']
- Note: multiple transforms chain via array is not supported; use a single transformer that composes steps
- transformContainer: string transformer service id from the container (must be invokable: implement MapTransformCallableInterface)
Defaults and errors
- If a source property is not readable, the default value from the DTO constructor is used, otherwise null
- If the if/ifContainer condition denies mapping:
- use the default from constructor; if none — null for a nullable type
- if the property is non-nullable and there is no default — MappingIfException is thrown
- If after all transforms the value is null, and the property is non-nullable — RuntimeException is thrown
- DateTimeInterface without transform is formatted to DateTimeInterface::ATOM
- Errors inside if/transform handlers result in MappingIfException/MappingTransformException with detailed messages
Usage examples and combinations
1) Map from another field (source)
final class DtoA { public function __construct( #[Map(source: 'altName')] public string $name, public int $age, public ?string $email, ) {} }
2) Conditional mapping: simple flags
final class DtoIfBool { public function __construct( #[Map(if: true)] public string $name, #[Map(if: false)] public ?int $age = null, public ?string $email = null, ) {} }
3) Conditional mapping: callable
final class DtoIfCallable { public function __construct( public string $name, public int $age, #[Map(if: [EmailGuard::class, 'allowIfNotEmpty'])] public ?string $email = null, ) {} } final class EmailGuard { public static function allowIfNotEmpty(?string $value): bool { return !empty($value); } }
Other accepted forms: 'ClassName' (if the class is invokable) or [ClassName, 'method']. The return value must be bool. Note: closures and object instances cannot be used directly inside attributes.
4) Conditional mapping via container (ifContainer)
final class DtoIfContainer { public function __construct( #[Map(ifContainer: EmailGuardService::class)] public ?string $email = null, public string $name, public int $age, ) {} } final class EmailGuardService implements \Danilovl\ObjectDtoMapper\Attribute\MapIfCallableInterface { public function __invoke(mixed $value, object $source, string $target): bool { return !empty($value); } }
5) Single value transform
final class DtoTransformOne { public function __construct( #[Map(transform: Uppercase::class)] public string $name, public int $age, public ?string $email, ) {} } final class Uppercase { public function __invoke(string $v): string { return mb_strtoupper($v); } }
6) Compose multiple steps inside a single transformer
final class DtoTransformComposed { public function __construct( #[Map(transform: TrimUppercase::class)] public ?string $email = null, public string $name, public int $age, ) {} } final class TrimUppercase { public function __invoke(?string $v): ?string { if ($v === null) { return null; } $v = trim($v); return mb_strtoupper($v); } }
7) Transform using a service from container (transformContainer)
final class DtoTransformService { public function __construct( #[Map(transformContainer: UppercaseService::class)] public string $name, public int $age, public ?string $email, ) {} } final class UppercaseService implements \Danilovl\ObjectDtoMapper\Attribute\MapTransformCallableInterface { public function __invoke(mixed $value, object $source, string $target): mixed { return mb_strtoupper((string) $value); } }
8) Collections + target
final class EntityList { public function __construct(public array $items) {} } final class ItemEntity { public function __construct(public string $title) {} } final class ItemDto { public function __construct(public string $title) {} } final class ListDto { public function __construct( #[Map(target: ItemDto::class)] public array $items, ) {} } $mapper->map (new EntityList([new ItemEntity('A'), new ItemEntity('B')]), ListDto::class );
9) Nested objects
If the DTO constructor parameter type is an object, and the source value is an object, it will be mapped recursively:
final class ProfileEntity { public function __construct(public string $city) {} } final class ProfileDto { public function __construct(public string $city) {} } final class UserEntity { public function __construct(public string $name, public ProfileEntity $profile) {} } final class UserDto { public function __construct(public string $name, public ProfileDto $profile) {} }
10) Groups (allowGroups/ignoreGroups)
Mark DTO properties with Groups and then pass allowGroups and/or ignoreGroups to ObjectToDtoMapper::map:
final class DtoWithGroups { public function __construct( #[Groups(['public'])] public string $name, #[Groups(['private'])] public int $age, ) {} } $dto = $mapper->map($entity, DtoWithGroups::class, allowGroups: ['public'], ignoreGroups: []);
If a property is filtered out and it is non-nullable without a default value — an exception will be thrown.
Integration with container
- For Map(ifContainer) use services implementing MapIfCallableInterface
- For Map(transformContainer) use services implementing MapTransformCallableInterface
- You can also pass class names (if invokable) or [Class, 'method'] for non-container handlers; the mapper will instantiate classes directly (it does not fetch them from the container)
Symfony service factory example
Below is a minimal example of how to wire ObjectToDtoMapper in a Symfony application and how to define callable services for Map(ifContainer) and Map(transformContainer).
services: # 1) Register the mapper and pass the container to it Danilovl\ObjectDtoMapper\Service\ObjectToDtoMapper: arguments: $container: '@service_container' # 2) Define a service usable in Map(ifContainer) App\ObjectMapper\ShouldIncludeEmailIf: class: App\ObjectMapper\ShouldIncludeEmailIf # 3) Define a service usable in Map(transformContainer) App\ObjectMapper\UppercaseTransform: class: App\ObjectMapper\UppercaseTransform
Then implement the services:
<?php declare(strict_types=1); namespace App\ObjectMapper; use Danilovl\ObjectDtoMapper\Attribute\MapIfCallableInterface;use Danilovl\ObjectDtoMapper\Attribute\MapTransformCallableInterface; final class ShouldIncludeEmailIf implements MapIfCallableInterface { public function __invoke(mixed $value, object $source, string $target): bool { return !empty($value); } } final class UppercaseTransform implements MapTransformCallableInterface { public function __invoke(mixed $value, object $source, string $target): mixed { return $value === null ? null : mb_strtoupper((string) $value); } }
Use them in your DTO:
<?php declare(strict_types=1); namespace App\ObjectMapper use Danilovl\ObjectDtoMapper\Attribute\Map; final class UserDto { public function __construct( #[Map(ifContainer: App\ObjectMapper\ShouldIncludeEmailIf::class)] public ?string $email, #[Map(transformContainer: App\ObjectMapper\UppercaseTransform::class)] public string $name, ) {} }
License
This package is open-sourced software licensed under the MIT.