Anyone who has worked with Doctrine or any other ORM knows the pattern: an entity maps a database table with all its fields, relations, and internal state. The temptation to serialize that entity directly as a JSON response is obvious — it already contains all the data.
This works right up until it breaks. And it breaks from at least three directions at once.
The Problem: Exposing Entities Directly
A typical example — a Doctrine entity for products:
#[ORM\Entity]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $price;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $purchasePrice;
#[ORM\Column(type: 'integer')]
private int $stock;
#[ORM\ManyToOne(targetEntity: Supplier::class)]
private Supplier $supplier;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
// Getters, setters...
}
Writing return $this->json($product) in the controller delivers to the client:
- The purchase price (
purchasePrice) — business information no customer should see - The complete Supplier object with contact details, terms, and internal ID
- The stock count — potentially confidential depending on the business model
- Internal timestamps and IDs that are irrelevant to the client
Three concrete problems arise:
1. Security. Internal fields end up at the client uncontrolled. Adding #[Ignore] on the serializer is a crutch — someone will forget it on the next field.
2. Coupling. Every schema change on the entity automatically changes the API response. Renaming a column, removing a field, restructuring a relation — the client feels it immediately. An internal refactor becomes a breaking change.
3. Representation. The entity stores data the way the database needs it. The client needs it differently: prices formatted, dates as ISO strings, nested objects reduced to essentials. That transformation does not belong in the entity.
What Is a DTO?
A Data Transfer Object is a simple class whose sole purpose is carrying data between two layers. No business logic, no database binding, no side effects — just typed fields.
class ProductDetailDto
{
public int $id;
public string $name;
public string $price;
public string $currency;
public bool $inStock;
public string $createdAt;
}
The key point: the DTO defines exactly the fields the client should see — no more, no less. The purchase price? Missing. The supplier? Reduced to a name or omitted entirely. The stock count? Converted to a boolean inStock.
Input DTOs vs. Output DTOs
DTOs come in two directions:
| Direction | Purpose | Example |
|---|---|---|
| Output DTO | Entity → Client (API response) | ProductDetailDto, OrderListDto |
| Input DTO | Client → Application (request body) | CreateProductDto, UpdateOrderDto |
Output DTO: Converting an Entity to a Controlled Response
class ProductDetailDto
{
public int $id;
public string $name;
public string $price;
public string $currency;
public bool $inStock;
public string $supplierName;
public string $createdAt;
public static function fromEntity(Product $product): self
{
$dto = new self();
$dto->id = $product->getId();
$dto->name = $product->getName();
$dto->price = number_format((float) $product->getPrice(), 2, '.', '');
$dto->currency = 'EUR';
$dto->inStock = $product->getStock() > 0;
$dto->supplierName = $product->getSupplier()->getName();
$dto->createdAt = $product->getCreatedAt()->format(\DateTimeInterface::ATOM);
return $dto;
}
}
The static fromEntity() method is the most common mapping pattern. The transformation happens in exactly one place, and the DTO controls which data goes out.
Input DTO: Receiving Validated Input Data
use Symfony\Component\Validator\Constraints as Assert;
class CreateProductDto
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $name = '';
#[Assert\NotBlank]
#[Assert\Positive]
public string $price = '';
/** @var string[] */
public array $tags = [];
}
In the controller:
#[Route('/api/products', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$dto = $this->serializer->deserialize(
$request->getContent(),
CreateProductDto::class,
'json'
);
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
return $this->json($errors, Response::HTTP_BAD_REQUEST);
}
$product = $this->productService->createFromDto($dto);
return $this->json(
ProductDetailDto::fromEntity($product),
Response::HTTP_CREATED
);
}
The interplay: the request is deserialized into an input DTO and validated. The service creates an entity from it. The response delivers an output DTO back. At no point does the client see the entity directly.
Mapping Strategies
There are three common ways to convert entities into DTOs:
1. Static Factory Method (Recommended for Most Cases)
class OrderDto
{
// ...fields...
public static function fromEntity(Order $order): self
{
$dto = new self();
$dto->id = $order->getId();
$dto->total = $order->getTotal();
$dto->items = array_map(
fn (OrderItem $item) => OrderItemDto::fromEntity($item),
$order->getItems()->toArray()
);
return $dto;
}
}
Advantage: No additional service needed. The mapping lives where the target structure is defined. Nested DTOs are recursively mapped via their own fromEntity() method.
2. Dedicated Mapper Service
class ProductMapper
{
public function toDetailDto(Product $product): ProductDetailDto
{
$dto = new ProductDetailDto();
$dto->id = $product->getId();
$dto->name = $product->getName();
$dto->price = $this->priceFormatter->format($product->getPrice());
// ...
return $dto;
}
public function toEntity(CreateProductDto $dto): Product
{
$product = new Product();
$product->setName($dto->name);
$product->setPrice($dto->price);
// ...
return $product;
}
}
When useful: When mapping requires external services (price formatting, URL generation, authorization checks). The factory method on the DTO cannot inject dependencies — a dedicated service can.
3. Constructor Mapping
readonly class ProductDetailDto
{
public function __construct(
public int $id,
public string $name,
public string $price,
public bool $inStock,
) {
}
}
$dto = new ProductDetailDto(
id: $product->getId(),
name: $product->getName(),
price: number_format((float) $product->getPrice(), 2),
inStock: $product->getStock() > 0,
);
Advantage: Immutable DTOs with readonly properties. No invalid intermediate state possible — all fields must be set at creation time.
DTOs with readonly and PHP 8.2+
Since PHP 8.2, DTOs can be written particularly cleanly as readonly classes:
readonly class CustomerDto
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $memberSince,
) {
}
public static function fromEntity(Customer $customer): self
{
return new self(
id: $customer->getId(),
name: $customer->getFullName(),
email: $customer->getEmail(),
memberSince: $customer->getCreatedAt()->format('Y-m-d'),
);
}
}
readonly at the class level makes every property automatically readonly. No accidental overwriting, no setter needed, no mutable state. For output DTOs, this is the ideal approach.
Common Mistakes
Using the DTO as an entity wrapper. If the DTO has exactly the same fields as the entity, it is not a DTO — it is boilerplate. DTOs only serve a purpose when they transform the data structure for the consumer.
Business logic in the DTO. A DTO does not calculate, does not validate business rules, and does not call services. Formatting (date to string, price with currency) is acceptable — anything beyond that belongs in the service.
Referencing the entity in the DTO. The DTO should not have a Product property — it should contain the relevant fields as primitive types. Otherwise the decoupling is undermined.
Introducing too many DTOs at once. Not every entity needs a DTO immediately. The effort pays off where the API boundary must be deliberately controlled — typically at REST endpoints and external interfaces.
When DTOs Are Unnecessary
Not every project needs DTOs. For internal admin tools, prototypes, or simple CRUD applications without a public API, direct entity serialization may suffice — provided sensitive fields are excluded via serializer groups.
The rule of thumb: as soon as a public API exists that is consumed by a separate client (SPA, mobile app, third party), DTOs are no longer optional — they are mandatory.
Conclusion
DTOs solve a concrete problem: the uncontrolled coupling between the database layer and the outside world. They cost a few lines of code per endpoint and prevent internal schema changes from jeopardizing API stability or leaking sensitive data.
My preferred setup: readonly classes with a static fromEntity() method for output, classes with validation constraints for input. This covers 90% of cases without introducing additional mapper services.
Comments
Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.