hprotravel/symfony-dto-bundle
A Symfony bundle from HproTravel for working with data transfer objects (DTOs), helping structure request and response data, improve type safety, and keep application layers clean. It integrates DTO handling into Symfony projects.
This is a private Bitbucket package (hprotravel/*), so the first hurdle is access, not code. Wire it up before you read a line of its source.
1. Teach Composer where the repo lives in the root composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "[email protected]:hprotravel/symfony-dto-bundle.git"
}
]
}
Use SSH keys or a Bitbucket app password (with repository:read) configured in auth.json / COMPOSER_AUTH. CI runners need the same credentials as your laptop — this is the single most common first-day blocker.
2. Require and register it
composer require hprotravel/symfony-dto-bundle
// config/bundles.php
return [
// ...
Hprotravel\SymfonyDtoBundle\SymfonyDtoBundle::class => ['all' => true],
];
If the bundle ships a Flex recipe you'll get config and services automatically; if not, check for a DependencyInjection/Configuration.php and add config/packages/symfony_dto.yaml by hand.
3. Read these files first, in this order
README.md — the only authoritative source for the public API; treat the rest of this doc as pattern guidance, not API reference.src/DependencyInjection/Configuration.php — the config tree tells you what's actually tunable (naming strategy, validation toggles, cache, etc.).src/ for the DTO base class/interface and any PHP attributes (e.g. mapping/ignore attributes).ArgumentResolver / Controller listener — this is where "magic" hydration from Request happens, and where most surprises come from.4. First use case: kill $request->request->all() in a controller
Replace untyped array access with a validated, typed DTO:
#[Route('/orders', methods: ['POST'])]
public function create(CreateOrderDto $dto): JsonResponse
{
// $dto is already hydrated from the request body and validated
$order = $this->orderService->place($dto);
return $this->json($order, 201);
}
If the bundle resolves controller arguments, this just works once the DTO is registered. If it doesn't, fall back to explicit construction via a factory/resolver service (see below). Start with one endpoint, confirm validation and error shape, then roll out.
Controller argument resolution (preferred) Type-hint the DTO on the action. The bundle typically resolves it from the request body/query, runs denormalization, and validates. Keep controllers thin: DTO in, domain service call, response DTO or entity out.
Manual hydration in services and queue handlers Don't rely on the HTTP layer for non-HTTP entry points (messenger consumers, CLI, webhooks). Inject the bundle's factory/denormalizer service and build DTOs explicitly:
$dto = $this->dtoFactory->create(CreateOrderDto::class, $payloadArray);
Wrap this in your own small PayloadToDto helper if you find yourself repeating it — it gives you one place to enforce validation groups and error handling across jobs.
Constructor-promoted, readonly DTOs Prefer immutable DTOs with promoted constructor properties. They compose better with validation attributes, are easier to test, and avoid "half-populated object" bugs during hydration. Reserve setter-based/mutable DTOs for partial-update (PATCH) flows.
Nested DTOs and collections
Model AddressDto, OrderLineDto[] as real DTO classes rather than associative arrays. This is where a DTO bundle earns its keep: nested denormalization plus #[Valid] on the parent property gives you recursive validation for free. For collections, type-hint the array docblock (@var OrderLineDto[]) so the serializer can pick the element type.
DTOs as command/query objects
The same DTOs are useful one layer down. Pass CreateOrderDto into a command bus or service method instead of an array or a partially-built entity. This keeps validation rules co-located with the shape of the input and makes service signatures self-documenting.
Response DTOs and serialization Use dedicated response DTOs (not entities) with serialization groups so you control the API surface. Map entity → response DTO explicitly in a small assembler/mapper method; don't expose entities directly. This decouples your JSON contract from Doctrine mappings and lets you evolve the schema independently.
Explicit entity mapping
Keep a hand-written toEntity() / assembler step between DTO and entity. Avoid generic "map all properties with matching names" helpers on write paths — see the mass-assignment note below.
Where to put the DTO
Co-locate DTOs with the feature that owns them (src/Order/Dto/, alongside the controller/service), not in a global Dto/ dump. It keeps ownership obvious and prevents the namespace sprawl that makes large Symfony apps hard to navigate.
dtoFactory, attribute names, and config keys in this document are illustrative. The bundle's README and Configuration.php are the source of truth; grep src/ for ArgumentResolver, Denormalizer, and attribute classes before writing patterns against it.snake_case vs. property camelCase) silently yields nulls — configure the name converter and verify with one real request.Default group. If you need create vs. update rule sets, check how the bundle lets you select a group (attribute, route default, or manual validation) — don't assume it's automatic.#[Valid]. A nested DTO without #[Valid] on the parent property passes validation even when the child is invalid. This bites hardest on collections.\DateTimeImmutable, enums, and int/float casting depend on the denormalizer config. Test enum-backed DTOs and date fields explicitly; silent coercion failures surface as null properties, not exceptions.isAdmin, ownerId). Always map field-by-field in an assembler.bin/console cache:clear. If hydration "ignores" a new property, clear cache before you debug the code.dump($dto) in the controller first; if it's empty, the resolver/denormalizer — not your DTO — is the problem. Check the Symfony profiler's request/validator panels and the validator channel in logs. Add a smoke test per DTO that feeds a realistic payload and asserts every property.kernel.exception subscriber that renders ValidationFailedException as a 422 with field-level errors, otherwise you'll get generic 500s.serializer.normalizer tags and priority), custom type mappers for value objects, and mapping callbacks/events. If the bundle doesn't cover a case, extend the serializer rather than patching the bundle.How can I help you explore Laravel packages today?