Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

JS to Auto-Update the Select 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

Thanks to these event listeners, no matter what data we start with - or what data we submit - for the location field, the specificLocationName field choices will update so that everything saves.

The last step is to add some JavaScript! When the form loaded, the location was set to "Near a star". When I change it to "The Solar System", we need to make an Ajax call that will fetch the list of planets and update the option elements.

Adding the Options Endpoint

In ArticleAdminController, let's add a new endpoint for this: public function getSpecificLocationSelect(). Add Symfony's Request object as an argument. Here's the idea: our JavaScript will send the location that was just selected to this endpoint and it will return the new HTML needed for the entire specificLocationName field. So, this won't be a pure API endpoint that returns JSON. We could do that, but because the form is already rendering our HTML, returning HTML simplifies things a bit.

... lines 1 - 14
class ArticleAdminController extends AbstractController
... lines 17 - 72
public function getSpecificLocationSelect(Request $request)
... lines 75 - 86
... lines 88 - 99

Above the method add the normal @Route() with /admin/article/location-select. And give it a name="admin_article_location_select".

... lines 1 - 69
* @Route("/admin/article/location-select", name="admin_article_location_select")
public function getSpecificLocationSelect(Request $request)
... lines 74 - 101

Inside, the logic is kinda cool: create a new Article: $article = new Article(). Next, we need to set the new location onto that. When we make the AJAX request, we're going to add a ?location= query parameter. Read that here with $request->query->get('location').

... lines 1 - 72
public function getSpecificLocationSelect(Request $request)
$article = new Article();
... lines 77 - 86
... lines 88 - 101

But, let's back up: we're not creating this Article object so we can save it, or anything like that. We're going to build a temporary form using this Article's data, and render part of it as our response. Check it out: $form = $this->createForm(ArticleFormType::class, $article). We know that, thanks to our event listeners - specifically our PRE_SET_DATA event listener - this form will now have the correct specificNameLocation options based on whatever location was just sent to us.

... lines 1 - 72
public function getSpecificLocationSelect(Request $request)
... lines 75 - 76
$form = $this->createForm(ArticleFormType::class, $article);
... lines 78 - 86
... lines 88 - 101

Or, the field may have been removed! Check for that first: if (!$form->has('specificLocationName') then just return new Response() - the one from HttpFoundation - with no content. I'll set the status code to 204, which is a fancy way of saying that the call was successful, but we have no content to send back.

... lines 1 - 72
public function getSpecificLocationSelect(Request $request)
... lines 75 - 78
// no field? Return an empty response
if (!$form->has('specificLocationName')) {
return new Response(null, 204);
... lines 83 - 86
... lines 88 - 101

If we do have that field, we want to render it! Return and render a new template: article_admin/_specific_location_name.html.twig. Pass this the form like normal 'articleForm' => $form->createView(). Then, I'll put my cursor on the template name and press alt+enter to make PhpStorm create that template for me.

... lines 1 - 72
public function getSpecificLocationSelect(Request $request)
... lines 75 - 83
return $this->render('article_admin/_specific_location_name.html.twig', [
'articleForm' => $form->createView(),
... lines 88 - 101

Inside, just say: {{ form_row(articleForm.specificLocationName) }} and that's it.

{{ form_row(articleForm.specificLocationName) }}

Yep, we're literally returning just the form row markup for this one field. It's a weird way to use a form, but it works!

Let's go try this out! Copy the new URL, open a new tab and go to http://localhost:8000/admin/article/location-select?location=star

Cool! A drop-down of stars! Try solar_system and... that works too. Excellent!

JS Setup: Adding data- Attributes & Classes

Next, open _form.html.twig. Our JavaScript will need to be able to find the location select element so it can read its value and the specificLocationName field so it can replace its contents. It also needs to know the URL to our new endpoint.

No problem: for the location field, pass an attr array variable. Add a data-specific-location-url key set to path('admin_article_location'). Then, add a class set to js-article-form-location.

{{ form_start(articleForm) }}
... lines 2 - 5
{{ form_row(articleForm.location, {
attr: {
'data-specific-location-url': path('admin_article_location_select'),
'class': 'js-article-form-location'
}) }}
... lines 12 - 22
{{ form_end(articleForm) }}

Next, surround the specificLocationName field with a new <div class="js-specific-location-target">. I'm adding this as a new element around the field instead of on the select element so that we can remove the field without losing this target element.

... lines 1 - 11
<div class="js-specific-location-target">
{% if articleForm.specificLocationName is defined %}
... line 14
{% endif %}
... lines 17 - 23

Adding the JavaScript

Ok, we're ready for the JavaScript! Open up the public/ directory and create a new file: admin_article_form.js. I'm going to paste in some JavaScript that I prepped: you can copy this from the code block on this page.

$(document).ready(function() {
var $locationSelect = $('.js-article-form-location');
var $specificLocationTarget = $('.js-specific-location-target');
$locationSelect.on('change', function(e) {
url: $locationSelect.data('specific-location-url'),
data: {
location: $locationSelect.val()
success: function (html) {
if (!html) {
// Replace the current field and show

Before we talk about the specifics, let's include this with the script tag. Unfortunately, we can't include JavaScript directly in _form.html.twig because that's an included template. So, in the edit template, override {% block javascripts %}, call the {{ parent() }} function and then add a <script> tag with src="{{ asset('js/admin_article_form.js') }}.

... lines 1 - 10
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/admin_article_form.js') }}"></script>
{% endblock %}

Copy that, open the new template, and paste this at the bottom of the javascripts block.

... lines 1 - 2
{% block javascripts %}
... lines 4 - 7
<script src="{{ asset('js/admin_article_form.js') }}"></script>
{% endblock %}
... lines 10 - 24

Before we try this, let's check out the JavaScript so we can see the entire flow. I made the code here as simple, and unimpressive as possible - but it gets the job done. First, we select the two elements: $locationSelect is the actual select element and $specificLocationTarget represents the div that's around that field. The $ on the variables is meaningless - I'm just using it to indicate that these are jQuery elements.

Next, when the location select changes, we make the AJAX call by reading the data-specific-location-url attribute. The location key in the data option will cause that to be set as a query parameter.

Finally, on success, if the response is empty, that means that we've selected an option that should not have a specificLocationName dropdown. So, we look inside the $specificLocationTarget for the select and remove it to make sure it doesn't submit with the form. On the wrapper div, we also need to add a Bootstrap class called d-none: that stands for display none. That will hide the entire element, including the label.

If there is some HTML returned, we do the opposite: replace the entire HTML of the target with the new HTML and remove the class so it's not hidden. And... that's it!

There are a lot of moving pieces, so let's try it! Refresh the edit page. The current location is "star" and... so far, no errors in my console. Change the option to "The Solar System". Yes! The options updated! Try "Interstellar Space"... gone!

If you look deeper, the js-specific-location-target div is still there, but it's hidden, and only has the label inside. Change back to "The Solar System". Yep! The d-none is gone and it now has a select field inside.

Try saving: select "Earth" and Update! We got it! We can keep changing this all day long - all the pieces are moving perfectly.

I'm super happy with this, but it is a complex setup - I totally admit that. If you have this situation, you need to choose the best solution: if you have a big form with 1 dependent field, what we just did is probably a good option. But if you have a small form, or it's even more complex, it might be better to skip the form component and code everything with JavaScript and API endpoints. The form component is a great tool - but not the best solution for every problem.

Next: there are a few small details we need to clean up before we are fully done with this form. Let's squash those!

Leave a comment!

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