Usage Guide

March 25, 2026 · View on GitHub

Controller Integration

From Request Data

use App\Dto\UserDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

class UserController extends AbstractController
{
    #[Route('/users', methods: ['POST'])]
    public function store(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $dto = new UserDto($data);

        return $this->json($dto->toArray());
    }

    #[Route('/users/{id}', methods: ['PATCH'])]
    public function update(Request $request, int $id): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        // Use ignoreMissing for partial updates
        $dto = UserDto::createFromArray($data, ignoreMissing: true);

        return $this->json($dto->toArray());
    }
}

DTO Mapping Helpers

use App\Dto\UserDto;
use PhpCollective\SymfonyDto\Mapper\DtoMapper;

$dto = DtoMapper::fromArray(['name' => 'Mark'], UserDto::class);

$dtos = DtoMapper::fromIterable($rows, UserDto::class);
$collection = DtoMapper::fromCollection($doctrineCollection, UserDto::class);

// Generic pagination wrapper
$pagination = DtoMapper::fromPaginated(
    items: $pageItems,
    total: $total,
    perPage: $perPage,
    page: $page,
    dtoClass: UserDto::class,
);

JSON Response Helper

use PhpCollective\SymfonyDto\Http\DtoJsonResponse;

return DtoJsonResponse::fromDto($dto);
// or
return DtoJsonResponse::fromCollection($dtos);

Automatic DTO Resolution

Enable the value resolver in config (enable_value_resolver: true) and use #[MapRequestDto]:

use App\Dto\UserDto;
use PhpCollective\SymfonyDto\Attribute\MapRequestDto;
use Symfony\Component\HttpFoundation\JsonResponse;

class UserController extends AbstractController
{
    #[Route('/users', methods: ['POST'])]
    public function store(#[MapRequestDto] UserDto $dto): JsonResponse
    {
        return $this->json($dto->toArray());
    }
}

Choose a specific source if needed:

#[MapRequestDto(source: MapRequestDto::SOURCE_QUERY)]

From Doctrine Entities

#[Route('/users/{id}', methods: ['GET'])]
public function show(User $user): JsonResponse
{
    // Convert entity to DTO
    $dto = new UserDto([
        'id' => $user->getId(),
        'name' => $user->getName(),
        'email' => $user->getEmail(),
    ]);

    return $this->json($dto->toArray());
}

Collections

The PhpCollectiveDtoBundle automatically registers Doctrine's ArrayCollection as the collection type for DTO collection fields. No manual setup is needed.

Defining Collection Fields

In your DTO config, use the [] suffix to define collection fields:

<dto name="User">
    <field name="id" type="int"/>
    <field name="name" type="string"/>
    <field name="roles" type="Role[]"/>
    <field name="tags" type="string[]"/>
</dto>

After generating, collection fields will use Doctrine's ArrayCollection class:

$user = new UserDto([
    'id' => 1,
    'name' => 'John',
    'roles' => [
        ['name' => 'admin', 'active' => true],
        ['name' => 'editor', 'active' => false],
    ],
    'tags' => ['vip', 'premium'],
]);

// Doctrine Collection methods are available
$activeRoles = $user->getRoles()->filter(fn (RoleDto $role) => $role->getActive());
$firstRole = $user->getRoles()->first();
$tagCount = $user->getTags()->count();

What the Adapter Does

The bundle performs two registrations on boot:

  1. Runtime collection factoryDto::setCollectionFactory(fn (array $items) => new ArrayCollection($items)) ensures that collection fields are hydrated as Doctrine\Common\Collections\ArrayCollection instances at runtime.
  2. Code generation adapterCollectionAdapterRegistry::register(new DoctrineCollectionAdapter()) ensures that generated DTO code uses new ArrayCollection([]) and ->add() for collection initialization and appending.

Both are required: the factory handles runtime hydration from arrays, while the adapter controls the generated PHP code.

Validation

Use Symfony's Validator component:

use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Constraints as Assert;

class UserController extends AbstractController
{
    #[Route('/users', methods: ['POST'])]
    public function store(Request $request, ValidatorInterface $validator): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $dto = new UserDto($data);

        $violations = $validator->validate($dto->toArray(), new Assert\Collection([
            'name' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
            'email' => [new Assert\NotBlank(), new Assert\Email()],
        ]));

        if (count($violations) > 0) {
            $errors = [];
            foreach ($violations as $violation) {
                $errors[$violation->getPropertyPath()] = $violation->getMessage();
            }
            return $this->json(['errors' => $errors], 422);
        }

        return $this->json($dto->toArray());
    }
}

Validation Bridge

If your DTOs use the built-in validation rules from php-collective/dto (e.g. required, minLength, maxLength, min, max, pattern), you can automatically convert them to Symfony Validator constraints:

composer require symfony/validator
use PhpCollective\SymfonyDto\Validation\DtoConstraintBuilder;
use Symfony\Component\Validator\Validation;

$dto = new UserDto();
$constraint = DtoConstraintBuilder::fromDto($dto);

$validator = Validation::createValidator();
$violations = $validator->validate($data, $constraint);

if (count($violations) > 0) {
    // Handle validation errors
}

The bridge maps DTO rules to Symfony constraints:

DTO RuleSymfony Constraint
requiredAssert\NotBlank (wrapped in Assert\Required)
minLength / maxLengthAssert\Length(min:, max:)
min / maxAssert\Range(min:, max:)
patternAssert\Regex(pattern:)

Optional fields are wrapped in Assert\Optional, required fields in Assert\Required. Extra fields not defined in the DTO are allowed by default.

Service Layer Pattern

// src/Service/UserService.php
use App\Dto\UserDto;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;

class UserService
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function createUser(UserDto $dto): User
    {
        $user = new User();
        $user->setName($dto->getName());
        $user->setEmail($dto->getEmail());

        $this->em->persist($user);
        $this->em->flush();

        return $user;
    }
}

Doctrine Integration

Query Results to DTOs

// src/Repository/UserRepository.php
use App\Dto\UserSummaryDto;

class UserRepository extends ServiceEntityRepository
{
    /**
     * @return UserSummaryDto[]
     */
    public function findActiveSummaries(): array
    {
        $rows = $this->createQueryBuilder('u')
            ->select('u.id', 'u.name', 'u.email')
            ->where('u.active = true')
            ->getQuery()
            ->getArrayResult();

        return array_map(fn($row) => new UserSummaryDto($row), $rows);
    }
}

Nested DTOs

When your DTO has nested DTO fields:

// config/dto.xml
<dto name="Order">
    <field name="id" type="int"/>
    <field name="customer" type="Customer"/>
    <field name="items" type="OrderItem[]"/>
</dto>

// Usage
$order = new OrderDto([
    'id' => 1,
    'customer' => ['name' => 'John', 'email' => 'john@example.com'],
    'items' => [
        ['product' => 'Widget', 'quantity' => 2],
        ['product' => 'Gadget', 'quantity' => 1],
    ],
]);

// Access nested data
$customerName = $order->getCustomer()->getName();

AutoMapper Bridge

For automatic object-to-DTO mapping without manual field-by-field code, install the optional jolicode/automapper integration:

composer require jolicode/automapper

Supports AutoMapper 8.x (PHP 8.2+) and 10.x (PHP 8.4+).

The bundle automatically registers the DtoAutoMapper service when AutoMapper is available.

Entity to DTO

use App\Dto\UserDto;
use App\Entity\User;
use PhpCollective\SymfonyDto\AutoMapper\DtoAutoMapperInterface;

class UserController extends AbstractController
{
    public function __construct(
        private DtoAutoMapperInterface $dtoMapper,
    ) {}

    #[Route('/users/{id}', methods: ['GET'])]
    public function show(User $user): JsonResponse
    {
        // Automatic mapping - no manual field assignment
        $dto = $this->dtoMapper->toDto($user, UserDto::class);

        return $this->json($dto->toArray());
    }

    #[Route('/users', methods: ['GET'])]
    public function index(UserRepository $repository): JsonResponse
    {
        $users = $repository->findAll();

        // Map entire collection
        $dtos = $this->dtoMapper->toDtoCollection($users, UserDto::class);

        return $this->json(array_map(fn($dto) => $dto->toArray(), $dtos));
    }
}

DTO to Entity (Updates)

#[Route('/users/{id}', methods: ['PUT'])]
public function update(
    #[MapRequestDto] UserDto $dto,
    User $user,
    EntityManagerInterface $em,
): JsonResponse {
    // Update existing entity from DTO
    $this->dtoMapper->fromDto($dto, $user);

    $em->flush();

    return $this->json($this->dtoMapper->toDto($user, UserDto::class)->toArray());
}

DTO to New Entity

#[Route('/users', methods: ['POST'])]
public function store(
    #[MapRequestDto] UserDto $dto,
    EntityManagerInterface $em,
): JsonResponse {
    // Create new entity from DTO
    $user = $this->dtoMapper->fromDtoToNew($dto, User::class);

    $em->persist($user);
    $em->flush();

    return $this->json(
        $this->dtoMapper->toDto($user, UserDto::class)->toArray(),
        201,
    );
}

Mapping Context

Pass additional context to control mapping behavior:

$dto = $this->dtoMapper->toDto($user, UserDto::class, [
    'groups' => ['read'],
]);

Configuration

The AutoMapper bridge is enabled by default when the package is installed. To disable it:

# config/packages/php_collective_dto.yaml
php_collective_dto:
    enable_automapper: false

When to Use Each Approach

ScenarioRecommended Approach
Array data (API request, DB row)DtoMapper::fromArray() or new UserDto($data)
Entity → DTO$dtoMapper->toDto($entity, UserDto::class)
DTO → existing Entity$dtoMapper->fromDto($dto, $entity)
DTO → new Entity$dtoMapper->fromDtoToNew($dto, User::class)
Multiple entities → DTOs$dtoMapper->toDtoCollection($entities, UserDto::class)

The static DtoMapper class handles array-based hydration (the core strength of generated DTOs), while DtoAutoMapper handles object-to-object transformations using AutoMapper's code generation.

Further Reading

See the main php-collective/dto documentation for:

  • DTO configuration options
  • Type support
  • Custom casters
  • Advanced patterns