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!

  • 2020-03-17 weaverryan

    Hey Mike!

    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!

  • 2020-03-15 Mike

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

  • 2020-03-15 weaverryan

    Yo Mike!

    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!

  • 2020-03-13 Mike

    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!

  • 2020-03-13 Mike

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

  • 2020-03-11 Diego Aguiar

    Cey Mike

    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!

  • 2020-03-11 Mike

    „ 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?

  • 2019-05-10 Vladimir Sadicov

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

    Cheers!

  • 2019-05-07 Larry Lu

    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?

  • 2019-05-06 Vladimir Sadicov

    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!

  • 2019-05-06 Larry Lu

    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?

  • 2019-03-06 Diego Aguiar

    Hey Kevin, 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!

  • 2019-03-06 Kevin

    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.

  • 2018-12-28 Diego Aguiar

    Hey Leif__

    Yea, it requires a bit more work but it let you manage crazy forms without polluting your entity classes

    Cheers!

  • 2018-12-28 Leif__

    This looks like a pretty annoying way of doing things !