Buy

Symfony 4 Forms: Build, Render & Conquer!

0%
Buy

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

Login Subscribe

We built a custom field type called UserSelectTextType and we're already using it for the author field. That's cool, except, thanks to getParent(), it's really just a TextType in disguise!

Internally, TextType basically has no data transformer: it takes whatever value is on the object and tries to print it as the value for the HTML input! For the author field, it means that it's trying to echo that property's value: an entire User object! Thanks to the __toString() method in that class, this prints the first name.

Let's remove that and see what happens. Refresh! Woohoo! A big ol' error:

Object of class User could not be converted to string

More importantly, even if we put this back, yes, the form would render. But when we submitted it, we would just get a different huge error: the form would try to take the submitted string and pass that to setAuthor().

To fix this, our field needs a data transformer: something that's capable of taking the User object and rendering its email field. And on submit, transforming that email string back into a User object.

Creating the Data Transformer

Here's how it works: in the Form/ directory, create a new DataTransformer/ directory, but, as usual, the location of the new class won't matter. Then add a new class: EmailToUserTransformer.

The only rule for a data transformer is that it needs to implement a DataTransformerInterface. I'll go to the Code -> Generate menu, or Command+N on a Mac, select "Implement Methods" and choose the two from that interface.

I love data transformers! Let's add some debug code in each method so we can see when they're called and what this value looks like. So dd('transform', $value) and dd('reverse transform', $value).

... lines 1 - 7
class EmailToUserTransformer implements DataTransformerInterface
{
public function transform($value)
{
dd('transform', $value);
}
public function reverseTransform($value)
{
dd('reverse transform', $value);
}
}

To make UserSelectTextType use this, head back to that class, go to the Code -> Generate menu again, or Command + N on a Mac, and override one more method: buildForm().

Hey! We know this method! This is is the method that we override in our normal form type classes: it's where we add the fields! It turns out that there are a few other things that you can do with this $builder object: one of them is $builder->addModelTransformer(). Pass this a new EmailToUserTransformer().

... lines 1 - 9
class UserSelectTextType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new EmailToUserTransformer());
}
... lines 16 - 20
}

The transform() Method

Let's try it! I'll hit enter on the URL in my browser to re-render the form with a GET request. And... boom! We hit the transform() method! And the value is our User object.

This is awesome! That's the whole point of transform()! This method is called. when the form is rendering: it takes the raw data for a field - in our case the User object that lives on the author property - and our job is to transform that into a representation that can be used for the form field. In other words, the email string.

First, if null is the value, just return an empty string. Next, let's add a sanity check: if (!$value instanceof User), then we, the developer, are trying to do something crazy. Throw a new LogicException() that says:

The UserSelectTextType can only be used with User objects.

Finally, at the bottom, so nice, return $value - which we now know is a User object ->getEmail().

... lines 1 - 8
class EmailToUserTransformer implements DataTransformerInterface
{
public function transform($value)
{
if (null === $value) {
return '';
}
if (!$value instanceof User) {
throw new \LogicException('The UserSelectTextType can only be used with User objects');
}
return $value->getEmail();
}
... lines 23 - 27
}

Let's rock! Move over, refresh and.... hello email address!

The reverseTransform() Method

Now, let's submit this. Boom! This time, we hit reverseTransform() and its data is the literal string email address. Our job is to use that to query for a User object and return it. And to do that, this class needs our UserRepository.

Time for some dependency injection! Add a constructor with UserRepository $userRepository. I'll hit alt+enter and select "Initialize Fields" to create that property and set it.

... lines 1 - 9
class EmailToUserTransformer implements DataTransformerInterface
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
... lines 18 - 41
}

Normally... that's all we would need to do: we could instantly use that property below. But... this object is not instantiated by Symfony's container. So, we don't get our cool autowiring magic. Nope, in this case, we are creating this object ourselves! And so, we are responsible for passing it whatever it needs.

It's no big deal, but, we do have some more work. In the field type class, add an identical __construct() method with the same UserRepository argument. Hit Alt+Enter again to initialize that field. The form type classes are services, so autowiring will work here.

... lines 1 - 10
class UserSelectTextType extends AbstractType
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
... lines 19 - 28
}

Thanks to that, in buildForm() pass $this->userRepository manually into EmailToUserTransformer.

... lines 1 - 19
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new EmailToUserTransformer($this->userRepository));
}
... lines 24 - 30

Back in reverseTransform(), let's get to work: $user = $this->userRepository and use the findOneBy() method to query for email set to $value. If there is not a user with that email, throw a new TransformationFailedException(). This is important - and its use statement was even pre-added when we implemented the interface. Inside, say:

No user found with email %s

and pass the value. At the bottom, return $user.

... lines 1 - 9
class EmailToUserTransformer implements DataTransformerInterface
{
... lines 12 - 31
public function reverseTransform($value)
{
$user = $this->userRepository->findOneBy(['email' => $value]);
if (!$user) {
throw new TransformationFailedException(sprintf('No user found with email "%s"', $value));
}
return $user;
}
}

The TransformationFailedException is special: when this is thrown, it's a signal that there is a validation error.

Check it out: find your browser and refresh to resubmit that form. Cool - it looks like it worked. Try a different email: [email protected] and submit! Nice! If I click enter on the address to get a fresh load... yep! It definitely saved!

But now, try an email that does not exist, like [email protected]. Submit and... validation error! That comes from our data transformer. This TransformationFailedException causes a validation error. Not the type of validation errors that we get from our annotations - like @Assert\Email() or @NotBlank(). Nope: this is what I referred to early as "sanity" validation: validation that is built right into the form field itself.

We saw this in action back when we were using the EntityType for the author field: if we hacked the HTML and changed the value attribute of an option to a non-existent id, we got a sanity validation error message.

Next: let's see how we can customize this error and learn to do a few other fancy things to make our custom field more flexible.

Leave a comment!

  • 2019-03-28 Diego Aguiar

    Oh yes, that's what I meant "ChoiceType to TextType". The thing here is that the ChoiceType will try to get the entity objects list before applying the reverse transformation, and since your keys are wrong, then you get an error :)

    Cheers!

  • 2019-03-27 Manny

    Changing it to a TextType field instead of TagSelectType does send the data back and forth, but then I cannot use the transformer. However, changing the ChoiceType to a TextType in the parent() function of TagSelectType does trigger the "dd()"! Now I will just deal with transforming the request data coming as a string instead of an array and I'm done!

    So I guess the issue has to do with the ChoiceType after all :)

    Thanks a lot!!

  • 2019-03-27 Manny

    Thank you! I will give it a try ;)

  • 2019-03-27 Diego Aguiar

    Wait a second, before trying with a "view transformer", try changing your "TagSelectType" to be a TextType field instead of a ChoiceType

  • 2019-03-27 Diego Aguiar

    Hmm, the "dd()" it not being executed, so it's failing before calling "reverseTransform()"? It makes me think what you need is a "ViewTransformer". I'm not totally sure but give it a try: https://symfony.com/doc/cur...

    You only have to change one line:


    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder->addViewTransformer(new NameToTagTransformer($this->tagRepository));
    }
  • 2019-03-25 Manny

    Yes, I am using the form component for rendering the form. If I take that field out, everything saves perfectly, even with some related entities. But for this field, I get an error, because of course it is expecting an array of tag ids like ["1", "2", "3"], and is receiving ["1", "2", "New Tag A", "New Tag B"], so it does not know how to convert those strings to Tag entities. The strangest thing is that the dd() on reverseTransform is not even being fired. Does it have to do with many to many relationships? This is my tags field:

    /**
    * @ORM\ManyToMany(targetEntity="App\Entity\Tag", inversedBy="events")
    */
    private $tags;

    Oh, and I don't know if it helps or has anything to do with it, but I am using symfony 4.2.4

    Thanks a lot for your help!

    PS: i have moved on to adding and removing tags full front-end with api endpoints on the tag controller, but I really want to do this the way the tutorial says.

  • 2019-03-25 Diego Aguiar

    Ok, how are you doing the post request? Double check the field names that you are posting but if you are using the Form component for rendering the form, then you should not have this problem.
    When you POST, does the other fields update?

  • 2019-03-25 Manny

    Thank you Diego! My field name is correct, it is called "tags", which is a many to many relationship.

    FORM:

    ->add('tags', TagSelectType::class, [
    'compound' => false, //Already tried true, false or removing it
    'attr' => [
    'class' => 'select2'
    ]
    ])

    TagSelectType:

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder->addModelTransformer(new NameToTagTransformer($this->tagRepository));
    }

    public function getParent(){
    return ChoiceType::class;
    }

    And finally the NameToTagTransformer:

    public function transform($value)
    {
    dd($value); // <-----WORKS
    }

    public function reverseTransform($value)
    {
    dd($value); // < does NOT WORK or even dies
    }

    The DataTransformer is being called correctly when loading the form (the transfrom method) but not when going back to the controller.

    When dumping the whole $request on the controller, I am getting exactly what the select2 sends:

    array:3 [▼
    0 => 8
    1 => "Tag A"
    2 => "Tag B"
    ]

    And this is why i need the datatransformer, as I am already receiving the existing tag ids (8 in the example), I want to save tag A and tag B, and then send the controller a proper array with only ids, so Symfony can set the manytomany relationship.

  • 2019-03-22 Diego Aguiar

    How are you handling that form? I believe the field name for your ChoiceType is incorrect and hence, Symfony thinks the field is empty.

    P.S. Double check your post request

    Cheers!

  • 2019-03-22 Manny

    Hi, I have followed all your great tutorials and think they are the most solid ones!

    I am applying this tutorial to a DataTransformer for my own custom ChoiceType that is using a select2 component for storing article tags.

    The transform method works, though i am basically using it to remove all data from the field because the select2 is being populated by ajax because of 'reasons'.

    Now, with the reverseTransfrom method is a different story. Seems like the method is not even being accessed. Even the dd($value); at the beginning of the method is being missed. I googled around and saw that adding 'compound' => false to the options could help, but no luck for me. Has this something to do with the ChoiceType parent?

  • 2019-02-28 Diego Aguiar

    Hey Samuel Halera

    Double check the code where you generate such URL. That error means that you forgot to pass the ID parameter

    Cheers!

  • 2019-02-28 Samuel Halera

    Hi Ryan,
    Really nice tutorials!!

    I'm having a symfony a error using the Data Transformer. When I update the article's author field, after I submit the form with btn "Update", I get this message error :

    " Some mandatory parameters are missing ("id") to generate a URL for route "admin_article_edit". "

    I don't understand why...
    Thank you for your helps

  • 2019-01-28 Victor Bocharsky

    Hey Aaron,

    Glad you had got your question answered faster than our team got to it. Yeah, we try to make screencasts short and finished, but sometimes we have to split a big topic into a few screencasts.

    Cheers!

  • 2019-01-25 Aaron Kincer

    Sigh. Yet another instance of my question being answered in the next video. Next time I'm really going to wait till I watch the next video.

  • 2019-01-24 Aaron Kincer

    The validation error message on the email not found doesn't match the exception message we created in reverseTransform in your video and on my end. Is this expected?

  • 2018-12-31 weaverryan

    Hey cybernet2u!

    Ahh! Two things:

    1) Right before that error, do a dump($value);die; so you can see what the value actually is.

    2) But... I think I already know what it will be ;). Some sort of ArrayCollection or PersistetCollection Doctrine object.

    In your situation, you have a ManyToMany. So, ran will not contain one Rank object. And, on the other side, inside Rank, your "members" property will not contain just a single Members object - it will be a Collection of objects (basically an array).

    So, your data transformer needs to be a bit smarter:

    A) In transform, you will be passed this "collection" of objects - you need to combine this into one string. To follow the example in this tutorial, because I'm using a text box, I would probably create a comma-separated string of all of the email addresses.

    B) In reverseTransform, you will be passed the comma-separated list of email addresses (or members in your situation - but I'm not sure your exact setup). You would then split that into separate email addresses, and query for all of the User object (Member objects for you). Ultimately, you would return an array of Member objects.

    Hopefully that will get you started!

    Cheers!

  • 2018-12-29 cybernet2u

    Hi ... again :D
    I'm trying to edit a App:Member's App:Rank in a Form with data_class Members but i'm getting the LogicException error

    if (!$value instanceof Members) {
    throw new \LogicException('The RankSelect can only be used with Rank objects');
    }

    any ideas :?

    Relation is

    /**
    * @ORM\ManyToMany(targetEntity="App\Entity\Rank", inversedBy="members")
    */
    private $rank;

  • 2018-12-03 weaverryan

    Hey Thomas Talbot!

    Very fair question :). I would recommend 2 things:

    1) For test purposes, allow the Transformer to be injected into your form class via a setTransformer() method. That's a little weird - just because we're creating this method ONLY for test purposes, but it would work. There are probably a few other ways to do this, and they're all probably fine.

    2) Don't test your form class. This is actually what I would do. If you have heavy logic in your form class that you want to unit test, I would isolate that into its own class and test that instead. Also, eventually this form class WILL have a decent amount of logic - near the end of this tutorial we add some event listeners, etc. But, these *still* aren't great to unit test - they are small pieces that go into the overall picture of getting the form to function correctly. So, I would (A) isolate any logic that you *can* but ultimately (B) functionally test the form if you want to verify it's working.

    Cheers!

  • 2018-12-02 Thomas Talbot

    Hello!

    So, we use the constructor for passing options : each builded form has its own version of the Transformer.
    But, how to "test double" the Transformer ? :(
    Maybe use Prototype or service Factory ? 🤔

  • 2018-12-01 weaverryan

    Hey Matt!

    Wow, awesome question! Seriously - I wondered if someone would ask this - but so soon! I’m impressed :).

    These data transformers are strange objects in a sense: they’re like services, they do work and don’t hold much data, but with one practical difference: we need to be able to configure them dynamically based on the options based to the form. As you’ll see in the next chapter(s), we add a field option to control the query. The only way for the form to pass that option to the transformer is through the constructor. That’s why we instantiate it manually instead of allow the container to do it.

    To say it differently: when I coded up this tutorial, I DID first inject it via a type-hint. But once I needed to pass an option, I realized that wouldn’t work. It’s an odd situation, which causes this.

    Cheers!

  • 2018-11-30 Matt

    Hi Ryan!

    Why didn't you inject EmailToUserTransformer to UserSelectTextType and call $builder->addModelTransformer($this->emailToUserTransformer)?