If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur 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.
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
.
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.
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?
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!
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?
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!
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?
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!
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!
Hi, Please can use the callable argument in symfony 3.4 ? because i don't have autocompletion in the __construct function
thanks
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!
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.
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?
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!
// 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
}
}
I am getting an error: Function name must be a string
It highlights this: $user = $callback($this->userRepository, $value);