Skip to content

DTOs in PHP: Why Entities Don't Belong in Your API

Published on May 27, 2026 | approx. 7 min read |

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.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Comments

Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.