Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Leveraging Custom Field Options

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

Our UserSelectTextType field work great! I've been high-fiving people all day about this! But now, imagine that you want to use this field on multiple forms in your app. That part is easy. Here's the catch: on some forms, we want to allow the email address of any user to be entered. But on other forms, we need to use a custom query: we only want to allow some users to be entered - maybe only admin users.

To make this possible, our field needs to be more flexible: instead of looking for any User with this email, we need to be able to customize this query each time we use the field.

Adding a finderCallback Option

Let's start inside the transformer first. How about this: add a new argument to the constructor a callable argument called $finderCallback. Hit the normal Alt+Enter to create that property and set it.

... lines 1 - 9
class EmailToUserTransformer implements DataTransformerInterface
{
... line 12
private $finderCallback;
... line 14
public function __construct(UserRepository $userRepository, callable $finderCallback)
{
... line 17
$this->finderCallback = $finderCallback;
}
... lines 20 - 48
}

Here's the idea: whoever instantiates this transformer will pass in a callback that's responsible for querying for the User. Down below, instead of fetching it directly, say $callback = $this->finderCallback and then, $user = $callback(). For convenience, let's pass the function $this->userRepository. And of course, it will need the $value that was just submitted.

... lines 1 - 33
public function reverseTransform($value)
{
... lines 36 - 39
$callback = $this->finderCallback;
$user = $callback($this->userRepository, $value);
... lines 42 - 47
}

Cool! We've now made this class a little bit more flexible. But, that doesn't really help us yet. How can we allow this $finderCallback to be customized each time we use this field? By creating a brand new field option.

Check this out: we know that invalid_message is already an option in Symfony and we're changing its default value. But, we can invent new options too! Add a new option called finder_callback and give it a default value: a callback that accepts a UserRepository $userRepository argument and the value - which will be a string $email. Inside return the normal $userRepository->findOneBy() with ['email' => $email].

... lines 1 - 11
class UserSelectTextType extends AbstractType
{
... lines 14 - 33
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
... line 37
'finder_callback' => function(UserRepository $userRepository, string $email) {
return $userRepository->findOneBy(['email' => $email]);
}
]);
}
}

Next, check out the buildForm() method. See this array of $options? That will now include finder_callback, which will either be our default value, or some other callback if it was overridden.

Let's break this onto multiple lines and, for the second argument to EmailToUserTransformer, pass $options['finder_callback'].

... lines 1 - 20
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new EmailToUserTransformer(
$this->userRepository,
$options['finder_callback']
));
}
... lines 28 - 44

Ok! Let's make sure it works. I'll hit enter on the URL to reload the page. Then, change to spacebar2@example.com, submit and... yes! It saves!

The real power of this is that, in ArticleFormType, when we use UserSelectTextType, we can pass a finder_callback option if we need to do a custom query. If we did that, it would override the default value and, when we instantiate EmailToUserTransformer, the second argument would be the callback that we passed from ArticleFormType.

Investigating the Core Field Types

This is how options are used internally by the core Symfony types. Oh, and you probably noticed by now that every field type in Symfony is represented by a normal, PHP class! If you've ever want to know more about how a specific field or option works, just open up the class!

For example, we know that this field is a DateTimeType. Press Shift+Shift and look for DateTimeType - open the one from the Form component. I love it - these classes will look a lot like our own custom field type class! This one has a buildForm() method that adds some transformers. And if you scroll down far enough, cool! Here is the configureOptions() method where all of the valid options are defined for this field.

Want to know how one of these options is used? Copy its name and find out! Search for the with_seconds option. No surprise: it's used in buildForm(). If you looked a little further, you'd see that this is eventually used to configure how the data transformer works.

These core classes are a great way to figure out how to do something advanced or to get inspiration for your own custom field type. Don't' be afraid to dig!

Next: let's hook up some auto-complete JavaScript to this field.

Leave a comment!

20
Login or Register to join the conversation
Farshad Avatar
Farshad Avatar Farshad | posted 2 years ago

I am getting an error: Function name must be a string
It highlights this: $user = $callback($this->userRepository, $value);

Reply

Hey @Farry7

Have you applied all code changes from this chapter before testing it?

Cheers!

Reply
Dirk Avatar

What if you want to show the name of the user instead of the e-mail address but still want to use the email property of the user to do the transformation?

Reply

Hey Dirk !

Hmm... interesting question :). Ultimately, you need the "email" (or even "user id") to be what is submitted to the server so that the form system can reliably look up that User using a unique key (the email or "user id"). So if you want to display the "name of the user" instead of the email, this is really more of a "rendering trick". What I mean is, under the surface, your form field should still work 100% with an email address, because this is what needs to be submitted.

So, there are two pieces to this:

A) How do I create an input field where the "email" is what is actually submitted (i.e. the value= attribute is set to the email) but the "name of the user" is what's actually displayed/visible.

B) How do I make the auto-complete system play with this special text box.

As far as I can tell, you'll need to do a little bit of custom setup to get this all working with the auto-complete system. On the surface, part (A) is a fairly-straightforward JavaScript trick: you render a completely normal input text box... except that maybe you add a data-name attribute which is set to the "name of the user". Then, on page load, you use JavaScript to "hide" that text box completely and create a new text box right next to it (or even just a "div" that looks like a text box) where the value="" the "name of the user". The second text box would not have a "name=" attribute, and so would not be submitted to the server. It's just there as a visual "trick".

Then, you would probably need to hook into a custom autocomplete event - https://github.com/algolia/autocomplete.js#events - probably autocomplete:selected - to that part working. In theory, if you "attach" the autocomplete to the "real input field" (the one that you hid), most things will just work. What I mean is: when the user selects someone from the auto-complete, it will update the "value" attribute of the hidden input box. The only possible problem here is positioning: I'm not sure if autocomplete will get confused about where to position the autocomplete because the input his hidden. The other thing you will need to do (just to make everything "look" correct) would be to update the visible input box with the "name of user".

There might be a JavaScript library that will combine these two abilities better than then one we're using, I'm not sure. At the end of the day, the "server form side" stuff is pretty simply - it's all a big, elaborate JavaScript trick.

Let me know if that helps :).

Cheers!

Reply
Ozornick Avatar
Ozornick Avatar Ozornick | posted 3 years ago | edited

Cannot autowire service "App\Form\Transformer\SpecialtyToArrayTransformer": argument "$finderCallback" of method "__construct()" is type-hinted "callable", you should configure its value explicitly.
In Symfony 5, autowiring does not work. What to do?

Reply

Hey Ozornick!

Sorry for the slow reply! I think I know what's going on. Due to the way that transformers work in Symfony, you need to instantiate these objects manually. That's what we do here: https://symfonycasts.com/screencast/symfony-forms/custom-field-option#codeblock-71e9a67f67

Do you, by chance, have a constructor argument on one of your services that is type-hinted with SpecialtyToArrayTransformer? It looks to me like Symfony is seeing a constructor argument type-hinted with this class and is then trying to autowire & instantiate the SpecialtyToArrayTransformer, which is not what we want. We want to instantiate this manually so that we can pass the callback argument.

Let me know if that helps :).

Cheers!

Reply
Default user avatar
Default user avatar pete | posted 3 years ago | edited

I am overhelmed with the passing of a vatriable $email to the OptionsResolver:


    public function configureOptions(OptionsResolver $resolver)
    {
            $resolver->setDefaults([
            'invalid_message' => 'Hmm, user not found!',
            'finder_callback' => function(UserRepository $userRepository, string $email) {
                return $userRepository->findOneBy(['email' => $email]);
            }            
        ])

How is the variable passt through and how it comes?

I tried to build the form field 'comments' in ArticleFormType.php using query_builder like this,


->add('comments', EntityType::class, array(
                'placeholder' => 'choose comments',
                'multiple' => true,
                'invalid_message' => 'Symfony is too smart for your hacking!',
                'class' => Comment::class, 
                'query_builder' => function (CommentRepository $er)  use ($options) {
                    return $er->getWithSearchQueryBuilder($options['comment_param']); 
                },
            ))

but I could not pass the parameter to narrow my CommentRepository.
Any idea on this?

Reply
Victor Avatar Victor | SFCASTS | pete | posted 3 years ago | edited

Hey pete,

That's PHP anonymous function, and the 2nd example perfectly shows how to pass variables to it - you need to add "use ()", here's an example:


public function configureOptions(OptionsResolver $resolver)
{
    $email = 'test@example.com';

    $resolver->setDefaults([
        'invalid_message' => 'Hmm, user not found!',
        'finder_callback' => function (UserRepository $userRepository) use (string $email) {
            return $userRepository->findOneBy(['email' => $email]);
        }            
    ])
}

Please, read PHP docs about using anonymous functions here to understand it better: https://www.php.net/manual/en/functions.anonymous.php

Cheers!

Reply
Default user avatar

Thanks Victor, I got the passing variable through the anonymous function with the use. The confusing part was for me, how the value shoud be passed from controller into buildForm function of the ArticleFormType. I overseen tha part, of option parameter defined in configureOptions() but passed in the method createForm as third parameter in Controller. You clarified this part in one of the following tutorial. Thanks for a great learning material!

Reply

Hey Pete,

Ah, I see... Glad you figured that out!

Cheers!

Reply
Alain N. Avatar
Alain N. Avatar Alain N. | posted 4 years ago

Hi, Please can use the callable argument in symfony 3.4 ? because i don't have autocompletion in the __construct function
thanks

Reply

Hey Alain N.

The callable type-hint doesn't come from Symfony, it comes from PHP (since PHP5.4 http://php.net/manual/en/language.types.callable.php ). In other words, yes, you should be able to use it.

Cheers!

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | posted 4 years ago

Hi, by adding the callback mechanics to my code, I lost the mechanics of NotNull on the author, the callBack necessarily expects a string in second argument, and if I don't provide any author to my form, an exception is thrown. Thank you.

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | gabrielmustiere | posted 4 years ago | edited

I changed the signature of the callable with string $email = null, however it is the invalid_message that is returned instead of the NotNull message, an idea?

Reply

Hey gabrielmustiere

About the $email, good catch, you have to allow passing nulls in order to avoid errors. And about the wrong constraint message, I'm not totally sure, I would need to see your code but first try adding this into your Article class


class Author 
{
...
    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="articles")
     * @ORM\JoinColumn(nullable=false)
     * @Assert\NotNull(message="Please set an author")
     * @Assert\Valid() # Add this line
     */
    private $author;
...
}

Cheers!

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | MolloKhan | posted 4 years ago | edited

Hi MolloKhan , thanks for the reply, i added the Valid Assert, same message is returned :/
You can find my code here

Reply

I just sent you a PR! (BTW, I didn't try the code but I'm 99% sure it will fix it)

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | MolloKhan | posted 4 years ago

Thank you, the good exception is thrown in.

1 Reply
Adsasd Avatar

What was the solution?

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.6
        "symfony/console": "^4.0", // v4.1.6
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/form": "^4.0", // v4.1.6
        "symfony/framework-bundle": "^4.0", // v4.1.6
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.6
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.6
        "symfony/validator": "^4.0", // v4.1.6
        "symfony/web-server-bundle": "^4.0", // v4.1.6
        "symfony/yaml": "^4.0", // v4.1.6
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
        "symfony/dotenv": "^4.0", // v4.1.6
        "symfony/maker-bundle": "^1.0", // v1.8.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.6
    }
}
userVoice