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!

14
Login or Register to join the conversation
Nick F. Avatar

NoSuchPropertyException
HTTP 500 Internal Server Error
Could not determine access type for property "id" in class "App\Entity\Question".
in \vendor\symfony\property-access\PropertyAccessor.php:533
when saving after removing one of the answers from the question.

Seemed to have solved it by calling hideWhenUpdating() on Question's IdField.

Reply

Hey Name->Nick!

Hmm, that is super weird. I'm not sure what would cause this (it seems like something is trying to *write* to the id property of Question)... or why hideWhenUpdating() seems to help... but I'm glad you at least got a workaround :).

Cheers!

Reply
Alessandro D. Avatar
Alessandro D. Avatar Alessandro D. | posted 5 months ago

Hi,
I am facing an related to this CollectionField I cannot find an answer for.
I have a project entity that has a OneToMany relationship with the entity Roles. In my ProjectCrudController. I have added the following:


CollectionField::new('roles')
->setEntryType(RoleType::class)
->setTemplatePath('easyAdmin/role/add.html.twig'). // <= this line does not work

Basically, I created a RoleType inside my Form Folder so. that I can decide what items to display, but the issue I am facing is that I cannot find a way to specify which twig template to use so that I can customise how those fields get displayed in the collection.
I have tried setTemplatePath but that doesn't work (I did make sure that 'easyAdmin/role/add.html.twig' do exists inside my symfony templates directory, but this small thing is driving me crazy).

Any idea on how I could achieve the above?

Thanks,
Alessandro

Reply

Hey Alessandro,

Hm, could you share the exact error you see when you use that template path? The "setTemplatePath()" method is the correct method name for it, so most probably you misprinted the template path, or you read the error message incorrectly. First of all, let's double-check the template path, are you sure you have that "templates/easyAdmin/role/add.html.twig" template path? i.e. the template name "easyAdmin/role/add.html.twig" you set to that method should be in the templates/ folder. Also, make sure your letter case is correct, i.e. you should match the letter case in path with one in your file system. And if everything looks good - please, try to clear the cache, do "rm -rf var/cache/*" just in case and try again. If still does not work - share the exact error message please.

Cheers!

Reply
Alessandro D. Avatar
Alessandro D. Avatar Alessandro D. | victor | posted 5 months ago

Hi Victor,
The problem is that I do not receive any error message. When I load the Project New Form, I can see the 'Roles' section with 'Add a new Item' and when I click on it it display whatever fields I have set in my RoleType custom form. My RoleType form class is as followed:


namespace App\Form\EasyAdmin;

use App\Entity\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class RoleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('character_name', TextType::class, [
'label' => 'Character Name *',
'label_html' => true
])
->add('gender', ChoiceType::class, [
'choices' => [
'Choose...' => '',
'Male' => 'male',
'Female' => 'female',
],
'label' => 'Gender *',
'label_html' => true
])
->add('type', TextType::class, [
'label' => 'Type *',
'label_html' => true
])
->add('playing_age')
->add('dialect')
->add('description')
->add('number_of_poses')
->add('height')
->add('appearance')
->add('fee')
->add('notes')
;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Role::class,
]);
}
}

I can confirm that easyAdmin/role/add.html.twig do exists inside my 'templates' folder and I also tried to empty my var/cache folder, but no luck there.

The way I thought logically it was going to work was:
CrudController -> EntryType -> Twig Template -> Front End

Although what I couldn't figure out was that, normally, when I load a custom FormType in a normal controller, I do set up the name of the Form Object (my_form) I am going to use from inside my twig template (to be used like {{ my_form.my_field }}, as oppose in here I do not seem to have the ability to do so, as I am going from the CrudController into the FormType. In the CrudController, if I am going to load my own custom twig template, how can I address the actual form object from within my FormType (I hope it makes sense)

Alessandro

Reply
Colin-P Avatar

Hello Diego,

I want to thank you for creating Easy Admin. I really love using it, and I'm grateful for this tutorial.

I have a question about collection fields that I was hoping you could answer for me:

When the entry type for a collection field has an entity type field, is it possible to have the embedded form as a TomSelect (like the Association Field), rather than a regular 'select' element? I've tried searching for this but haven't been able to find the answer.

Thank you,
Colin

Reply

Hey Colin,

First of all, thank you for your kind feedback about this course and the bundle in general, that's really made our day!

About your question, hm, are you sure you want to use exactly TomSelect? Looks like behind the scene it uses a simple input field as I see form its docs. I'd suggest you to take a look at this library instead: https://github.com/select2/... - it's based on a select tag which is exactly what you need I think. And so, you will only need to render the list of entities as a simple select tag - Symfony already can do this for you, then add some CSS classes to it and some glue select2 JS code, and it should work as you want I suppose.

I hope this helps!

Cheers!

Reply
Colin-P Avatar

Hey Victor,

I don't actually want to exactly use TomSelect. I just want to know if I use the CollectionField in the crud controller of an entity, and the embedded form type contains an entity field, can Easy Admin create this field the same way it would an Association field?

In a more general sense, can the CollectionField wrap other Easy Admin field types?

In the documentation for example, you see a collection type where the entry type is some sort of User type (has the fields fullName, username, and email. https://symfony.com/doc/4.x...

If the User type also had a field called 'department' which is a relation to a Department entity, this gets rendered as an HTML select element in the embedded form. Can Easy Admin handle making this an Association field (so that I could use the autocomplete feature)?

SymfonyCasts makes my job so much easier. Thanks again to you and the team. Hope everyone is able to stay safe.

Colin

Reply

Hey Colin,

Hm, I see... I don't have a simple yes/no answer actually because I've never tried this before, did you try to do this? It's something I've never thought about, but it might work from the first sight. Please, try this. I suppose you need to take a look at the AssociationField. You're right, it's used "EntityType::class" behind the scene, but I suppose you will need to add more CSS classes to match the EasyAdmin render of that field. So, it might work, but I'm not sure, you need to try.

What about AssociationField instead of EntityType directly in the CollectionType - hm, you can try this too, but I suppose it won't work because AssociationField class is an EastAdmin's "field" when EntityType is a Symfony form type - so, nevertheless that AssociationField uses EntityType behind the scene, those are different concepts unfortunately. So, I don't think you can easily replace EntityType with AssociationField unfortunately.

I hope this helps!

Cheers!

Reply
Colin-P Avatar

Hey Viktor,

yes I have been trying this out but haven't found what I'm looking for. I guess I'm just hoping that a CollectionField in EasyAdmin could contain other EasyAdmin fields.

So if I had the following entities, Person, Occupation, and PersonOccupation
Person has a string field called 'name', and a one-to-many relation field to PersonOccupation called 'occupations',
Occupation has a string field called 'title'
PersonOccupation has a many-to-one relation to person, a many-to-one relation field to occupation and a date field called 'startDate'.

Now, if I made the crud controller for Person, and configured it so that occupations was a collection field, for each sub-form of PersonOccupation, I'd like the 'Occupation' field to be an autocomplete/Assocation field, just as if I created the crud controller for PersonOccupation, and configured the fields so that occupation is an Association field. But they just show up as a select field.

I'm sure I'm doing something incorrectly or there's a good reason for it not working this way. I just wanted to get my thoughts out there so that I can understand a bit better.

thanks,
Colin

Reply

Hey Colin,

Ah, I see. Well, I'm not sure how to easily achieve what you want fairly speaking. If it does not work out of the box with default configuration - I'm not sure. Well, you can try to look at the field where you have autocompletetion, and add the same CSS classes, etc. It may bring some JS functionality there I suppose, but I'm not sure it will work with AJAX autocompletion, probably only filter the select options.

Cheers!

Reply
PXLWIDGETS Avatar
PXLWIDGETS Avatar PXLWIDGETS | posted 6 months ago

Hi! Great tutorial so far :)

I have a question: I have a many to many relation, with additional data, like here ( https://symfonycasts.com/sc... )

I cannot (yet) figure out to edit that. In my case I have a product, which has a relation with a service. The additional data is a service fee. How would I go about making that editable on the product page? I've been messing around with a custom field, but no success so far.

Reply

Hey Thom van der Veldt

I'm afraid you'll have to refactor your ManyToMany approach because you're adding extra fields to the extra table that holds the relationship between your objects, you may want to watch this chapter https://symfonycasts.com/sc...

So, what you need to do is to create another entity that will work as an intermediate table for your other 2 entities, you can add as many fields you want to that new entity class, and then, create an EasyAdmin CRUD controller to handle it.

Cheers!

Reply
PXLWIDGETS Avatar

Hi Diego!

I did do that yes, the ease solution for me would have been to create an additional CRUD controller for the intermediate table. For my clients convenience however, I was trying to add it to the product page. Which did end up working!


if ($pageName === 'new') {
$product = new Product();

$serviceTypes = $this->doctrine->getRepository(ServiceType::class)->findAll();

foreach ($serviceTypes as $serviceType) {
$productServiceFee = (new ProductServiceFee())
->setProduct($product)
->setServiceType($serviceType);
$product->addProductServiceFee($productServiceFee);
}

$this->getContext()->getEntity()->setInstance($product);
}

yield CollectionField::new('productServiceFees')
->allowAdd(true)
->allowDelete(false)
->setEntryIsComplex(true)
->renderExpanded()
->setEntryType(ProductServiceFeeType::class);

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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