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 SubscribeAlright, here's the issue and it is super technical. If we change the Location from "Near a Star" to "Solar System", even if we "hack" the specificLocationName
field so that it submits the value "Earth", it doesn't work! It fails validation!
This is a real problem, because, in a few minutes, we're going to add JavaScript to the page so that when we change location
to "The Solar System", it will dynamically update the specificLocationName
dropdown down to be the list of planets. But for that to work, our form system needs to be smart enough to realize - at the moment we're submitting - that the location has changed. And then, before it validates the ChoiceType
, it needs to change the choices to be the list of planets.
Don't worry if this doesn't make complete sense yet - let's see some code!
There's one piece of the form system that we haven't talked about yet: it has an event system, which we can use to hook into the form loading & submitting process.
At the end of the form, add $builder->get('location')->addEventListener()
and pass this FormEvents::POST_SUBMIT
. This FormEvents
class holds a constant for each "event" that we can hook into for the form system. Pass a callback as a second argument: Symfony will pass that a FormEvent
object.
Let's dd()
the $event
so we can see what it looks like.
... lines 1 - 26 | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
... lines 29 - 68 | |
$builder->get('location')->addEventListener( | |
FormEvents::POST_SUBMIT, | |
function(FormEvent $event) { | |
dd($event); | |
} | |
); | |
} | |
... lines 76 - 117 |
But before we check it out, two important things. First, when you build a form, it's actually a big form tree. We've seen this inside of the form profiler. There's a Form
object on top and then each individual field below is itself a full Form
object. The same is true with the "form builder": we normally just interact with the top-level $builder
by adding fields to it. When we call $builder->add()
, that creates another "form builder" object for that field, and you can fetch it later by saying $builder->get()
.
Second, we're attaching the event to only the location field - not the entire form. So, when the form submits, Symfony will call this function, but the $event
object will only have information about the location
field - not the entire form.
Let's actually see this! Refresh to re-submit the form. There it is! The FormEvent
contains the raw, submitted data - the solar_system
string - and the entire Form
object for this one field.
This gives us the hook we need: we can use the submitted data to dynamically change the specificLocationName
field to use the correct choices, right before validation occurs. Actually, this hook happens after validation - but we'll use a trick where we remove and re-add the field, to get around this.
To start, create a new private function
called setupSpecificLocationNameField()
. The job of this function will be to dynamically add the specificLocationName
field with the correct choices. It will accept a FormInterface
- we'll talk about that in a minute - and a ?string $location
, the ?
part so this can be null
.
... lines 1 - 81 | |
private function setupSpecificLocationNameField(FormInterface $form, ?string $location) | |
{ | |
... lines 84 - 102 | |
} | |
... lines 104 - 145 |
Inside, first check if $location
is null
. If it is, take the $form
object and actually ->remove()
the specificLocationName
field and return
. Here's the idea: if when I originally rendered the form there was a location set, then, thanks to our logic in buildForm()
, there will be a specificLocationName
field. But if we changed it to "Choose a location", meaning we are not selecting a location, then we want to remove the specificLocationName
field before we do any validation. We're kind of trying to do the same thing in here that our future JavaScript will do instantly on the frontend: when we change to "Choose a location" - we will want the field to disappear.
... lines 1 - 81 | |
private function setupSpecificLocationNameField(FormInterface $form, ?string $location) | |
{ | |
if (null === $location) { | |
$form->remove('specificLocationName'); | |
return; | |
} | |
... lines 89 - 102 | |
} | |
... lines 104 - 145 |
Next, get the $choices
by using $this->getLocationNameChoices()
and pass that $location
. Then, similar to above, if (null === $choices)
remove the field and return. This is needed for when the user selects "Interstellar Space": that doesn't have any specific location name choices, and so we don't want that field at all.
... lines 1 - 81 | |
private function setupSpecificLocationNameField(FormInterface $form, ?string $location) | |
{ | |
... lines 84 - 89 | |
$choices = $this->getLocationNameChoices($location); | |
if (null === $choices) { | |
$form->remove('specificLocationName'); | |
return; | |
} | |
... lines 97 - 102 | |
} | |
... lines 104 - 145 |
Finally, we do want the specificLocationName
field, but we want to use our new choices. Scroll up and copy the $builder->add()
section for this field, paste down here, and change $builder
to $form
- these two objects have an identical add()
method. For choices
pass $choices
.
... lines 1 - 81 | |
private function setupSpecificLocationNameField(FormInterface $form, ?string $location) | |
{ | |
... lines 84 - 97 | |
$form->add('specificLocationName', ChoiceType::class, [ | |
'placeholder' => 'Where exactly?', | |
'choices' => $choices, | |
'required' => false, | |
]); | |
} | |
... lines 104 - 145 |
Nice! We created this new function so that we can call it from inside of our listener callback. Start with $form = $event->getForm()
: that gives us the actual Form
object for this one field. Now call $this->setupSpecificLocationNameField()
and, for the first argument, pass it $form->getParent()
.
... lines 1 - 27 | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
... lines 30 - 69 | |
$builder->get('location')->addEventListener( | |
FormEvents::POST_SUBMIT, | |
function(FormEvent $event) { | |
$form = $event->getForm(); | |
$this->setupSpecificLocationNameField( | |
$form->getParent(), | |
... line 76 | |
); | |
} | |
); | |
} | |
... lines 81 - 145 |
This is tricky. The $form
variable is the Form object that represents just the location
field. But we want to pass the top level Form
object into the function so that the specificLocationName
field can be added or removed from it.
The second argument is the location
itself, which will be $form->getData()
, or $event->getData()
.
... lines 1 - 73 | |
$this->setupSpecificLocationNameField( | |
$form->getParent(), | |
$form->getData() | |
); | |
... lines 78 - 145 |
Okay guys, I know this is craziness, but we're ready to try it! Refresh to resubmit the form. It saves. Now change the Location to "Near a Star". In a few minutes, our JavaScript will reload the specificLocationName
field with the new options. To fake that, inspect the element. Let's go copy a real star name - how about Sirius
. Change the selected option's value to that string.
Hit update! Yes! It saved! We were able to change both the location
and specificLocationName
fields at the same time.
And that means that we're ready to swap out the field dynamically with JavaScript. But first, we're going to leverage another form event to remove some duplication from our form class.
// 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
}
}