Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Form Model Classes (DTOs)

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I want to talk about a different strategy that we could have used for the registration form: a strategy that many people really love. The form class behind this is UserRegistrationFormType and it's bound to our User class. That makes sense: we ultimately want to create a User object. But this was an interesting form because, out of its three fields, two of them don't map back to a property on our User class! There is no plainPassword property or agreeTerms property on User. To work around this, we used a nice trick - setting mapped to false - which allowed us to have these fields without getting an error. Then, in our controller, we just need to read that data in a different way: like with $form['plainPassword']->getData()

This is a great example of a form that doesn't look exactly like our entity class. And when your form starts to look different than your entity class, or maybe it looks more like a combination of several entity classes, it might not make sense to try to bind your form to your entity at all! Why? Because you might have to do all sorts of crazy things to get that to work, including using embedded forms, which isn't even something I like to talk about.

What's the better solution? To create a model class that looks just like your form.

Creating the Form Model Class

Let's try this out on our registration form. In your Form/ directory, I like to create a Model/ directory. Call the new class UserRegistrationFormModel. The purpose of this class is just to hold data, so it doesn't need to extend anything. And because our form has three fields - email, plainPassword and agreeTerms - I'm going to create three public properties: email, plainPassword, agreeTerms.

... lines 1 - 4
class UserRegistrationFormModel
{
public $email;
public $plainPassword;
public $agreeTerms;
}

Wait, why public? We never make public properties! Ok, yes, we could make these properties private and then add getter and setter methods for them. That is probably a bit better. But, because these classes are so simple and have just this one purpose, I often cheat and make the properties public, which works fine with the form component.

Next, in UserRegistrationFormType, at the bottom, instead of binding our class to User::class, bind it to UserRegistrationFormModel::class.

... lines 1 - 15
class UserRegistrationFormType extends AbstractType
{
... lines 18 - 44
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => UserRegistrationFormModel::class
]);
}
}

And... that's it! Now, instead of creating a new User object and setting the data onto it, it will create a new UserRegistrationFormModel object and put the data there. And that means we can remove both of these 'mapped' => false options: we do want the data to be mapped back onto that object.

In the controller, the big difference is that $form->getData() will not be a User object anymore - it will be a $userModel. I'll update the inline doc above this to make that obvious.

... lines 1 - 15
class SecurityController extends AbstractController
{
... lines 18 - 45
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator)
{
... lines 48 - 50
if ($form->isSubmitted() && $form->isValid()) {
/** @var UserRegistrationFormModel $userModel */
$userModel = $form->getData();
... lines 54 - 72
}
... lines 74 - 77
}
}

When you use a model class, the downside is that you need to do a bit more work to transfer the data from our model object into the entity object - or objects - that actually need it. That's why these model classes are often called "data transfer objects": they just hold data and help transfer it between systems: the form system and our entity classes.

Add $user = new User() and $user->setEmail($userModel->email). For the password field, it's almost the same, but now the data comes from $userModel->plainPassword. Do the same thing for $userModel->agreeTerms.

... lines 1 - 45
public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, LoginFormAuthenticator $formAuthenticator)
{
... lines 48 - 50
if ($form->isSubmitted() && $form->isValid()) {
... lines 52 - 54
$user = new User();
$user->setEmail($userModel->email);
$user->setPassword($passwordEncoder->encodePassword(
... line 58
$userModel->plainPassword
));
... line 61
if (true === $userModel->agreeTerms) {
... line 63
}
... lines 65 - 75
}
... lines 77 - 80
}
... lines 82 - 83

The benefit of this approach is that we're using this nice, concrete PHP class, instead of referencing specific array keys on the form for unmapped fields. The downside is... just more work! We need to transfer every field from the model class back to the User.

And also, if there were an "edit" form, we would need to create a new UserRegistrationFormModel object, populate it from the existing User object, and pass that as the second argument to ->createForm() so that the form is pre-filled. The best solution is up to you, but these data transfer objects - or DTO's, are a pretty clean solution.

Let's see if this actually works! I'll refresh just to be safe. This time, register as WillRyker@theenterprise.org, password engage, agree to the terms, register and... got it!

Validation Constraints

Mission accomplished! Right? Wait, no! We forgot about validation! For example, check out the email field on User: we did add some @Assert constraints above this! But... now that our form is not bound to a User object, these constraints are not being read! It is now reading the annotations off of these properties... and we don't have any!

Go back to your browser, inspect element on the form and add the novalidate attribute. Hit register to submit the form blank. Ah! We do have some validation: for the password and agree to terms fields. Why? Because those constraints were added into the form class itself.

Let's start fixing things up. Above the email property, paste the two existing annotations. I do need a use statement for this: I'll cheat - add another @Email, hit tab - there's the use statement - and then delete that extra line.

... lines 1 - 6
class UserRegistrationFormModel
{
/**
* @Assert\NotBlank(message="Please enter an email")
* @Assert\Email()
*/
public $email;
... lines 14 - 24
}

At this point, if you want to, you can remove these annotations from your User class. But, because we might use the User class on a form somewhere else - like an edit profile form - I'll keep them there.

One of the really nice things about using a form model class is that we can remove the constraints from the form and put them in the model class so that we have everything in one place. Above $plainPassword, add @Assert\NotBlank() and @Assert\Length(). Let's pass in the same options: message="" and copy that from the form class. Then copy the minMessage string, add min=5, minMessage= and paste.

Finally, above agreeTerms, go copy the message from the form, and add the same @Assert\IsTrue() with message= that message.

... lines 1 - 14
/**
* @Assert\NotBlank(message="Choose a password!")
* @Assert\Length(min=5, minMessage="Come on, you can think of a password longer than that!")
*/
public $plainPassword;
/**
* @Assert\IsTrue(message="I know, it's silly, but you must agree to our terms.")
*/
public $agreeTerms;
... lines 25 - 26

Awesome! Let's celebrate by removing these from our form! Woo! Time to try it! Find your browser, refresh and... ooook - annotations parse error! It's a Ryan mistake! Let's go fix that - ah - what can I say? I love quotes!

Try it again. Much better! All the validation constraints are being cleanly read from our model class.

Except... for one. Go back to your User class: there was one more validation annotation on it: @UniqueEntity(). Copy this, go back into UserRegistrationFormModel and paste this above the class. We need a special use statement for this, so I'll re-type it, hit tab and... there it is! This annotation happens to live in a different namespace than all the others.

... lines 1 - 4
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 6 - 7
/**
* @UniqueEntity(
* fields={"email"},
* message="I think you're already registered!"
* )
*/
class UserRegistrationFormModel
... lines 15 - 33

Let's try this - refresh. Woh! Huge error!

Unable to find the object manager associated with an entity of class UserRegistrationFormModel

It thinks our model class is an entity! And, bad news friends: it is not possible to make UniqueEntity work on a class that is not an entity class. That's a bummer, but we can fix it: by creating our very-own custom validation constraint. Let's do that next!

Leave a comment!

26
Login or Register to join the conversation
Default user avatar
Default user avatar Guido | posted 1 year ago | edited

Hi! Do you habe an idea why I get the following error on SF 5.4? I think I did it like you suggest but something has to be wrong. :(

Could not load type "App\Form\Model\MySpecialFormModel": class does not implement "Symfony\Component\Form\FormTypeInterface".

I think it happens here, but you do the same?!

$form = $this->createForm(MySpecialFormModel::class);

Reply
Default user avatar

Damn, please delete this comment and my comment above...I just failed with copy & paste, sorry!

Reply

Haha, do not worry man :)

Reply
Roland W. Avatar
Roland W. Avatar Roland W. | posted 2 years ago | edited

I use this DTO approach. There is one thing that does not work for me when I use typed properties (PHP 7.4) for the public properties of these DTO.

EXAMPLE:

`class ProductDto
{

/*
 * @Assert\NotBlank
 */
public string $title;

}`

This generally seems to work quite well - in case the user submits the form with a blank title or description, the validation kicks in and the form is displayed with validation warnings.

BUT THERE IS A PROBLEM when data is added while creating a form (e.g. the edit form):

$productDto = new ProductDto();<br />$productDto->title = 'Foo';<br />$form = $this->createForm(ProductFormType::class, $productDto);

Initially the form is displayed as expected with "Foo" as the value for the "title". When a user clears the title input form field and submits the form an exception like this is thrown:

Typed property App\Form\Dto\ProductDto::$title must be string, null used

As far as I can see this is caused by the fact that during Form->handleRequest() the title is set to an empty string (or null) after it was set to "Foo" before, right?

Is there a solution for this problem?

Reply
Roland W. Avatar
Roland W. Avatar Roland W. | Roland W. | posted 2 years ago | edited

This is what I just came up with:

DTO:

`use GenericSetterTrait;

/**

  • @Assert\NotBlank
    */
    public string $title;

public function setTitle(?string $title): void
{

$this->set('title', $title);

}

/**

  • @Assert\NotNull
    */
    public Foo $foo;

public function setFoo(?Foo $foo): void
{

$this->set('foo', $foo);

}`

Trait:

`trait GenericSetterTrait
{

private function set(string $propertyName, $value): void
{
    if ($value === null) {
        unset($this->{$propertyName});
    } else {
        $this->{$propertyName} = $value;
    }
}

}`

Seems to work. What do you think? Any objections?

Reply

if you're using public properties, then you don't need setter methods. About the set(string $propertyName, $value) method. I particularly don't like that kind of magic methods but if you have a valid use-case where it will save you a lot of time, then go ahead.

Reply
Roland W. Avatar

This setter method prevents the "Typed property App\Form\Dto\ProductDto::$title must be string, null used" error. That's the reason I added it.

Reply

In that case, you can make the "title" property private, right?

Reply

Hey Roland W.

Do you get that error by submitting the form with an empty title? If that's the case, then, that's the right behavior because you updated the ProductDto title's from foo to null and it's not allowed to be null. Try setting a different value for the title and see if that works (it should).

Cheers!

Reply
Roland W. Avatar

Yes, that is exactly my problem. So type properties must be used with the ? Operator in these DTOs when data is passed to the form, right?

Isn’t there another way - I would really like to use typed properties without the ? operator.

Reply

I'm afraid that's a downside of using Symfony forms. Using a DTO helps at it so your entities can *stop* allowing nulls but the DTO class still have to.

Reply
Mike P. Avatar
Mike P. Avatar Mike P. | posted 3 years ago

I have a suggestion for your new SF 5 track, if you plan to showcase DTOs again, a new way to overcome the "code duplication" of validations/constraints with DTOs:
https://symfony.com/blog/ne...

Reply

Yo Mike P.!

I just saw that feature! It's super cool - I *love* it. But I guess you would need to create one constraint class per property that has constraints... and then apply that on both the entity and DTO properties. So, it *would* cut down on some duplication (if a property has 3 constraints, you wouldn't need to repeat that on each constraint), but I guess you would still need to have each constraint on both entity & DTO properties. So... it *helps*, but doesn't eliminate it entirely, I think. Or do you disagree? I'm asking because... I hope I'm not seeing something and would love to be wrong :).

Cheers!

Reply
Mike P. Avatar

I agree 100% 👍
Could you please take a look at this case:
https://symfonycasts.com/sc...

I would love to hear feedback from you to clear my mind! :)

Reply
Mike P. Avatar
Mike P. Avatar Mike P. | posted 3 years ago

„ Because you might have to do all sorts of crazy things to get that to work, including using embedded forms, which isn't even something I like to talk about„

Would you choose DTO over a embedded form every time? If yes, why (use cases for both)?

I can’t yet decide when to use embedded forms and when a dto. DTO is better maintanable?

Reply

Cey Mike P.

DTO's are good when you have a form that is conformed of many different entities
Embedded forms are good when you want to add or remove items of the form. For example, you have a Users and Articles, a User may have many Articles, so in your form you may want to manage the user information and the list of his articles (It may not be the best example but I hope it helps you clarifying things a little bit)

Cheers!

Reply
Mike P. Avatar

And if you want a combination of both worlds, we need to use both?

By example:
I haven an Recipe Entity, with a ManyToMany to Ingredients and Categories.
The "new recipe" page does a have a new "ingredient" field for each ingredient. (Which means each ingredient has its OWN text field)

So in this example, we have different entities (recipe & ingredient & category) and items (ingredients) can be added or removed.
So in this case I should use both, DTO and embedded forms?

Could you clear my mind please :)
Thanks in advance!

Reply

Hey Mike P.!

It sounds like you actually have a fairly simple (I mean "fairly", because this situation is never that simple) example of an entity (Recipe) which has an embedded collection of other entities (Recipe). As far as I can tell, the Categories relationship is not important for this form. And also, there are no extra/weird fields.

So, this seems like a fairly simple CollectionType situation... which still makes it tough. If you used entities, you would use the CollectionType on a RecipeFormType over to an IngredientFormType. The IngredientFormType would probably only have one field - a text field. That could definitely work - you would need cascade={"persist"} on the ingredients relationship... but it should work. Still, the JavaScrip with a CollectionType is a pain.

So... I would probably instead do this:

1) Create a RecipeFormType that is bound to the Recipe entity (because, as far as I know, there aren't many weird fields - other than the ingredients - that would necessitate using a DTO).

2) For the "ingredients" field on that form, make it 'mapped' => false and a CollectionType using TextType - I think the code is:


$builder->add('ingredients', CollectionType::class, [
    'entry_type' => TextType::class
]);

3) In the template, you would still be dealing with a CollectionType, but hopefully it will be a bit simpler because it will just be a collection of input boxes. You can use the normal Symfony way of doing the "add new" thing, or (honestly), you could render the widget {{ form_widget('ingredients'), which I think will render nothing on this "new" form - then just write your own JavaScript to do everything. As long as you end up with a bunch of input fields with the correct name attribute, the form won't know the difference.

4) On submit, use $form->get('ingredients')->getData() to get the array of strings. Turn these into Ingredient objects and link them.

So... it's all about trying to reduce complexity - and, for me, complexity is all about the CollectionType. Let me know what you end up doing and how it goes!

Cheers!

1 Reply
Default user avatar
Default user avatar Larry Lu | posted 4 years ago

How do I run this DTO validation on multiple entries of the same input fields? For example, I'm posting 10 entries of names and addresses at a time, how do I validating them all?

Reply

hey Larry Lu

For such cases there is Symfony\Component\Validator\Constraints\All constraint, nit sure that it will work with annotations, but if constraints defined programmatically it should work in combination with Collection constraint
It will be something like this:


$constraint = new Assert\All(['constraints' => [
    new Assert\Collection([
        'name' => new Assert\NotBlank(),
        'address' => new Assert\NotBlank()
    ])
]);

if I didn't missed something =)

Cheers! Hope this will help!

Reply
Default user avatar
Default user avatar Larry Lu | sadikoff | posted 4 years ago | edited

Thanks it works. I followed that $constraint with the validator check and it's able to check my $data array.

`$errors = $validator->validate($data, $constraint);

if (count($errors) > 0 ) {

$errorsString = (string) $errors;
return new JsonResponse(
    [
        'validation failed' => $errorsString
    ]);

}`

So does this mean that I don't have to use the DTO model class anymore? And that check for validator error is the replacement for the if ($form->isSubmitted() && $form->isValid()) block if I were working with form?

Reply

You can add this $constraint to your form class, and continue using form, I think controllers are more readable when you using forms =)

Cheers!

Reply
Rita M. Avatar
Rita M. Avatar Rita M. | posted 4 years ago

What would be the best way to populate a dropdown with this DTO approach? Basically I want to receive a list of objects from the database and display them in the dropdown. Putting it into the FormType class in the builder as "choices" seems not to be the most elegant version.

Reply

Hey Rita M., probably a DataTransformer is what you need: https://symfony.com/doc/cur...
but if it doesn't fit your needs, then probably you may have to fetch entity objects and then code a function for transforming them into a DTO list.

Cheers!

1 Reply

This looks like a pretty annoying way of doing things !

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.6
        "symfony/console": "^4.0", // v4.1.6
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/form": "^4.0", // v4.1.6
        "symfony/framework-bundle": "^4.0", // v4.1.6
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.6
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.6
        "symfony/validator": "^4.0", // v4.1.6
        "symfony/web-server-bundle": "^4.0", // v4.1.6
        "symfony/yaml": "^4.0", // v4.1.6
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
        "symfony/dotenv": "^4.0", // v4.1.6
        "symfony/maker-bundle": "^1.0", // v1.8.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.6
    }
}
userVoice