Back to Blog
Jun 17th, 2026

Keeping Doctrine Entities Honest with DTOs and ObjectMapper

Written by kbond

Edit
Keeping Doctrine Entities Honest with DTOs and ObjectMapper

Important

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):

#[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.

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:

$talk = new ConferenceTalk();

And so is this:

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

Both objects represent a conference talk that can never be successfully persisted.

Eventually, Doctrine catches the problem:

$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:

#[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:

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:

#[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:

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:

$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 Symfony's ObjectMapper component (added in 7.3) changes the tradeoff.

Instead of manually creating entities from DTOs:

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

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

we can let ObjectMapper do the transformation for us:

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

Note

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:

// 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:

// src/Form/ConferenceTalkType.php

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

After:

// 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:

// 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:

// 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:

// 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:

// 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:

// src/Controller/ConferenceTalkController.php

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

$form->handleRequest($request);

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

    // ...
}

// ...

After:

// 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!

Note

Want to see all of these changes together?

Check out the full diff to see this pattern applied to a real Symfony application.

ObjectMapper Changes the Equation

For years, we've accepted a contradiction.

The validator says required.

The database says required.

The entity says optional.

We allow entities to exist in invalid states because they also need to act as form models. We make required properties nullable. We rely on validation to enforce requirements that our type system cannot express.

The tradeoff made sense when entities doubled as form models.

But ObjectMapper changes the equation.

To be fair, this idea isn't entirely new.

Many Symfony developers have used DTOs for years, often with custom mapping code or libraries such as symfonycasts/micro-mapper.

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!

3 Comments

Sort By
Login or Register to join the conversation
Tac-Tacelosky 21 days ago

DTO and entities are much easier to work with now that PHP supports property hooks and asymmetric visibility. So much less boilerplate! I've started to use DTOs more in part because ObjectMapper eases the mapping so much. Thanks for the post.

2 |

This approach (DTO + ObjectMapper) is still a real improvement over the classic pattern: it eliminates impossible states and makes typing honest. But in a hexagonal architecture project with CQRS, it shifts the problem rather than solving it.

  1. Layer-skipping, not a dependency-direction violation → $objectMapper->map($dto, Product::class) called from the Controller builds the Entity directly from Infrastructure. Infra knowing about Domain is normal (that's the allowed direction). The real issue is that the Controller takes on a responsibility that belongs to Application: deciding when and how an Entity gets constructed. Risk: if a business rule needs to run at creation time (raising a Domain Event, checking a cross-aggregate rule), it has nowhere proper to live → it ends up duplicated across every Controller (web, API, CLI) that calls the mapper its own way.
  2. No Command, so no CQRS — > CQRS requires an explicit separation between the write intent and the Domain construction. The article jumps straight from DTO to Entity, with no Command and no Handler. Risk: the construction logic can't be tested without simulating a full HTTP request, since it lives in the Controller instead of an isolated Handler.
  3. Entity mutated by a generic mapper, not by the Domain itself → in the edit controller, $objectMapper->map($dto, $product) mutates the Entity directly from Infra. Nothing guarantees that business invariants (beyond basic Assert validation) get re-applied on mutation. Risk: a reflection-based mapper copies fields, it doesn't enforce business rules → an Entity can end up technically "filled" without being actually valid from a business standpoint.

The ideal: don't use ObjectMapper at all. In a hexagonal/CQRS architecture, the role ObjectMapper tries to fill (the DTO → Entity transition) is already covered natively by the Handler. The Handler receives the Command (already validated), builds the Entity through its Domain factory (Product::create(...)), and persists it through the Repository port. The DTO → Entity transition stops being a technical detail to automate →it becomes an explicit business act, written in code, visible, and testable.

1 |
Lola-Slade 15 days ago

I am not sure about using the DTO by default. I think it would be fantastic as an option.
It would be very helpful to have an updated reference implementation.
It would become even more helpful with some screencasts that get into the benefits of going this way by extending the object mapping.

1 |

Delete the comment?

Share this comment

astronaut with balloons in space

"Houston: no signs of life"
Start the conversation!