Working with Symfony Forms is pretty easy, awesome and functional. They already solve many problems that you can theoretically ignore because you only need to take care about building them. For lazy persons like me, there's something even awesome, the generation of the CRUD Forms (create, update and delete) from an entity. This build an entire module with a controller and properly views that ready to use. Although the automatic build solves a lot of problems, pitifully an automatic dependent select feature doesn't exist by default in Symfony, which means that you will have to implement it by yourself.
According to the design of a database, you will need a dependent select in your form to show the user only the rows that are related to another select in the same form. The most tipical example in the real life (and easier to understand) is the Person
, City
and Neighborhood
relationship. Consider the following entities, the first one is Person.php
:
Note
We'll skip the getters and setters in the class to make the article shorter, however they need to obviously exist.
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Person
*
* @ORM\Table(name="person", indexes={@ORM\Index(name="city_id", columns={"city_id"}), @ORM\Index(name="neighborhood_id", columns={"neighborhood_id"})})
* @ORM\Entity
*/
class Person
{
/**
* @var integer
*
* @ORM\Column(name="id", type="bigint")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="last_name", type="string", length=255, nullable=false)
*/
private $lastName;
/**
* @var \AppBundle\Entity\City
*
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\City")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="city_id", referencedColumnName="id")
* })
*/
private $city;
/**
* @var \AppBundle\Entity\Neighborhood
*
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\Neighborhood")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="neighborhood_id", referencedColumnName="id")
* })
*/
private $neighborhood;
}
Every person that can be registered in the system needs to live in a City from our database and as well to live in a specific Neighborhood of our database, so the City.php
entity:
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* City
*
* @ORM\Table(name="city")
* @ORM\Entity
*/
class City
{
/**
* @var integer
*
* @ORM\Column(name="id", type="bigint")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
*/
private $name;
}
And the Neighborhood.php
entity:
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Neighborhood
*
* @ORM\Table(name="neighborhood", indexes={@ORM\Index(name="city_id", columns={"city_id"})})
* @ORM\Entity
*/
class Neighborhood
{
/**
* @var integer
*
* @ORM\Column(name="id", type="bigint")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
*/
private $name;
/**
* @var \AppBundle\Entity\City
*
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\City")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="city_id", referencedColumnName="id")
* })
*/
private $city;
}
The Entities are totally valid and we don't need to modify them at all during this tutorial, however they are useful to understand what we are going to do. If you generate a Form automatically from those entities, in the Neighborhood field of the Person Form, you will find all the neighborhoods without filtering only the Neighborhoods that belong to the selected City:
That's why we need to implement a dependent select, so when the user selects for example San Francisco as his City, in the Neighborhood select he should find only the 2 neighborhoods that belong to San Francisco (Treasure Island and Presidio of San Francisco). Filtering the query in the FormType is easy, however this should be made dinamically with JavaScript as well, so this can be easily achieved following these steps:
1. Configure FormType properly
The logic to create a select dependent is the following, initially, the select of Neighborhood (dependent) will be empty, until the user selects a City, using the ID of the selected City you should load the new options in the neighborhood select. However, if you are editing the form of Person, the selected neighborhood should appear automatically selected without needing JavaScript in the edit view. That's why you need to modify the FormType of your form, in this case the PersonType. To get started you need to attach 2 event listeners to the form that are executed when the events PRE_SET_DATA
and PRE_SUBMIT
of the form are triggered. Inside the events you will verify if there's a selected City in the form or not, if there's you will send it as argument to the addElements method.
The addElements
method expects as second argument the City entity (or null) to decide which data is going to be rendered in the Neighborhood select:
Note
The FormType needs to receive the Entity Manager in the constructor as you will need to make some queries inside. Depending of your Symfony versions, this is automatically done by Autowiring, if it is not made automatically, you may need to pass it as argument in the constructor in the controllers where the class is casted.
<?php
namespace AppBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
// 1. Include Required Namespaces
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Doctrine\ORM\EntityManagerInterface;
// Your Entity
use AppBundle\Entity\City;
class PersonType extends AbstractType
{
private $em;
/**
* The Type requires the EntityManager as argument in the constructor. It is autowired
* in Symfony 3.
*
* @param EntityManagerInterface $em
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
// 2. Remove the dependent select from the original buildForm as this will be
// dinamically added later and the trigger as well
$builder->add('name')
->add('lastName');
// 3. Add 2 event listeners for the form
$builder->addEventListener(FormEvents::PRE_SET_DATA, array($this, 'onPreSetData'));
$builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, 'onPreSubmit'));
}
protected function addElements(FormInterface $form, City $city = null) {
// 4. Add the province element
$form->add('city', EntityType::class, array(
'required' => true,
'data' => $city,
'placeholder' => 'Select a City...',
'class' => 'AppBundle:City'
));
// Neighborhoods empty, unless there is a selected City (Edit View)
$neighborhoods = array();
// If there is a city stored in the Person entity, load the neighborhoods of it
if ($city) {
// Fetch Neighborhoods of the City if there's a selected city
$repoNeighborhood = $this->em->getRepository('AppBundle:Neighborhood');
$neighborhoods = $repoNeighborhood->createQueryBuilder("q")
->where("q.city = :cityid")
->setParameter("cityid", $city->getId())
->getQuery()
->getResult();
}
// Add the Neighborhoods field with the properly data
$form->add('neighborhood', EntityType::class, array(
'required' => true,
'placeholder' => 'Select a City first ...',
'class' => 'AppBundle:Neighborhood',
'choices' => $neighborhoods
));
}
function onPreSubmit(FormEvent $event) {
$form = $event->getForm();
$data = $event->getData();
// Search for selected City and convert it into an Entity
$city = $this->em->getRepository('AppBundle:City')->find($data['city']);
$this->addElements($form, $city);
}
function onPreSetData(FormEvent $event) {
$person = $event->getData();
$form = $event->getForm();
// When you create a new person, the City is always empty
$city = $person->getCity() ? $person->getCity() : null;
$this->addElements($form, $city);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Person'
));
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'appbundle_person';
}
}
Just by configuring correctly the FormType, if you try to create a new Person, the Neighborhood field will be empty and you won't be able to save anything yet and if you try to edit a person, you will see that the Neighborhood field only loads the neighborhoods related to the selected City.
2. Create an endpoint to get the neighborhoods of a city in the view dinamically
As next step, you need to create an ajax accesible endpoint that will return the neighborhoods of a City (the id of the city is sent through a get parameter namely cityid), so you are free to create it wherever you want and how you want, in this example we decided to write it in the same Person controller:
<?php
namespace AppBundle\Controller;
use AppBundle\Entity\Person;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
// Include JSON Response
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Person controller.
*
*/
class PersonController extends Controller
{
// Rest of your original controller
/**
* Returns a JSON string with the neighborhoods of the City with the providen id.
*
* @param Request $request
* @return JsonResponse
*/
public function listNeighborhoodsOfCityAction(Request $request)
{
// Get Entity manager and repository
$em = $this->getDoctrine()->getManager();
$neighborhoodsRepository = $em->getRepository("AppBundle:Neighborhood");
// Search the neighborhoods that belongs to the city with the given id as GET parameter "cityid"
$neighborhoods = $neighborhoodsRepository->createQueryBuilder("q")
->where("q.city = :cityid")
->setParameter("cityid", $request->query->get("cityid"))
->getQuery()
->getResult();
// Serialize into an array the data that we need, in this case only name and id
// Note: you can use a serializer as well, for explanation purposes, we'll do it manually
$responseArray = array();
foreach($neighborhoods as $neighborhood){
$responseArray[] = array(
"id" => $neighborhood->getId(),
"name" => $neighborhood->getName()
);
}
// Return array with structure of the neighborhoods of the providen city id
return new JsonResponse($responseArray);
// e.g
// [{"id":"3","name":"Treasure Island"},{"id":"4","name":"Presidio of San Francisco"}]
}
}
In this project our routes are defined via a yml file (routing.yml) and the route will look like this:
# AppBundle/Resources/config/routing/person.yml
person_list_neighborhoods:
path: /get-neighborhoods-from-city
defaults: { _controller: "AppBundle:Person:listNeighborhoodsOfCity" }
methods: GET
Once the endpoint is available, you can test it manually by accesing the route. The important thing is that the controller needs to return a JSON response with the array that contains the neighborhoods that belong to the desired city.
3. Write JavaScript to handle City change
As last step, we need to make that when the user changes of City, the neighborhoods will be updated with the data of the previously created controller. For it, you will need to write your own JavaScript and make an AJAX request to the previously created endpoint. This part depends totally of the JS frameworks that you use or the way you like to work with JavaScript. To make our example universal, we'll use jQuery.
The logic needs to be placed in both of the form views (new and edit), for example in our new.html.twig
the code will be:
{# views/new.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Person creation</h1>
{{ form_start(form) }}
{{ form_widget(form) }}
<input type="submit" value="Create" />
{{ form_end(form) }}
<ul>
<li>
<a href="{{ path('person_index') }}">Back to the list</a>
</li>
</ul>
{% endblock %}
{% block javascripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script>
$('#appbundle_person_city').change(function () {
var citySelector = $(this);
// Request the neighborhoods of the selected city.
$.ajax({
url: "{{ path('person_list_neighborhoods') }}",
type: "GET",
dataType: "JSON",
data: {
cityid: citySelector.val()
},
success: function (neighborhoods) {
var neighborhoodSelect = $("#appbundle_person_neighborhood");
// Remove current options
neighborhoodSelect.html('');
// Empty value ...
neighborhoodSelect.append('<option value> Select a neighborhood of ' + citySelector.find("option:selected").text() + ' ...</option>');
$.each(neighborhoods, function (key, neighborhood) {
neighborhoodSelect.append('<option value="' + neighborhood.id + '">' + neighborhood.name + '</option>');
});
},
error: function (err) {
alert("An error ocurred while loading data ...");
}
});
});
</script>
{% endblock %}
If everything was correctly implemented, when the user tries to create a new register with the form, the neighborhoods of the selected city will be loaded when the select changes. As well, thanks to the Form Events of Symfony, the neighborhoods will be automatically loaded on the field on the server side when the user edits the form:
Happy coding !