Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Auto-complete Association Field & Controlling the Query

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

The AssociationField creates these pretty cool select elements. But these are really just normal, boring select elements with a fancy UI. All of the options, in this case, every user in the database, is loaded onto the page in the background to build the select. This means that if you have even a hundred users in your database, this page is going to start slowing down, and eventually, explode.

AssociationField::autoComplete()

To fix this, head over and call a custom method on the AssociationField called ->autocomplete():

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

Yes, this is as nice as it sounds. Refresh. It looks the same, but when we type in the search bar... and open the Network Tools... check it out! That made an AJAX request! So instead of loading all of the options onto the page, it leverages an AJAX endpoint that handles the autocompletion. Problem solved!

Controlling the Autocomplete Items with formatValue()

And as you can see, it uses the __toString() method on User to display the option, which is the same thing it does on the index page in the "Asked By" column. We can control that, however. How? We already know: it's our old friend ->formatValue(). As you might remember, this takes a callback function as its argument: static function() with $value as the first argument and Question as the second:

... lines 1 - 11
class QuestionCrudController extends AbstractCrudController
{
... lines 14 - 18
public function configureFields(string $pageName): iterable
{
... lines 21 - 28
yield AssociationField::new('askedBy')
... line 30
->formatValue(static function ($value, Question $question): ?string {
... lines 32 - 36
});
... lines 38 - 39
}
}

The $value argument will be the formatted value that it's about to print onto the page. And then Question is the current Question object. We'll eventually need to make this argument nullable and I'll explain why later. But for now, just pretend that we always have a Question to work with.

Inside: if (!$question->getAskedBy()) - if for some reason that field is null, we'll return null. If that is set, return sprintf() - with %s,   for a space, and then %s inside of parentheses. For the first wildcard, pass $user->getEmail().

Oh, whoops! In the if statement, I meant to say if !$user =. This, fancily, assigns the $user variable and checks to see if there is an askedBy user all at once. Finish the ->getEmail() method and use $user->getQuestions()->count() for the second wildcard:

... lines 1 - 11
class QuestionCrudController extends AbstractCrudController
{
... lines 14 - 18
public function configureFields(string $pageName): iterable
{
... lines 21 - 28
yield AssociationField::new('askedBy')
... line 30
->formatValue(static function ($value, Question $question): ?string {
if (!$user = $question->getAskedBy()) {
return null;
}
return sprintf('%s (%s)', $user->getEmail(), $user->getQuestions()->count());
});
... lines 38 - 39
}
}

HTML IS Allowed in EasyAdmin

Oh, and about that  . I added this, in part, to show off the fact that when you render things in EasyAdmin, you can include HTML in most situations. That's normally not how Symfony and Twig work, but since we're never configuring EasyAdmin based off of user input... and this is all just for an admin interface anyways, EasyAdmin allows embedded HTML in most places.

Ok, let's check things out! Reload and... boom! We get our new "Asked By" format on the index page.

The real reason I wanted us to do this was to point out that the formatted value is used on the index page and the detail page... but it is not used on the form. The form always uses the __toString() method from your entity.

Controlling the Autocomplete Query

One of the things we can control for these association fields is the query that's used for the results. Right now, our autocomplete field returns any user in the database. Let's restrict this to only enabled users.

How? Once again, we can call a custom method on AssociationField called ->setQueryBuilder(). Pass this a function() with a QueryBuilder $queryBuilder argument:

... lines 1 - 5
use Doctrine\ORM\QueryBuilder;
... lines 7 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 29
yield AssociationField::new('askedBy')
... lines 31 - 38
->setQueryBuilder(function (QueryBuilder $qb) {
... lines 40 - 41
});
... lines 43 - 44
}
}

When EasyAdmin generates the list of results, it creates the query builder for us, and then we can modify it. Say $queryBuilder->andWhere(). The only secret is that you need to know that the entity alias in the query is always entity. So: entity.enabled = :enabled, and then ->setParameter('enabled', true):

... lines 1 - 12
class QuestionCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 29
yield AssociationField::new('askedBy')
... lines 31 - 38
->setQueryBuilder(function (QueryBuilder $qb) {
$qb->andWhere('entity.enabled = :enabled')
->setParameter('enabled', true);
});
... lines 43 - 44
}
}

That's it! We don't need to return anything because we modified the QueryBuilder. So let's go see if it worked!

Well, I don't think we'll notice any difference because I'm pretty sure every user is enabled. But watch this. When I type... here's the AJAX request. Open up the web debug toolbar... hover over the AJAX section and click to open the profiler.

You're now looking at the profiler for the autocomplete AJAX call. Head over to Doctrine section so we can see what that query looks like. Here it is. Click "View formatted query". Cool! It looks on every field to see if it matches the %ti% value and it has WHERE enabled = ? with a value of 1... which comes from up here. Super cool!

Next: could we use an AssociationField to handle a collection relationship? Like to edit the collection of answers related to a Question? Totally! But we'll need a few Doctrine & form tricks to help us.

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