Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

AssociationField for a "Many" Collection

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

There's one other AssociationField that I want to include inside this CRUD section and it's an interesting one: $answers. Unlike $topic and $answeredBy, this is a Collection: each Question has many answers:

... lines 1 - 13
class Question
{
... lines 16 - 41
#[ORM\OneToMany('question', Answer::class)]
private Collection $answers;
... lines 44 - 206
}

Back in QuestionCrudController, yield AssociationField::new('answers'):

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 42
yield AssociationField::new('answers');
... lines 44 - 45
}
}

And.... let's just see what happens! Click back to the index page and... Awesome! It recognizes that it's a Collection and prints the number of answers that each Question has... which is pretty sweet. And if we go to the form, I'm really starting to like this error! The form is, once again, trying to get a string representation of the entity.

Configuring the choice_label Field Option

We know how to fix this: head over to Answer.php and add the __toString() method. But, there's actually one other way to handle this. If you're familiar with the Symfony Form component, then this problem of converting your entity into a string is something that you see all the time with the EntityType. The two ways to solve it are either to add the __toString() method to your entity, or pass your field a choice_label option. We can do that here thanks to the ->setFormTypeOption() method.

Before we fill that in, open up the AssociationField class... and scroll down to new. Behind the scenes, this uses the EntityType for the form. So any options EntityType has, we have. For example, we can set choice_label, which accepts a callback or just the property on the entity that it should use. Let's try id:

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 42
yield AssociationField::new('answers')
->setFormTypeOption('choice_label', 'id');
... lines 45 - 46
}
}

And now... beautiful! The ID isn't super clear, but we can see that it's working.

by_reference => false

Let's... try removing a question! Remove "95", hit "Save and continue editing" and... uh. Absolutely nothing happened? Answer id "95" is still there!

If you're familiar with collections and the Symfony Form component, you might know the fix. Head over and configure one other form type option called by_reference set to false:

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 42
yield AssociationField::new('answers')
->setFormTypeOption('choice_label', 'id')
->setFormTypeOption('by_reference', false);
... lines 46 - 47
}
}

I won't go into too much detail, but basically, by setting by_reference to false, if an answer is removed from this question, it will force the system to call the removeAnswer() method that I have in Question:

... lines 1 - 13
class Question
{
... lines 16 - 163
public function removeAnswer(Answer $answer): self
{
if ($this->answers->removeElement($answer)) {
// set the owning side to null (unless already changed)
if ($answer->getQuestion() === $this) {
$answer->setQuestion(null);
}
}
return $this;
}
... lines 175 - 206
}

That method properly removes the Answer from Question. But more importantly, it sets $answer->setQuestion() to null, which is the owning side of this relationship... for you Doctrine geeks out there.

orphanRemoval

Ok, try removing "95" again and saving. Hey! We upgraded to an error!

An exception occurred ... Not null violation: ... null value in column question_id of relation answer...

So what happened? Open Question.php back up. When we remove an answer from Question, our method sets the question property on the Answer object to null. This makes that Answer an orphan: its an Answer that is no longer related to any Question.

However, inside Answer, we have some code that prevents this from ever happening: nullable: false:

... lines 1 - 10
class Answer
{
... lines 13 - 23
#[ORM\JoinColumn(nullable: false)]
private ?Question $question;
... lines 26 - 92
}

If we ever try to save an Answer without a Question, our database will stop us.

So we need to decide what should happen when an answer is "orphaned". In some apps, maybe orphaned answers are ok. In that case, change to nullable: true and let it save. But in our case, if an answer is removed from its question, it should be deleted.

In Doctrine, there's a way to force this and say:

If an Answer ever becomes orphaned, please delete it.

It's called "orphan removal". Inside of Question, scroll up to find the $answers property... here it is. On the end, add orphanRemoval set to true:

... lines 1 - 13
class Question
{
... lines 16 - 41
#[ORM\OneToMany('question', Answer::class, orphanRemoval: true)]
private Collection $answers;
... lines 44 - 206
}

Now refresh and... yes! It worked! The "95" is gone! And if you looked in the database, no answer with "ID 95" would exist. Problem solved!

Customizing the AssociationField

The last problem with this answers area is the same problem we have with the other ones. If we have many answers in the database, they're all going to be loaded onto the page to render the select. That's not going to work, so let's add ->autocomplete():

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 42
yield AssociationField::new('answers')
->autocomplete()
... lines 45 - 48
}
}

When we refresh, uh oh!

Error resolving CrudAutocompleteType: The option choice_label does not exist.

Ahhh. When we call ->autocomplete(), this changes the form type behind AssociationField. And that form type does not have a choice_label option! Instead, it always relies on the __toString() method of the entity to display the options, no matter what.

No big deal. Remove that option:

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 42
yield AssociationField::new('answers')
->autocomplete()
->setFormTypeOption('by_reference', false);
... lines 46 - 47
}
}

You can probably guess what will happen if we refresh. Yup! Now it's saying:

Hey Ryan! Go add that __toString() method!

Ok fine! In Answer, anywhere in here, add public function __toString(): string... and return $this->getId():

... lines 1 - 10
class Answer
{
... lines 13 - 93
public function __toString(): string
{
return $this->getId();
}
}

Now... we're back! And if we type... well... the search isn't great because it's just numbers, but you get the idea. Hit save and... nice!

Next, let's dig into the powerful Field Configurators system where you can modify something about every field in the system from one place. It's also key to understanding how the core of EasyAdmin works.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}