🕵️ SicherheitslückenWeb Application Firewall Rule Bypass in Jetpack WAF Runtime(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenCross-Site Request Forgery in WooCommerce Product and Term Ordering(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenUnescaped Output in Enable Media Replace Error View(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenStored Cross-Site Scripting in WooCommerce Order Notes REST API v4(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenUnescaped Attribute Output in Enable Media Replace Upsell View(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenWeb Application Firewall Rule Bypass in Jetpack WAF Runtime(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenCross-Site Request Forgery in WooCommerce Product and Term Ordering(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenUnescaped Output in Enable Media Replace Error View(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenStored Cross-Site Scripting in WooCommerce Order Notes REST API v4(17.09.2026 um 16:34 Uhr)
🕵️ SicherheitslückenUnescaped Attribute Output in Enable Media Replace Upsell View(17.09.2026 um 16:34 Uhr)
🔧 Programmierung 🕛 vor 2 Monaten 9 Min Lesezeit
0

Keeping Doctrine Entities Honest with DTOs and ObjectMapper

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht






Your Doctrine entities are lying to you!




For years, the standard way to build Doctrine entities in Symfony has looked

something like this (and it's still what MakerBundle generates today):




CODE
#[ORM\Entity]
class ConferenceTalk
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[Assert\NotBlank]
#[ORM\Column(length: 255)]
private ?string $title = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $abstract = null;

public function getId(): ?int
{
return $this->id;
}

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(?string $title): static
{
$this->title = $title;

return $this;
}

public function getAbstract(): ?string
{
return $this->abstract;
}

public function setAbstract(?string $abstract): static
{
$this->abstract = $abstract;

return $this;
}
}






At first glance, this looks perfectly reasonable.



The title field is required. We know that because it has a

NotBlank constraint and the database column is not nullable.



But look closer.




CODE
private ?string $title = null;

public function setTitle(?string $title): static

public function getTitle(): ?string






According to the PHP type system, the title is optional. In fact, the public

API of this class explicitly allows us to set it to null.



That means this is perfectly valid:




CODE
$talk = new ConferenceTalk();






And so is this:




CODE
$talk = new ConferenceTalk();
$talk->setTitle(null);






Both objects represent a conference talk that can never be successfully

persisted.



Eventually, Doctrine catches the problem:




CODE
$talk = new ConferenceTalk();

$entityManager->persist($talk);
$entityManager->flush(); // boom!







SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'title' cannot be null




The database knows that a conference talk must have a title.



Our PHP code does not.



So why do we build entities this way?



Historically, this pattern was optimized for simplicity.



A single object could be used everywhere:




  • Render a form

  • Receive submitted data

  • Validate that data

  • Persist it to the database



For beginners, this is fantastic. There is very little ceremony, very little

boilerplate, and very little to learn before you can build working CRUD

screens.



The tradeoff is that entities end up serving two very different purposes.



They represent persisted application data, but they also act as form models.

Because user input is often incomplete or invalid, entities need to be able

to temporarily hold data that should never actually exist in the database.



That's why we end up with a seemingly contradictory combination:




CODE
#[Assert\NotBlank]
#[ORM\Column(nullable: false)]
private ?string $title = null;






The validator says the field is required.



The database mapping says it's required.



The PHP type says it's optional.



Those statements don't all agree.



But what if our entities didn't need to act as form models at all?



What if incomplete user input lived somewhere else?






Giving Incomplete Data Somewhere Else to Live



Validation is not the problem.



The problem is that we're asking our entity to be two different things at the

same time:




  • A valid ConferenceTalk entity

  • A form data model (a temporary container for incomplete user input)



So let's split those responsibilities.



First, create a DTO (Data Transfer Object) that represents the form data:




CODE
final class ConferenceTalkDto
{
#[Assert\NotBlank]
public ?string $title = null;

public ?string $abstract = null;
}






Unlike our entity, the DTO is allowed to be incomplete.



That's its job. It represents data coming from the outside world before we've

proven that it's valid.



Our entity can finally express its real requirements:




CODE
#[ORM\Entity]
class ConferenceTalk
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $title;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $abstract = null;

public function __construct(string $title)
{
$this->title = $title;
}

public function getTitle(): string
{
return $this->title;
}

public function getAbstract(): ?string
{
return $this->abstract;
}

public function setAbstract(?string $abstract): void
{
$this->abstract = $abstract;
}
}






Notice what's changed.



The required title is no longer nullable and must be provided when the

entity is created:




CODE
public function __construct(string $title)
{
$this->title = $title;
}






The optional abstract remains nullable, and does not have a constructor argument,

because it truly is optional.



Now the entity's API matches its actual requirements. If you want a

ConferenceTalk, you must provide a title: ""




CODE
$talk = new ConferenceTalk('Keeping Doctrine Entities Honest');






At this point, the PHP type system, the entity, and the database all agree on

what is required and what is optional.



We've eliminated an entire category of impossible states.



A ConferenceTalk can no longer exist without a title.



The natural objection is:




Great, but now I have two classes. Do I need to manually map data between them?




Historically, yes - and that was a legitimate objection.



And that boilerplate is one of the reasons many Symfony applications have

continued using entities directly with forms and validation.



This is where ) changes the tradeoff.



Instead of manually creating entities from DTOs:




CODE
$talk = new ConferenceTalk($dto->title);

$talk->setAbstract($dto->abstract);






we can let ObjectMapper do the transformation for us:




CODE
$talk = $objectMapper->map($dto, ConferenceTalk::class);







One nice detail is that this remains fully type-safe. Thanks to generics,

IDE autocompletion and static analysis tools can correctly infer that

$talk is a ConferenceTalk instance.




The DTO remains flexible enough to receive and validate user input. The

entity remains strict enough to represent valid application data.

ObjectMapper bridges the gap between the two.



In other words:




  • The DTO receives and validates user input

  • The entity represents valid application data


  • ObjectMapper moves data between them



Each object has a single responsibility, and each object tells the truth

about what it represents.





What Does This Look Like in Practice?



The nice thing about this approach is that it doesn't require a massive

rewrite. Starting from a standard make:crud setup, only five things change:




  1. Add a DTO

  2. Update the form to map to the DTO instead of the entity

  3. Make the entity requirements honest

  4. Update the create controller

  5. Update the edit controller





1. Add a DTO



First, I created a new ConferenceTalkDto class:




CODE
// src/Dto/ConferenceTalkDto.php

final class ConferenceTalkDto
{
#[Assert\NotBlank]
public ?string $title = null;

public ?string $abstract = null;
}






This class is now responsible for receiving user input and holding invalid

data during validation.





2. Update the Form



The form barely changes.



Before:




CODE
// src/Form/ConferenceTalkType.php

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ConferenceTalk::class,
]);
}






After:




CODE
// src/Form/ConferenceTalkType.php

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ConferenceTalkDto::class,
]);
}






That's it.



The form now hydrates the DTO instead of the entity.






3. Make the Entity Honest



With validation moved to the DTO, the entity can finally express what is

actually required.



Before:




CODE
// src/Entity/ConferenceTalk.php

#[Assert\NotBlank]
#[ORM\Column(length: 255)]
private ?string $title = null;

// ...
public function setTitle(?string $title): void // (nullable property)
{
$this->title = $title;
}

public function getTitle(): ?string // (nullable return type)
{
return $this->title;
}






After:




CODE
// src/Entity/ConferenceTalk.php

#[ORM\Column(length: 255)]
private string $title;

public function __construct(string $title)
{
$this->title = $title;
}

// ...
public function setTitle(string $title): void // (non-nullable property)
{
$this->title = $title;
}

public function getTitle(): string // (non-nullable return type)
{
return $this->title;
}






The title is no longer nullable because a conference talk without a title

should never exist.





4. Update the Create Controller



This is where ObjectMapper comes in.



Before, the form populated the entity directly:




CODE
// src/Controller/ConferenceTalkController.php

$talk = new ConferenceTalk();

$form = $this->createForm(
ConferenceTalkType::class,
$talk,
);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($talk);
$entityManager->flush();

// ...
}

// ...






Now the form populates the DTO:




CODE
// src/Controller/ConferenceTalkController.php

$dto = new ConferenceTalkDto();

$form = $this->createForm(
ConferenceTalkType::class,
$dto,
);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$talk = $objectMapper->map(
$dto,
ConferenceTalk::class,
);

$entityManager->persist($talk);
$entityManager->flush();

// ...
}

// ...









5. Update the Edit Controller



Editing existing entities works too.



Before:




CODE
// src/Controller/ConferenceTalkController.php

$form = $this->createForm(
ConferenceTalkType::class,
$talk,
);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();

// ...
}

// ...






After:




CODE
// src/Controller/ConferenceTalkController.php

$dto = $objectMapper->map(
$talk,
ConferenceTalkDto::class,
);

$form = $this->createForm(
ConferenceTalkType::class,
$dto,
);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$objectMapper->map(
$dto,
$talk,
);

$entityManager->flush();

// ...
}

// ...






Notice what's happening.



For creation, ObjectMapper creates a new entity from the DTO.



For editing, ObjectMapper first creates a DTO from the entity so the form

has something to edit. Once validation succeeds, it maps the DTO back onto

the existing entity.



The pattern remains consistent:




  • Forms work with DTOs

  • Validation runs on DTOs

  • Entities remain valid application data



Most importantly, we no longer have a period of time when a ConferenceTalk exists without a title!




Want to see all of these changes together?

Check out the .



The challenge has never been whether DTOs are useful. The challenge has been standardization.



ObjectMapper brings this capability into Symfony itself, giving the ecosystem a first-class solution for moving data between DTOs and entities.



For the first time, this pattern feels practical enough to be considered a

mainstream Symfony approach rather than an advanced architectural choice.



That allows each object to focus on a single responsibility:




  • DTOs receive and validate user input

  • Entities represent valid application data



The result is a model where required data is actually required, optional data is actually optional, and entities can no longer be created in impossible states.



At least to me, that feels like a cleaner approach.



Should MakerBundle generate DTOs by default? Should make:crud offer a

DTO/ObjectMapper workflow? Have you already been using a pattern like this in your applications?



Maybe it's time for our entities to start telling the truth.



What do you think? Let us know in the comments!






Originally posted on https://symfonycasts.com/blog/honest-entities

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Microsoft Office Ohne Abonnement? Jetzt kostet es ein paar Hundert - Jablíčkář
1 Quelle
Windows Defender: Falsche Warnung täuscht Sicherheitslücke vor - ad-hoc-news.de
1 Quelle
DFN-CERT-2026-4930 FFmpeg: Mehrere Schwachstellen ermöglichen u. a. das Ausführen ...
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Keeping Doctrine Entities Honest with DTOs and ObjectMapper

Thematisch verwandte Begriffe: Keeping, Doctrine, Entities, Honest · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...