Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

The AssociationField

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 configure the fields for some of our other CRUD controllers. Go to the "Questions" page. This shows the default field list. We can do better. Open QuestionCrudController, uncomment configureFields(), and then... let's yield some fields! I'm going to write that down in my poetry notebook.

Let's yield a field for IdField... and call ->onlyOnIndex(). Then yield Field::new('name'):

... lines 1 - 6
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
class QuestionCrudController extends AbstractCrudController
{
... lines 12 - 16
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')
->onlyOnIndex();
yield Field::new('name');
... lines 22 - 24
}
}

Yea, yea... I'm being lazy. I'm using Field::new() and letting it guess the field type for me. This should be good enough most of the time, unless you need to configure something specific to a field type.

Copy that... and paste this two more times for votes and createdAt. For createdAt, don't forget to add ->hideOnForm():

... lines 1 - 9
class QuestionCrudController extends AbstractCrudController
{
... lines 12 - 16
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')
->onlyOnIndex();
yield Field::new('name');
yield Field::new('votes');
yield Field::new('createdAt')
->hideOnForm();
}
}

Cool! Find your browser, refresh and... good start!

More Field Configuration

There are a lot of things that we can configure on these fields, and we've already seen several. If you check the auto-completion, wow! That's a great list: addCssClass(), setPermission() (which we'll talk about later) and more. We can also control the field label. Right now, the label for votes is... "Votes". Makes sense! But we can change that with ->setLabel('Total Votes').

Or, "label" is the second argument to the new() method, so we could shorten this by passing it there:

... lines 1 - 9
class QuestionCrudController extends AbstractCrudController
{
... lines 12 - 16
public function configureFields(string $pageName): iterable
{
... lines 19 - 21
yield Field::new('votes', 'Total Votes');
... lines 23 - 24
}
}

And... that works perfectly! But I think these numbers would look better if they were right-aligned. That is, of course, another method: ->setTextAlign('right'):

... lines 1 - 9
class QuestionCrudController extends AbstractCrudController
{
... lines 12 - 16
public function configureFields(string $pageName): iterable
{
... lines 19 - 21
yield Field::new('votes', 'Total Votes')
->setTextAlign('right');
... lines 24 - 25
}
}

This... yup! Scooches our numbers to the right!

These are just a few examples of the crazy things you can do when you configure each field. And of course, many field classes have more methods that are specific to them.

Back on the question section, let's edit one of these. Not surprisingly, it just lists "Name" and "Total Votes". But our Question entity has more fields that we want here, like the $question text itself... and $askedBy and $topic which are both relationships:

... lines 1 - 13
class Question
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id;
#[ORM\Column]
private ?string $name;
/**
* @Gedmo\Slug(fields={"name"})
*/
#[ORM\Column(length: 100, unique: true)]
private ?string $slug;
#[ORM\Column(type: Types::TEXT)]
private ?string $question;
#[ORM\ManyToOne(inversedBy: 'questions')]
#[ORM\JoinColumn(nullable: false)]
private User $askedBy;
#[ORM\Column]
private int $votes = 0;
#[ORM\OneToMany('question', Answer::class)]
private Collection $answers;
#[ORM\ManyToOne(inversedBy: 'questions')]
#[ORM\JoinColumn(nullable: false)]
private Topic $topic;
#[ORM\Column]
private bool $isApproved = false;
#[ORM\ManyToOne]
private User $updatedBy;
... lines 54 - 206
}

Back in QuestionCrudController, the question field will hold a lot of text, so it should be a textarea. For this, there is a (surprise!) TextareaField. Yield TextareaField::new('question')... and then ->hideOnIndex():

... lines 1 - 8
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
class QuestionCrudController extends AbstractCrudController
{
... lines 13 - 17
public function configureFields(string $pageName): iterable
{
... lines 20 - 22
yield TextareaField::new('question')
->hideOnIndex();
... lines 25 - 28
}
}

Because we definitely do not want a wall of text in the list.

Back on the form... excellent!

Hello AssociationField

Let's do the $topic field! This is an interesting one because it's a relation to the Topic entity. How can we handle that in EasyAdmin? With the super powerful AssociationField. Yield AssociationField::new() and pass topic:

... lines 1 - 6
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
... lines 8 - 11
class QuestionCrudController extends AbstractCrudController
{
... lines 14 - 18
public function configureFields(string $pageName): iterable
{
... lines 21 - 22
yield AssociationField::new('topic');
... lines 24 - 30
}
}

That's it!

Click "Questions" to go back to the index page. Hmm. We do have a "Topic" column, but it's not very descriptive. It's just "Topic" and then the ID. And if you click to edit a question, it explodes!

Object of class App\Entity\Topic could not be converted to string

On both the index page and on the form, it's trying to find a string representation of the Topic object. On the index page, it guesses by using its id. But on the form... it just... gives up and explodes. The easiest way to fix this is to open the Topic entity and add a __toString() method.

Scroll down a bit... and, after the __construct method, add public function __toString(), which will return a string. Inside return $this->name:

... lines 1 - 10
class Topic
{
... lines 13 - 28
public function __toString(): string
{
return $this->name;
}
... lines 33 - 79
}

Now when we refresh... got it! And check it out! It renders a really cool select element with a search bar on it. For free? No way!

The important thing to know about this is that it's really just a select element that's made to look and work fabulously. But when you type, no AJAX calls are made to build the list. All of the possible topics are loaded onto the page in the HTML. And then this JavaScript widget helps you select them.

And over on the index page for Questions, our __toString() method now gives us better text in the list. And EasyAdmin even renders a link to jump right to that Topic.

The only problem is that, when we click, it's busted! It goes to the "detail" action of TopicCrudController... which we disabled earlier. Whoops. In a real app, you probably won't disable the "detail" action... it's pretty harmless. So I'm not going to worry about this. But you could argue that this is a tiny bug in EasyAdmin because it doesn't check the permissions correctly before generating the link.

Anyways, let's repeat this AssociationField for the $askedby property in Question, which is another relationship. Over in the controller, down near the bottom... because it's less important... yield AssociationField::new('askedBy'):

... lines 1 - 11
class QuestionCrudController extends AbstractCrudController
{
... lines 14 - 18
public function configureFields(string $pageName): iterable
{
... lines 21 - 28
yield AssociationField::new('askedBy');
... lines 30 - 31
}
}

As soon as we do that, it shows up the index page... but just with the id... and on the form, we get the same error. No problem. Pop open User... I'll scroll up, then add public function __toString(): string... and return $this->getFullName():

... lines 1 - 15
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 18 - 65
public function __toString(): string
{
return $this->getFullName();
}
... lines 70 - 286
}

Back over on the form... nice! It's way at the bottom, but works great!

Adding some Field Margin

Well, it's so far at the bottom that there's not much space! It's hard to see the entire list of users. Let's add some "margin-bottom" to the page. We can do that very easily now thanks to the assets/styles/admin.css file.

Let's do some digging. Ah! There's a section up here called main-content, which holds this entire body area. This time, instead of overriding a CSS property - since there is no CSS property that controls the bottom margin for this element - we can do it the normal way. Add .main-content with margin-bottom: 100px:

... line 1
.main-content {
margin-bottom: 100px;
}

Let's check it! Refresh. Ah, that's much better! If the change didn't show up for you, try a force refresh.

Ok, the AssociationField is great. But ultimately, what it renders is just a fancy-looking select field... which means that all the users in the entire database are being rendered into the HTML right now. Watch! I'll view the page source, and search for "Tisha". Yup! The server loaded all of the options onto the page. If you only have a few users or topics, no biggie!. But in a real app, we're going to have hundreds, thousands, maybe even millions of users, and we cannot load all of those onto the page. That will absolutely break things.

But no worries: the AssociationField has a trick up its sleeve.

Leave a comment!

10
Login or Register to join the conversation
Dmitry-K Avatar
Dmitry-K Avatar Dmitry-K | posted 6 months ago
The easiest way to fix this is to open the Topic entity and add a __toString() method.

What are the other ways to fix this problem?

1 Reply
Dmitry-K Avatar

Found the answer in Chapter 14 (choice_label option)

1 Reply

Hey Dmitriy,

Good catch! I'll leave a link to that chapter here: https://symfonycasts.com/sc...

Cheers!

Reply
Pedro A. Avatar
Pedro A. Avatar Pedro A. | posted 1 month ago

This is more a nice-to-have than a question about the lecture:
I wanted to have it so when the user clicks on the "name" of the question in the CRUD index page, the user is redirected to the edit page (an alternative to having to click twice to edit: one to open the right menu and another to select "edit".
This is the code I ended up with:

    yield TextField::new('name')
->formatValue(function ($value, $question) {
$url = $this->adminUrlGenerator
->setController(QuestionCrudController::class)
->setAction(Action::EDIT)
->setEntityId($question->getId())
->generateUrl();
return "<a href=\"{$url}\">{$value}</a>";
});

This works as intended but I'm wondering if there's a better way to do it. Thanks!

Reply

Hey Pedro,

Nice, thank you for sharing your solution with others! This is indeed an interesting idea :)

Cheers!

Reply
Scott S. Avatar
Scott S. Avatar Scott S. | posted 4 months ago

He Team,

I have 3 entities. Templates, Blocks and TemplateBlocks. TemplateBlocks has a ManyToOne relationship to Templates and Blocks. Also Templates and Blocks has a OneToMany relationship with TemplateBlocks. The goal here is that a Template can have many blocks and I want to configure this in the TemplateCrudController. If i was to use ManyToMany the AssociationField would automatically show all Blocks. I cannot use ManyToMany because there's a extra column on TemplateBlocks called orderBy. How do you show all available blocks in this situation and make it sortable?

Reply

Hey Scott!

Ah yes, I'm familiar with this setup! But to get this setup in EasyAdmin - with a nice user experience - you're going to need to do some work. EasyAdmin's form system works via Symfony's form system... and there is no built-in "form type" that can handle what you want to do. If you want this to all happen on one screen, hmm. This is a tough one... the tools that I can think of just aren't built well to "bend" to this use-case, even if it's not that crazy of a use-case.

I actually started writing a long reply to this... but is just got SO complex. So, here is what I'd probably do in the real-world:

A) Do not add a templateBlocks in TemplateCrudController. Instead, add some fake templateBlocksEditor field. Set a custom form type to "mapped false", which means that this data won't actually be set back onto the Template object. Make it, idk, a TextType.

B) Immediately override the template for this field. The point is not actually to render a form field at all. Rather, we're just using this as a way to "put some custom HTML into the middle of the form".

C) In this template, add some HTML - and write some JavaScript in a JS file - that builds a 100% custom "template block editor". Work entirely outside of EasyAdmin to make things flexible. I'd make an Ajax call to fetch all of the current TemplateBlocks for the current Template (create this controller by hand: it doesn't need to live inside of EasyAmin in any way) and another Ajax call to fetch all of the available "Blocks". When the user selects a Block, make a POST Ajax call to another custom endpoint that takes in the Template id, that Block id, and saves a new TemplateBlock object. Also add whatever sorting JS you want to this interface. When the user stops sorting, send another Ajax request up with a list of the ordered ids. In that custom controller, update the orderBy on all of the TemplateBlocks.

So basically I'm saying: the interface you likely need is so custom that you should work entirely outside of EasyAdmin and build it yourself. Btw, the easiest way to do the above is to only allow this "template block editor" to load once a Template has been saved. So, for a new Template, you might render "Save Template to edit blocks" first.

Sorry I can't give you a quicker answer, but I hope this helps!

Cheers!

Reply
Scott S. Avatar

He weaverryan
I was digging into the different form types and thought it was my lack of knowledge why i couldn't find any way to do this. As i will be using EasyAdmin as a base for all my projects i will have to come up with a custom way of handeling this. Thanks for all your suggestions!

Reply

Hello Symfonycasts Team.

Does the autocomplete also have a corresponding rights check or can this Ajax endpoint be used by anyone who searches a bit through the source code of the website?

Reply

Hey Michael,

Good question! I've never thought about it before, most probably it does not have any ability to configure permissions check, at least I don't remember I see how it can be configured this way but I might be wrong. Try to play around to figure it out. Well, if you're asking if your users (not admin users) can leverage this feature to leak the data from admin - they answer is "no". Because as you might already know, EasyAdmin handles all its requests thought a single route, and if you already configured permissions, i.e. allows only admins to access your /admin - then you're safe and users won't be able to get any data leveraging that autocomplete feature because they don't have access to the /admin route. But if you're asking about configuration additional permissions between admin users - I'm not sure here, try to play around with the code a bit yourself :)

Cheers!

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