Wer mit Doctrine oder einem anderen ORM arbeitet, kennt das Muster: Eine Entity bildet eine Datenbanktabelle ab, mit allen Feldern, Relationen und internem Zustand. Die Versuchung liegt nahe, diese Entity direkt als JSON-Response an den Client zu schicken — schließlich enthält sie bereits alle Daten.
Das funktioniert genau so lange, bis es knallt. Und es knallt aus mindestens drei Richtungen gleichzeitig.
Das Problem: Entities nach draußen reichen
Ein typisches Beispiel — eine Doctrine-Entity für Produkte:
#[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;
// Getter, Setter...
}
Wer jetzt return $this->json($product) im Controller schreibt, liefert dem Client:
- Den Einkaufspreis (
purchasePrice) — eine Geschäftsinformation, die kein Kunde sehen soll - Das komplette Supplier-Objekt mit Kontaktdaten, Konditionen und interner ID
- Den Lagerbestand — je nach Geschäftsmodell vertraulich
- Interne Timestamps und IDs, die für den Client irrelevant sind
Drei konkrete Probleme:
1. Sicherheit. Interne Felder landen unkontrolliert beim Client. Ein #[Ignore]-Attribut auf dem Serializer ist eine Krücke — beim nächsten Feld vergisst es jemand.
2. Kopplung. Jede Schemaänderung an der Entity ändert automatisch die API-Response. Eine Spalte umbenennen, ein Feld entfernen, eine Relation umbauen — der Client bekommt es sofort zu spüren. Aus einer internen Refaktorierung wird ein Breaking Change.
3. Darstellung. Die Entity speichert Daten so, wie die Datenbank sie braucht. Der Client braucht sie anders: Preise formatiert, Datumsangaben als ISO-String, verschachtelte Objekte auf das Wesentliche reduziert. Diese Transformation gehört nicht in die Entity.
Was ist ein DTO?
Ein Data Transfer Object ist eine einfache Klasse, deren einziger Zweck der Transport von Daten zwischen zwei Schichten ist. Keine Geschäftslogik, keine Datenbankanbindung, keine Seiteneffekte — nur typisierte Felder.
class ProductDetailDto
{
public int $id;
public string $name;
public string $price;
public string $currency;
public bool $inStock;
public string $createdAt;
}
Der entscheidende Punkt: Das DTO definiert exakt die Felder, die der Client sehen soll — nicht mehr und nicht weniger. Der Einkaufspreis? Fehlt. Der Lieferant? Wird auf den Namen reduziert oder weggelassen. Der Lagerbestand? Wird zu einem Boolean inStock.
Input-DTOs vs. Output-DTOs
DTOs gibt es in zwei Richtungen:
| Richtung | Zweck | Beispiel |
|---|---|---|
| Output-DTO | Entity → Client (API-Response) | ProductDetailDto, OrderListDto |
| Input-DTO | Client → Anwendung (Request-Body) | CreateProductDto, UpdateOrderDto |
Output-DTO: Entity in eine kontrollierte Antwort umwandeln
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;
}
}
Die statische fromEntity()-Methode ist das gängigste Mapping-Pattern. Die Transformation passiert an genau einer Stelle, und das DTO kontrolliert, welche Daten nach draußen gehen.
Input-DTO: Validierte Eingabedaten entgegennehmen
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 = [];
}
Im 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
);
}
Das Zusammenspiel: Der Request wird in ein Input-DTO deserialisiert und validiert. Der Service erstellt daraus eine Entity. Die Response liefert ein Output-DTO zurück. Zu keinem Zeitpunkt sieht der Client die Entity direkt.
Mapping-Strategien
Es gibt drei gängige Wege, Entities in DTOs umzuwandeln:
1. Statische Factory-Methode (empfohlen für die meisten Fälle)
class OrderDto
{
// ...Felder...
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;
}
}
Vorteil: Kein zusätzlicher Service nötig. Das Mapping lebt dort, wo die Zielstruktur definiert ist. Verschachtelte DTOs werden rekursiv über ihre eigene fromEntity()-Methode gemappt.
2. Eigener 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;
}
}
Wann sinnvoll: Wenn das Mapping externe Services braucht (Preisformatierung, URL-Generierung, Berechtigungsprüfungen). Die Factory-Methode auf dem DTO kann keine Dependencies injecten — ein eigener Service schon.
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,
);
Vorteil: Immutable DTOs mit readonly-Properties. Kein ungültiger Zwischenzustand möglich — alle Felder müssen beim Erstellen gesetzt werden.
DTOs mit readonly und PHP 8.2+
Seit PHP 8.2 lassen sich DTOs besonders schlank als readonly-Klassen schreiben:
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 auf Klassenebene macht jede Property automatisch readonly. Kein versehentliches Überschreiben, kein Setter nötig, kein veränderbarer Zustand. Für Output-DTOs ist das der ideale Ansatz.
Häufige Fehler
DTO als Entity-Wrapper missbrauchen. Wenn das DTO 1:1 die gleichen Felder wie die Entity hat, ist es kein DTO — es ist Boilerplate. DTOs haben nur dann einen Zweck, wenn sie die Datenstruktur für den Empfänger transformieren.
Geschäftslogik im DTO. Ein DTO berechnet nichts, validiert keine Geschäftsregeln und ruft keine Services auf. Formatierung (Datum zu String, Preis mit Währung) ist akzeptabel — alles darüber hinaus gehört in den Service.
Entity im DTO referenzieren. Das DTO sollte keine Product-Property haben, sondern die relevanten Felder als primitive Typen. Sonst wird die Entkopplung unterlaufen.
Zu viele DTOs auf einmal einführen. Nicht jede Entity braucht sofort ein DTO. Der Aufwand lohnt sich dort, wo die API-Grenze bewusst kontrolliert werden muss — typischerweise bei REST-Endpoints und externen Schnittstellen.
Wann DTOs unnötig sind
Nicht jedes Projekt braucht DTOs. Für interne Admin-Tools, Prototypen oder simple CRUD-Anwendungen ohne öffentliche API kann die direkte Entity-Serialisierung ausreichen — vorausgesetzt, sensible Felder werden per Serializer-Gruppen ausgeschlossen.
Die Faustregel: Sobald eine öffentliche API existiert, die von einem separaten Client konsumiert wird (SPA, Mobile App, Drittanbieter), sind DTOs keine Option mehr — sie sind Pflicht.
Fazit
DTOs lösen ein konkretes Problem: die unkontrollierte Kopplung zwischen Datenbankschicht und Außenwelt. Sie kosten wenige Zeilen Code pro Endpoint und verhindern, dass interne Schemaänderungen die API-Stabilität gefährden oder sensible Daten nach draußen lecken.
Mein bevorzugtes Setup: readonly-Klassen mit statischer fromEntity()-Methode für Output, Klassen mit Validation-Constraints für Input. Das deckt 90 % der Fälle ab, ohne zusätzliche Mapper-Services einzuführen.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.