Many To Many With The EntityType Form Field


In this video we will build upon what we already know about the ChoiceType field, and take it one step further by using the EntityType field, a field type that has all the base functionality of ChoiceType, but specialises in loading choices / options from a Doctrine entity.

Please note, all the code for this example is at the bottom of this page.

Now, more often than not, this is one of the most useful form field types available to you when working in real world applications. In a good number of situations, you will need to show a dynamic list of options to your end users - and loading those options from records stored in your database is a really common need.

To make this example a little more interesting, I am going to switch out the previous example we have been using (X-Wing vs Tie-Fighters), to a Street Fighter battle matcher. I know, I know, I bet you are falling off your chair in excitement.

The reasoning for this is that I want to show how a many-to-many setup may work, allowing multiple battles to be added to the system, each containing a pair of fighters. The fighters can appear in zero, one, or more battles.

Switching To Dynamic Choices

In all of the previous videos we have been using a static list of choices. This began with booleans, then moved on to multiple strings.

On the back end, we initially stored off our submitted value as a simple boolean. Then we moved on to storing whatever string had been chosen. And finally we covered saving an array of data, serialised, or as JSON.

This is great, and it works, but particularly when we had allowed multiple choices the resulting data became hard to use outside of our application. That is to say, querying against serialised arrays is not how anyone wants to spend their days. And if you are working in a larger organisation, potentially some other part of your team (maybe a Business Intelligence chap, or SQL boffin) may want direct access to the data. It's much easier if they can query directly, rather than bother you to write some code, which you then have to maintain and spin up infrastructure for.

Let's resolve this problem.

Without realising there is the EntityType available, it would seem to make sense to pass in some choices to our form type.

This leads to situations where we might think about running some query in our controller, then new'ing up the form type, and either injecting the choices via the constructor, or passing them in using a setter... eurghh.

Thankfully, Symfony 3 makes this a lot more difficult to do than it was in Symfony 2. With the best will and intentions, the above quickly leads to a big old mess. Generally, if something is difficult to do, let that be a loud claxon sounding the warning signal.

Instead, we need to replace our ChoiceType with the more specialised EntityType. As the name implies, this is a form field that is designed to work with Doctrine entities.

At this point, we no longer need a choices option inside our form type, so let's get rid of that:

// - use Symfony\Bridge\Doctrine\Form\Type\ChoiceType; // remove this
use Symfony\Bridge\Doctrine\Form\Type\EntityType; // add this

class BattleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ->add('fighters', ChoiceType::class, [ // remove this
            ->add('fighters', EntityType::class, [ // add this
                'label'     => 'Who is fighting in this round?',
                'expanded'  => true,
                'multiple'  => true,
            ])
            ->add('save', SubmitType::class)
        ;
    }

    // * snip *

And now we need to tell the form field where it can get its list of choices / options from.

As the entity field type specialises in dealing with entities, unsurprisingly it comes with a few extra form options to make this very easy:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('fighters', EntityType::class, [
            'class'        => 'AppBundle:Fighter',
            'choice_label' => 'name',
            'label'        => 'Who is fighting in this round?',
            'expanded'     => true,
            'multiple'     => true,
        ])

So long as we follow the convention of keeping our entities inside the YourBundle\Entity directory, you can use the short hand entity naming syntax, meaning AppBundle\Entity\Fighter can be shortend to AppBundle:Fighter. Passing in the fully qualified class name is also fine.

The choice_label is also useful - it allows you to define which property from your entity to use as the visible text on the front end that the end user can choose from.

Now, one thing to note here is that the way this property will be accessed is via a get'ter, so be sure to have a matching get method for whatever property you specify as the choice_label. In other words, in our case we specify name, so we must have a getName method, or you will get a NoSuchPropertyException. Thankfully, the error message that Symfony throws is also fairly clear:

Neither the property "name" nor one of the methods "getName()", "name()", "isName()", "hasName()", "__get()" exist and have public access in class "AppBundle\Entity\Fighter". 500 Internal Server Error - NoSuchPropertyException

At this stage you should now have a working form once again.

Whatever content you have in your database for the fighters table will be used as the available options in your form. Pretty cool.

This is because by default, a query will be run to figure out what options to display and use for your form:

SELECT f0_.id AS id_0, f0_.name AS name_1 FROM fighters f0_ 

This might be good enough, or you might want to be more specific, writing your own query logic instead.

Again, the EntityType has you covered:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('fighters', EntityType::class, [
            'class'     => 'AppBundle:Fighter',
            'choice_label' => 'name',
            'query_builder' => function (EntityRepository $repo) {
                return $repo->createQueryBuilder('f')
                    ->where('f.id > :id')
                    ->setParameter('id', 1);
            },
            'label'     => 'Who is fighting in this round?',
            'expanded'  => true,
            'multiple'  => true,
        ])
        ->add('save', SubmitType::class)
    ;
}

The query_builder method allows you to write your own query using Doctrine's QueryBuilder. If this sounds new to you, be sure to check out this tutorial series, where we have covered this in more detail already.

All we need to do is create a function with the exact signature as above:

'query_builder' => function (EntityRepository $repo) {
    return $repo->createQueryBuilder('some_alias')
      // rest of your logic here
},

You don't need to worry about how the EntityRepository gets there. All you need to know is that the given EntityRepository will match whatever class you specified already.

And with that the bulk of the hard work is done.

Validating Responses

In this particular example it seems logical to only allow a battle to have two possible fighters. No more, no less.

If you have been following along then you will have already seen how we can add validation to our forms.

If not, then not to worry, adding in some validation rules is simple enough. We just need to add in the appropriate use statement to our entity, and choose the assertion that best meets our needs.

Now, rather unintuitively, we need the Count validation constraint, rather than the more seemingly immediately appropriate Choice constraint. So be it.

// /src/AppBundle/Entity/Battle.php

    /**
     * @ORM\ManyToMany(targetEntity="Fighter")
     * @ORM\JoinColumn(name="fighter_id", referencedColumnName="id")
     * @Assert\Count(min="2", max="2")
     */
    protected $fighters;

I'm not going to bother updating the error message, but feel free to do so if you so desire.

Towards the end of this series we will cover a more advanced validation set up, but for now, I hope you will agree it really doesn't get much easier than that.

Reloading Data On To Your Form

The last point to cover is: how do we reload our saved data when using the EntityType form field?

Well, the good news is - exactly the same way as in any of the previous examples.

Remember a few videos back when we created a new Product() in our Controller, and then called the setChoice method - which in turn would change the default values on our form?

This exact same process is used to reload data onto a form. It doesn't matter if we are reloading existing data, or creating a new entity and setting up some fields as 'default' values. The principle is indentical.

Our form shows a HTML representation of the passed in object. So long as the object is valid, the form cannot do anything other than display the given data. Simple, in some respects :)

In the code sample below you will find the form_edit_example controller action. Try this yourself - create a few battles using the form_add_example, and then use the created ID's to reload the submitted data.

I know I keep covering this point over and over, but that's because when I was first learning Symfony I found this one of the hardest things to wrap my head around.

Anyway, here are the code changes:

Battle entity

<?php

// /src/AppBundle/Entity/Battle.php

namespace AppBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity
 * @ORM\Table(name="battles")
 */
class Battle
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Fighter")
     * @ORM\JoinColumn(name="fighter_id", referencedColumnName="id")
     * @Assert\Count(min="2", max="2")
     */
    protected $fighters;

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

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getFighters()
    {
        return $this->fighters;
    }

    /**
     * @param mixed $fighters
     * @return Battle
     */
    public function setFighters($fighters)
    {
        $this->fighters = $fighters;

        return $this;
    }
}

Fighter entity

<?php

// /src/AppBundle/Entity/Fighter.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity
 * @ORM\Table(name="fighters")
 */
class Fighter
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param mixed $name
     * @return Fighter
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }
}

Battle Form Type

<?php

// /src/AppBundle/Form/Type/BattleType.php

namespace AppBundle\Form\Type;

use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class BattleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('fighters', EntityType::class, [
                'class'     => 'AppBundle:Fighter',
                'choice_label' => 'name',
                'query_builder' => function (EntityRepository $repo) {
                    return $repo->createQueryBuilder('f')
                        ->where('f.id > :id')
                        ->setParameter('id', 1);
                },
                'label'     => 'Who is fighting in this round?',
                'expanded'  => true,
                'multiple'  => true,
            ])
            ->add('save', SubmitType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Battle',
        ]);
    }
}

Battle Controller

<?php

// /src/AppBundle/Controller/BattleController.php

namespace AppBundle\Controller;

use AppBundle\Entity\Battle;
use AppBundle\Form\Type\BattleType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class BattleController extends Controller
{
    /**
     * @Route("/", name="form_add_example")
     */
    public function formAddExampleAction(Request $request)
    {
        $form = $this->createForm(BattleType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $em = $this->getDoctrine()->getManager();

            $battle = $form->getData();

            $em->persist($battle);
            $em->flush();

            $this->addFlash('success', 'We saved a battle with id ' . $battle->getId());
        }

        return $this->render(':form-example:index.html.twig', [
            'myForm' => $form->createView()
        ]);
    }

    /**
     * @Route("/edit/{battle}", name="form_edit_example")
     */
    public function formEditExampleAction(Request $request, Battle $battle)
    {
        $form = $this->createForm(BattleType::class, $battle);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $em = $this->getDoctrine()->getManager();
            $em->flush();

            $this->addFlash('info', 'We edited a battle with id ' . $battle->getId());

            return $this->redirectToRoute('form_add_example');
        }

        return $this->render(':form-example:index.html.twig', [
            'myForm' => $form->createView()
        ]);
    }
}

Code For This Course

Get the code for this course.

Episodes