DTO in Symfony Form - Part II
I’d like to revisit the topic of using DTOs for handling forms in Symfony. It turns out there are a few points that could use some clarification. This time, the format will be a little different—I’ll focus on specific use cases and explain them.
This post expands on the information shared in one of the earlier posts: DTO in Symfony Forms.
To Null or Not to Null
A DTO is not a Value Object. It’s mutable, serving as a container for data. That’s why, instead of getters and setters, I provide public attributes. But why are all these attributes initialized to null
? Even when some data is required?
Whenever possible, I avoid constructors in DTOs. A DTO should be an empty object, ready to be filled with data later.
DTOs rely on external validation. Since I use the symfony/validator component, it heavily influences the use of null
. Assertions define whether a value should be empty, what type it should be, its length, and anything else I decide. When the validator kicks in, it accesses the values via public attributes or getters. Without initializing these attributes, you’d run into this error:
PHP Warning: Uncaught Error: Typed property Foo::$foo must not be accessed before initialization
What if I forget to validate the data and pass a null instead of a proper value to the entity? The primary safeguard is type hints in method parameters or constructors. Passing invalid data throws a TypeError. Additionally, you can layer on validation with tools like beberlei/assert:
1 2 3 4 5
public function setTitle(string $title): void
{
Assertion::notBlank($title);
$this->title = $title;
}
This way, if invalid data is passed to the entity, an exception will be thrown immediately.
Splitting Data Between Create and Edit Actions
Typically, I use a single class to handle both creating and editing an entity. It’s simpler, as input data is always processed using the same class. For a while, I followed the “1 DTO per action” approach, but in most cases, the two classes ended up looking identical, except for a constructor that took the entity as an argument to prefill the data. Instead, I find it easier to use validation groups for partial validation (see the documentation on validation groups).
Simplifying the process of creating an object prefilled with existing data is also straightforward with named constructors. Instead of passing attributes through a constructor, I use a static method like fromEntity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
class Foo
{
/**
* @Assert\NotBlank()
*/
public ?string $foo = null;
public static function fromEntity(Entity $bar): self
{
$data = new self();
$data->foo = $bar->getFoo();
return $data;
}
}
Neat, isn’t it?
Relationships Between Entities
This is another problem I’ve addressed using two different approaches.
Simpler Approach: Using EntityType
You might think that using DTOs in forms rules out EntityType
. But why? The idea is to separate data before validation from the entity. EntityType
doesn’t introduce changes—it only serves to read them. One major advantage of this approach is that you don’t need to write a custom validator to check if an entity exists; Symfony’s built-in mechanisms for form fields handle this. In this case, the DTO should accept the entity itself, not another DTO or just the ID.
More Complex Approach: Passing Only Entity IDs
In this method, the DTO only receives the entity’s ID, using ChoiceType
instead of EntityType
. While ChoiceType
can theoretically verify if the selected option exists, I prefer to write a custom validator to ensure the entity with the given ID exists in the database. The downside of this approach is the need to manually build the list of options and the validator.
This second solution is more common in APIs, which is why I suggest passing IDs instead of entities. When working with APIs and Symfony’s basic serializer, you typically get an ID rather than the entity object. If the data comes only from a form, it’s easier to build a list of options from entities rather than IDs. But even then, I’d still add a custom validator.
Conclusion
This article came about after a discussion in a Facebook group I’m part of. It all started with an exception caused by invalid data types passed to a setter.
I hope this post clears up any doubts or questions you might have had after reading the first part. If you have additional questions or suggestions, feel free to leave a comment!