Form Events & Dynamic ChoiceType choices

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

Let's focus on the edit form first - it'll be a little bit easier to get working. Go to /admin/article and click to edit one of the existing articles. So, based on the location, we need to make this specificLocationName field have different options.

Determining the specificLocationName Choices

Open ArticleFormType and go to the bottom. I'm going to paste in a function I wrote called getLocationNameChoices(). You can copy this function from the code block on this page. But, it's fairly simple: We pass it the $location string, which will be one of solar_system, star or interstellar_space, and it returns the choices for the specificLocationName field. If we choose "solar system", it returns planets. If we choose "star", it returns some popular stars. And if we choose "Interstellar space", it returns null, because we actually don't want the drop-down to be displayed at all in that case.

... lines 1 - 75
private function getLocationNameChoices(string $location)
{
$planets = [
'Mercury',
'Venus',
'Earth',
'Mars',
'Jupiter',
'Saturn',
'Uranus',
'Neptune',
];
$stars = [
'Polaris',
'Sirius',
'Alpha Centauari A',
'Alpha Centauari B',
'Betelgeuse',
'Rigel',
'Other'
];
$locationNameChoices = [
'solar_system' => array_combine($planets, $planets),
'star' => array_combine($stars, $stars),
'interstellar_space' => null,
];
return $locationNameChoices[$location];
}
... lines 107 - 108

Oh, and I'm using array_combine() just because I want the display values and the values set back on my entity to be the same. This is equivalent to saying 'Mercury' => 'Mercury'... but saves me some duplication.

Dynamically Changing the Options

The first step to get this working is not so different from something we did earlier. To start, forget about trying to use fancy JavaScript to instantly reload the specificLocationName drop-down when we select a new location. Yes, we are going to do that - but later.

Hit "Update" the save the location to "The Solar System". The first goal is this: when the form loads, because the location field is already set, the specificLocationName should show me the planet list. In other words, we should be able to use the underlying Article data inside the form to figure out which choices to use.

I'll add some inline documentation just to tell my editor that this is an Article object or null. Then, $location = , if $article is an object, then $article->getLocation(), otherwise, null.

... lines 1 - 24
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var Article|null $article */
$article = $options['data'] ?? null;
... line 29
$location = $article ? $article->getLocation() : null;
... lines 31 - 65
}
... lines 67 - 108

Down below, copy the entire specificLocationName field and remove it. Then only if ($location) is set, add that field. For choices, use $this->getLocationNameChoices() and pass that $location.

... lines 1 - 24
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 27 - 52
if ($location) {
$builder->add('specificLocationName', ChoiceType::class, [
'placeholder' => 'Where exactly?',
'choices' => $this->getLocationNameChoices($location),
'required' => false,
]);
}
... lines 60 - 65
}
... lines 67 - 108

Cool! Again, no, if we change the location field, it will not magically update the specificLocationName field... not yet, at least. With this code, we're saying: when we originally load the form, if there is already a $location set on our Article entity, let's add the specificLocationName field with the correct choices. If there is no location, let's not load that field at all, which means in _form.html.twig, we need to render this field conditionally: {% if articleForm.specificLocationName is defined %}, then call form_row().

{{ form_start(articleForm) }}
... lines 2 - 6
{% if articleForm.specificLocationName is defined %}
{{ form_row(articleForm.specificLocationName) }}
{% endif %}
... lines 10 - 15
{{ form_end(articleForm) }}

Let's try this! Refresh the page. The Solar System is selected and so... sweet! There is our list of planets! And we can totally save this. Yep! It saved as Earth. Open a second tab and go to the new article form. No surprise: there is no specificLocationName field here because, of course, the location isn't set yet.

Our system now... sort of works. We can change the data... but we need to do it little-by-little. We can go to "Near a Star", hit "Update" and then change the specificLocationName field and save that. But I can't do it all at once: I need to fully reload the page... which kinda sucks!

Can you Hack the Options to Work?

Heck, we can't even be clever! Change location to "The Solar System". Then, inspect element on the next field and change the "Betelgeuse" option to "Earth". In theory, that should work, right? Earth is a valid option when location is set to solar_system, and so this should at least be a hacky way to work with the system.

Hit Update. Woh! It does not work! We get a validation error: This value is not valid. Why?

Think about it: when we submit, Symfony first builds the form based on the Article data that's stored in the database. Because location is set to star in the database, it builds the specificLocationName field with the star options. When it sees earth being submitted for that field, it looks invalid!

Our form needs to be even smarter: when we submit, the form needs to realize that the location field changed, and rebuild the specificLocationName choices before processing the data. Woh.

We can do that by leveraging form events.

Leave a comment!

  • 2020-06-09 Victor Bocharsky

    Hey Raed,

    Woohoo! Thanks for your feedback about getting it working :)

    Cheers!

  • 2020-06-06 Zool

    Thanks Victor Bocharsky for your help ! Its working now, i think this is due a bug in my IDE, i just updated the composer and
    it's working, thanks again

  • 2020-06-04 Victor Bocharsky

    Hey Raed,

    Difficult to say, better to look at the error stacktrace. I suppose this error is not related to the code you pasted. The problem with that error is that somewhere in your code you're passing a variable to a method that is string instead of "App\Entity\User" object. It would be good to know the exact place, then you would be able to debug that string and see the exact value and figure out why it's so.

    Cheers!

  • 2020-06-03 Zool
    Expected argument of type "App\Entity\User or null", "string" given.

    Hello,
    I have this error when i try to update the page with the location, i checked the User entity but i could'not
    figure it out ! Any help would be much appreciated !

    Here is code for User entity:



    namespace App\Entity;

    use Doctrine\ORM\Mapping as ORM;
    use Doctrine\Common\Collections\Collection;
    use Doctrine\Common\Collections\ArrayCollection;
    use Symfony\Component\Serializer\Annotation\Groups;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
    use Symfony\Component\Validator\Constraints as Assert;


    /**
    * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
    * @UniqueEntity(fields={"email"}, message="I think you already registered!")
    */
    class User implements UserInterface
    {
    /**
    * @ORM\Id()
    * @ORM\GeneratedValue()
    * @ORM\Column(type="integer")
    */
    private $id;

    /**
    * @ORM\Column(type="string", length=180, unique=true)
    * @Groups("main")
    * @Assert\NotBlank(message="please enter an email")
    * @Assert\Email()
    */
    private $email;

    /**
    * @ORM\Column(type="json")
    */
    private $roles = [];

    /**
    * @ORM\Column(type="string", length=255, nullable=true)
    * @Groups("main")
    */
    private $firstName;

    /**
    * @ORM\Column(type="string", length=255)
    */
    private $password;

    /**
    * @ORM\Column(type="string", length=255, nullable=true)
    * @Groups("main")
    */
    private $twitterUsername;

    /**
    * @ORM\OneToMany(targetEntity="App\Entity\ApiToken", mappedBy="user", orphanRemoval=true)
    */
    private $apiTokens;

    /**
    * @ORM\OneToMany(targetEntity="App\Entity\Article", mappedBy="author")
    */
    private $articles;

    /**
    * @ORM\Column(type="datetime")
    */
    private $agreedTermAt;

    public function __construct()
    {
    $this->apiTokens = new ArrayCollection();
    $this->articles = new ArrayCollection();
    }

    public function getId(): ?int
    {
    return $this->id;
    }

    public function getEmail(): ?string
    {
    return $this->email;
    }

    public function setEmail(?string $email): self
    {
    $this->email = $email;

    return $this;
    }

    /**
    * A visual identifier that represents this user.
    *
    * @see UserInterface
    */
    public function getUsername(): string
    {
    return (string) $this->email;
    }

    /**
    * @see UserInterface
    */
    public function getRoles(): array
    {
    $roles = $this->roles;
    // guarantee every user at least has ROLE_USER
    $roles[] = 'ROLE_USER';

    return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
    $this->roles = $roles;

    return $this;
    }

    /**
    * @see UserInterface
    */
    public function getPassword()
    {
    return $this->password;
    }

    /**
    * @see UserInterface
    */
    public function getSalt()
    {
    // not needed when using bcrypt or argon
    }

    /**
    * @see UserInterface
    */
    public function eraseCredentials()
    {
    // If you store any temporary, sensitive data on the user, clear it here
    // $this->plainPassword = null;
    }

    public function getFirstName(): ?string
    {
    return $this->firstName;
    }

    public function setFirstName(?string $firstName): self
    {
    $this->firstName = $firstName;

    return $this;
    }

    public function setPassword(string $password): self
    {
    $this->password = $password;

    return $this;
    }

    public function getTwitterUsername(): ?string
    {
    return $this->twitterUsername;
    }

    public function setTwitterUsername(?string $twitterUsername): self
    {
    $this->twitterUsername = $twitterUsername;

    return $this;
    }

    public function getAvatarUrl(int $size = null): string
    {
    $url = 'https://robohash.org/'.$this->getEmail();

    if ($size) {
    $url .= sprintf('?size=%dx%d', $size, $size);
    }

    return $url;
    }

    /**
    * @return Collection|ApiToken[]
    */
    public function getApiTokens(): Collection
    {
    return $this->apiTokens;
    }

    public function addApiToken(ApiToken $apiToken): self
    {
    if (!$this->apiTokens->contains($apiToken)) {
    $this->apiTokens[] = $apiToken;
    //$apiToken->setUser($this);
    }

    return $this;
    }

    public function removeApiToken(ApiToken $apiToken): self
    {
    if ($this->apiTokens->contains($apiToken)) {
    $this->apiTokens->removeElement($apiToken);
    // set the owning side to null (unless already changed)
    if ($apiToken->getUser() === $this) {
    // $apiToken->setUser(null);
    }
    }

    return $this;
    }

    /**
    * @return Collection|Article[]
    */
    public function getArticles(): Collection
    {
    return $this->articles;
    }

    public function addArticle(?Article $article): self
    {
    if (!$this->articles->contains($article)) {
    $this->articles[] = $article;
    $article->setAuthor($this);
    }

    return $this;
    }

    public function removeArticle(?Article $article): self
    {
    if ($this->articles->contains($article)) {
    $this->articles->removeElement($article);
    // set the owning side to null (unless already changed)
    if ($article->getAuthor() === $this) {
    $article->setAuthor(null);
    }
    }

    return $this;
    }

    public function __toString()
    {
    return $this->getFirstName();
    }

    public function getAgreedTermAt(): ?\DateTimeInterface
    {
    return $this->agreedTermAt;
    }

    public function agreedTerms(): self
    {
    $this->agreedTermAt = new \DateTime();

    return $this;
    }
    }

    ArticleFormType
    class ArticleFormType extends AbstractType{

    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
    $this->userRepository = $userRepository;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    //dd($options);
    //If you don't know that syntax, it basically says that I want the $article variable to be
    //equal to $options['data'] if it exists and is not null. But if it does not exist,
    /** Var Article|null $article */
    $article = $options['data'] ?? null;
    $isEdit = $article && $article->getId();
    $location = $article ? $article->getLocation() : null;

    $builder
    ->add('title', TextType::class,[
    'help'=>'Choose something checky!',
    ])
    ->add('content')
    ->add('author', EntityType::class,[
    'class' => User::class,
    //'choice_label'=>'email', //to the email of users, and the same for other fields, we can even return a function
    'choice_label'=> function(User $user){
    return sprintf('(%d) %s', $user->getId(), $user->getEmail());
    },
    'placeholder'=>'Choose a user',
    'choices'=> $this->userRepository->findAllEmailWithAlphabetical(),
    'disabled'=> $isEdit,
    ])
    ->add('author', UserSelectTextType::class)
    ->add('location',ChoiceType::class,[
    'placeholder'=> 'Choose a location',
    'required' => false,
    'choices'=>[
    'The Solar System' => 'solar_system',
    'Near a star' => 'star',
    'Interstellar Space' => 'interstellar_space'
    ],
    ])

    ;

    if($location){
    $builder->add('specificLocationName',ChoiceType::class,[
    'placeholder' => 'Where exactly?',
    'choices'=> $this->getLocationNameChoices($location),
    'required'=>false,

    ]);
    }

    if ($options['include_published_at']) {
    $builder->add('publishedAt', null, [
    'widget' => 'single_text',
    ]);
    }

    }


    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefaults([
    'data_class' => Article::class,
    'include_published_at' =>false, //as we dont declare a such var we put it here too
    ]);
    }



    private function getLocationNameChoices(string $location)
    {

    //array_combine — Creates an array by using one array for keys and another for its values
    $planets = [
    'Mercury',
    'Venus',
    'Earth',
    'Mars',
    'Jupiter',
    'Saturn',
    'Uranus',
    'Neptune',
    ];
    $stars = [
    'Polaris',
    'Sirius',
    'Alpha Centauari A',
    'Alpha Centauari B',
    'Betelgeuse',
    'Rigel',
    'Other'
    ];
    $locationNameChoices = [
    'solar_system' => array_combine($planets, $planets),
    'star' => array_combine($stars, $stars),
    'interstellar_space' => null,
    ];
    return $locationNameChoices[$location];
    }
    }
  • 2019-11-05 Diego Aguiar

    Ahh I get it now and I would say that it's not an expected or common behavior. It does not happen in my browser so it might be your browser's cache our maybe an addon of it

  • 2019-11-05 Niki

    You didn't understand me right. The data is saving, but if a edit is made on the form but the save button is not clicked, after refresh the new data is shown. If I force refresh the page it shows the original data from the database.

  • 2019-11-05 Diego Aguiar

    Hey Niki

    That's odd, makes me thinkg that the data didn't save after edit. Could you try it again but before doing a hard refresh, check the data in the DB and corroborate that it indeed saved?

    Cheers!

  • 2019-11-05 Niki

    I have a problem when refresh. The thing is:
    When I click edit and the form appear all the saved data is shown. If I edit any field and refresh the page the data i've provided is displayed, but after force refresh the original saved data is displayed from the database. Is this an issue or it's handled by cache?

  • 2019-10-29 Niki

    Ah thanks, I had forgot it, so I recreated the entities via make:entity and made the relations and now everything works fine.

  • 2019-10-28 weaverryan

    Hey Niki

    Sorry for the slow reply :). So, is the licensed_products property on your entity a relation field (e.g. OneToMany or ManyToMany)? If so, did you remember to initialize the property to an ArrayCollection in the entity's constructor? For example:


    class YourEntityClass
    {
    private $licensed_products;

    public function __construct()
    {
    // check that this line is there!
    $this->licensed_products = ArrayCollection();
    }
    }

    Let me know if that's the problem :). If not, if you could share your entity code, that might help!

    Cheers!

  • 2019-10-22 Niki

    How can I save builder EntityType field with multiple selected options to the database (I use mysql 5.7) and when displaying edit form the "select multiple" field to have those options highlighted (with selected attribute)? I load a list of products via 'class' but on after selecting something and hit save i get error:
    "Unable to transform value for property path "licensed_products": Expected a Doctrine\Common\Collections\Collection object."

    This is the code from my builder:

    ->add('licensed_products', EntityType::class,[
    'class' => Product::class,
    'choice_label' => function($choice){
    return $choice->getProductName();
    },
    'multiple' => true
    ])

    licensed_products is a string currently.