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!

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