Forms - Extending Symfony2 Web Application Framework (2014)

Extending Symfony2 Web Application Framework (2014)

Chapter 3. Forms

Symfony ships with a powerful form component. Building forms based on your classes, keeping the data in sync between a form and an object or any data structure, is a complicated topic. There are a few abstractions to understand how the form component works in order to enable its full power and make complete use of it.

One of the good things about it is that almost everything, once defined, is easily reusable. In the previous chapters, we were building a website that allows users to publish or join meetups. We stated at the outset that we wanted to show users only those meetups that are happening with a certain distance from them. For this, we had to know the actual location of each meetup and user. For users, we relied on their IP address, but for meetups, we should probably let the organizer define the exact address—maybe even on a map. There is no map input predefined in the form framework, so we will define one. It should be easy enough to reuse the same input in the user profile to know exactly where our user lives rather than relying on their IP.

An input for geographical coordinates

Our special field will use Google Maps and this will be the only part visible to the user. To achieve all this, since this is a rather complex widget, we will need all of the following four elements:

· A Coordinate class to hold our information

· A form type

· A Twig template

· A data transformer

In most cases, you will not need all of these. You have probably already defined form types without any of the other elements.

The Google Maps integration will be done by an external bundle available at https://github.com/egeloen/IvoryGoogleMapBundle.

The Coordinate class is quite straightforward and will not change much, so let's have a quick look at it in the following code:

namespace Khepin\BookBundle\Geo;

use Ivory\GoogleMapBundle\Entity\Coordinate as GMapsCoordinate;

class Coordinate

{

private $latitude;

private $longitude;

public function __construct($latitude = null, $longitude = null)

{

$this->latitude = $latitude;

$this->longitude = $longitude;

}

public function getLatitude()

{

return $this->latitude;

}

public function setLatitude($latitude)

{

$this->latitude = $latitude;

}

public function getLongitude()

{

return $this->longitude;

}

public function setLongitude($longitude)

{

$this->longitude = $longitude;

}

The default representation as a string should be latitude, longitude, as shown in the following code:

public function __toString()

{

return '('.$this->latitude.', '.$this->longitude.')';

Based on the string representation (latitude, longitude), we will want to be able to create a new Coordinate instance using the following code:

public static function createFromString($string)

{

if(strlen($string) < 1){

return new self;

}

$string = str_replace(['(', ')', ' '], '', $string);

$data = explode(',', $string);

if($data[0] === "" || $data[1] === ""){

return new self;

}

return new self($data[0], $data[1]);

}

We will need to convert this coordinate to the Google Maps version from the bundle using the following code. The reason we are not using it directly is that with our own Coordinate class, we can control and decide how to map it to a database later.

public function toGmaps()

{

return new GMapsCoordinate($this->latitude, $this->longitude);

}

}

Setting up the basics

If you have ever built a form type, based on one of your entities for example, it probably looked like the one in the following code:

class CoordinateType extends AbstractType

{

public function buildForm(FormBuilderInterface $builder, array $options)

{

// Build the form, add fields etc

}

public function getName()

{

return 'coordinate';

}

public function setDefaultOptions(OptionsResolverInterface $resolver)

{

$resolver->setDefaults(['widget' => 'coordinate', 'compound' => false, 'data_class' => 'Khepin\BookBundle\Geo\Coordinate']);

}

}

We will keep the form like this for now and refer to this again whenever we need it. We have just done two simple things:

· We gave our form a name.

· We stated that the form should render using a special widget, coordinate. By default, you already have access to a certain number of widgets in Symfony. They are text fields, select boxes, checkboxes, and so on.

We must set the compound option as it is true by default. The compound option should only be set to true when our field represents a collection that could contain any number of elements.

Our widget will display a map, and it already includes a hidden field. For now, we will define it in a very simple way at Bundle/Resources/views/Form/widgets.html.twig. Alternately, later on if you want to see what's happening in the hidden field, use form_widget_simpleinstead of hidden_widget in the template to replace the hidden field with a standard text field, as shown in the following code:

{% block coordinate_widget %}

<div>Display the map here</div>

{{ block('hidden_widget') }}

{% endblock %}

For Symfony (and Twig) to know about this widget, it needs to be added in the configuration under the twig section:

# Twig Configuration

twig:

debug: %kernel.debug%

strict_variables: %kernel.debug%

form:

resources:

- 'KhepinBookBundle:Form:widgets.html.twig'

Now that we have defined a coordinate type and its widget, we would like to try it. For trying this, we will have a simple controller and template in which we will use it as follows:

// Controller

public function mapAction()

{

$form = $this->createFormBuilder()

->add('location', 'coordinate')

->add('submit', 'submit')

->getForm();

$form = $form->createView();

return compact('form');

}

{# Template #}

{% extends "::base.html.twig"%}

{% block body %}

{{form(form)}}

{% endblock %}

If we were to try that though, we would get an exception informing us that the coordinate type is not defined. Indeed, we defined the class, but we tried to use it by referencing its name. What you normally do when you create a type class for your entities is that you define $this->createForm(new TaskType(),$task);, and you are in charge of instantiating the Type class yourself. For the types that are built in Symfony, you can just use their name. We aim to completely integrate our type into the framework, so this is what we want.

We need to tell the form framework that we have a special class somewhere that should be recognized as a form type. This is done in the exact same way as we previously told Twig that we had a special class that needed to be loaded as an extension through services and tags. Let's define a service for our form type and tag it properly using the following code:

khepin.form.type.coordinate:

class: Khepin\BookBundle\Form\CoordinateType

scope: prototype

tags:

- { name: form.type, alias: coordinate }

This is the first time we encounter the prototype scope. If you remember Chapter 1, Services and Listeners, we saw that the default scope is container, which always returns to you the same instance of a given class. But here, if we want to use that coordinate field more than once in a form (or per request), we need a new instance each time.

Now, loading our page will show our widget, although it doesn't do much yet.

Using the map

Our type class should prepare a map object and pass it on to the template. The template then has all the required logic to display it. In our controller, we see that to get the form in a way that can be used by the template, we call getForm() and then createView(). So, we need to get into that view creation process and add our map there. The map bundle we are using defines a service named ivory_google_map.map for creating maps from PHP. We inject this in our Type class and start adding the map to the view using the following code:

khepin.form.type.coordinate:

class: Khepin\BookBundle\Form\CoordinateType

scope: prototype

arguments: [@ivory_google_map.map]

tags:

- { name: form.type, alias: coordinate }

class CoordinateType extends AbstractType

{

protected $map;

public function __construct($map)

{

$this->map = $map;

}

// Other methods unchanged omitted here

public function buildView(FormView $view, FormInterface $form,array $options)

{

$center = new GMapsCoordinate(39.91311850372953, 116.4002054820312);

$this->map->setCenter($center);

$this->map->setMapOption('zoom', 10);

$view->vars['map'] = $this->map;

}

}

We create the map and set the center to some sensible coordinates. We can also use the user_locator service we previously defined to set it to where the user is connecting from, or their exact address if we acquire it later. Also, when we are using this form to update an existing value, we will center the map on the existing coordinate. For now, we will change our widget as shown in the following code:

{% block coordinate_widget %}

{{ google_map_container(map) }}

{{ google_map_js(map) }}

{% set read_only = true %}

{{ block('form_widget_simple') }}

{% endblock %}

Now when we display the form, we can see our map!

We need a little bit of JavaScript so that our field will update every time we click on a point on the map. So, in the end, our widget could look as follows:

{% block coordinate_widget %}

{{ google_map_container(map) }}

{{ google_map_js(map) }}

<script type="text/javascript">

google.maps.event.addListener(

{{map.javascriptVariable}}, {# The {{}} here is from Twig #}

'click',

setValue

);

function setValue(event) {

var input = document.getElementById('{{id}}'); {# The {{}} here is from Twig #}

input.value = event.latLng;

}

</script>

{% set read_only = true %}

{{ block('form_widget_simple') }}

{% endblock %}

Now, let's use our form and see what we get. We will display a map and the values from the last form submission, if any, using the following code:

/**

* @Route("/map")

* @Template()

*/

public function mapAction(Request $request)

{

$form = $this->createFormBuilder()

->add('location', 'coordinate')

->getForm();

$location = null;

if ($request->getMethod() === 'POST') {

$form->handleRequest($request);

$location = $form->getData()['location'];

}

$form = $form->createView();

return compact('form', 'location');

}

{% extends "::base.html.twig"%}

{% block body %}

Latitude: {{location.latitude}} - Longitude: {{location.longitude}}

{{form_start(form)}}

{{form_row(form.location)}}

{{form_rest(form)}}

<button type="submit">Submit</button>

{{form_end(form)}}

{% endblock %}

So far, it all works, except that the data we retrieve for the location is a string, and we would like to actually have it as a Coordinate object instead.

Data transformers

By using data transformers, the form components in Symfony offer a powerful way of dealing with this scenario. The form component allows three distinct representations of the same data, which are as follows:

· The one in the view (in the HTML)

· The one in the model

· The one in the form itself (if necessary)

In most cases, this is overkill. For our current case, only one transformer will be enough to go from a string (such as 42.0321650 and 115.032160513) to the PHP object representation. However, if you think about date and time, it can be that your form offers the choice that the view shows three select boxes for the year, month, and day; a datepicker; or a timestamp-based value. At the same time, you can expect that your PHP model object always needs it as a string based on a certain format. If you want to create a form type that offers this kind of flexibility, it's better if the form internally keeps everything as a DateTime object, and then transforms it for the view or the model.

Data transformers have only two methods: transform and reverseTransform. The transform method goes from the model to the form and from the form to the view. The reverseTransform method goes from the view to the form and from the form to the model. The following diagram represents the flow of two methods:

Data transformers

Consider the following code snippet:

namespace Khepin\BookBundle\Form\Transformer;

use Symfony\Component\Form\DataTransformerInterface;

use Symfony\Component\Form\Exception\TransformationFailedException;

use Khepin\BookBundle\Geo\Coordinate;

class GeoTransformer implements DataTransformerInterface

{

public function transform($geo)

{

return $geo;

}

public function reverseTransform($latlong)

{

return Coordinate::createFromString($latlong);

}

}

The transform method will not do anything as our class already implements a toString() method that will directly render the view value. The reverseTransform method does the opposite by creating a Coordinate object from a string.

Now, we will add our transformer to the coordinate form type, update the view, and build the map using the data from the form instead of a predefined location so that while editing the form, the map will be centered on the previously chosen coordinates:

public function buildForm(FormBuilderInterface $builder, array $options)

{

$builder->addViewTransformer(new GeoTransformer);

}

public function buildView(FormView $view, FormInterface $form, array$options)

{

$center = new GMapsCoordinate($form->getData()->getLatitude(), $form->getData()->getLongitude());

$this->map->setCenter($center);

$this->map->setMapOption('zoom', 10);

$view->vars['map'] = $this->map;

}

Since Coordinate implements a __toString() method, there will be no difference on the template. However, if you try to dump the object that we get from the form, you can see that it is actually a Coordinate object.

One last thing we would like to improve is that currently we have set the default location to something predefined. However, in Chapter 1, Services and Listeners, we created a service that helps us determine where a user is located based on their IP address. It would be nicer to use this and set the default map location to the one the user is likely connecting from instead of setting it to a predefined value.

Forms based on user data

We had previously defined our form type as a service, so now we will change its configuration for it to take the user_locator service as the second argument, as shown in the following code:

khepin.form.type.coordinate:

class: Khepin\BookBundle\Form\CoordinateType

scope: prototype

arguments: [@ivory_google_map.map, @user_locator]

tags:

- { name: form.type, alias: coordinate }

If you recall correctly, the user_locator service was in the request scope, but our form type is in the prototype scope. Since the prototype scope is more restrictive than the request scope, we don't have any issues here.

We will also update the default values of CoordinateType using the following code so that it always has a default value, which will be an empty coordinate:

public function setDefaultOptions(OptionsResolverInterface $resolver)

{

$resolver->setDefaults([

'widget' => 'coordinate',

'compound' => false,

'data_class' => 'Khepin\BookBundle\Geo\Coordinate',

'data' => new Coordinate(),

]);

}

There are many places where we can change that default value to a new value before displaying the form. We can change the way in which we build GMapsCoordinate in the buildView function. This will work technically, but it will be better to have the form to display its value normally.

The form framework in Symfony uses events. They're not sent through the Symfony kernel though, and are specific to each form. Each class or function that wants to listen to an event on the form has to be declared in that form or form type. We can declare them as event subscribers or as anonymous functions, which we will be using here.

There are five possible events described as follows:

· PRE_SET_DATA: This event is triggered before the data is bound to the form and allows you to change the data. If you are editing an object, it is likely that there will be some data to be set. When you are using a blank form, the data will usually be empty or will only contain default values.

· POST_SET_DATA: This event allows you to perform some actions after the data has been set in the form.

· PRE_SUBMIT: This event lets you modify the form before submission.

· SUBMIT: This event allows you to perform some actions on form submission.

· POST_SUBMIT: This event lets you perform actions after the form has been submitted.

In our case, of course, we can only use PRE_SET_DATA since anything after that would be too late! The following code shows exactly how to do this in the Form class:

public function buildForm(FormBuilderInterface $builder, array $options)

{

$builder->addModelTransformer(new GeoTransformer);

$builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($builder) {

$data = $event->getData();

if (null === $data->getLatitude()) {

$geocoded = $this->locator->getUserCoordinates();

$value = new Coordinate($geocoded->getLatitude();$geocoded->getLongitude());

$event->setData($value);

}

});

}

Note

The getUserCoordinates method of the user_locator service was not implemented in Chapter 1, Services and Listeners. The implementation shouldn't be a problem for you at this point of the book.

If the data has latitude that is not null, it is not coming from our default value, so we don't need to modify it in any way. If it is empty, however, we replace it with the coordinates of the current user.

Going further

For the last part of this chapter, we will go a bit further with the customization of forms.

A part of our meetups website requires a user to enter their house address so that they can receive a membership card that will be directly sent out to them. Since we already have a relatively good idea where that user is coming from, we will preset the country for them in the form. Here, we only differentiate between users coming from within or outside the USA to decide if they must fill in the state they are coming from.

The initial setup

Our Address class is very simple and contains only a few attributes as well as getters and setters, as shown in the following code snippet:

class Address

{

protected $id;

protected $street;

protected $number;

protected $country;

protected $state;

protected $zip;

// public function getXxx();

// public function setXxx($x);

}

The basic form class will be as shown in the following code:

class AddressType extends AbstractType

{

public function buildForm(FormBuilderInterface $builder, array$options)

{

$state_options = [

'AL' => 'Alabama',

// ...

'WY' => 'Wyoming'

];

$builder

->add('street')

->add('number')

->add('country', 'choice', [

'choices' => [

'US' => 'USA',

'OTHER' => 'Not USA'

]

])

->add('state', 'choice', [

'choices' => $state_options

])

->add('zip')

;

}

public function setDefaultOptions(OptionsResolverInterface $resolver)

{

$resolver->setDefaults(array(

'data_class' => 'Khepin\BookBundle\Entity\Address'

));

}

public function getName()

{

return 'address';

}

}

In the controller, we only set a default value for the country while displaying an empty form. If it is a POST request, the user will have picked a country when using the form; therefore, we can avoid this step and a long network call to a GeoIP provider. We have, of course, created a controller to display this form, as shown in the following code:

/**

* @Route("/address")

* @Template()

*/

public function addressAction(Request $request)

{

$message = '';

$form = null;

$address = new \Khepin\BookBundle\Entity\Address;

if ($request->getMethod() === 'GET') {

$country = $this->get('user_locator')->getCountryCode();

$address->setCountry($country);

}

$form = $this->createForm(new AddressType, $address, [

'action' => '',

'method' => 'POST',

]);

if ($request->getMethod() === 'POST') {

$form->handleRequest($request);

if ($form->isValid()) {

$message = 'The form is valid';

}

}

$form = $form->createView();

return compact('form', 'message');

}

We have also included a message in the template to know if the form is valid or not. This will be important very soon. So far, everything should look pretty straightforward to anyone having worked with Symfony.

Adding and removing fields

We will now customize the form based on its own data. If we already know that the country is the USA, we add a field for the state; otherwise, we don't.

Note

In a more realistic scenario, you would probably want to always have the field and decide in the frontend if you want to show it or not, as this would allow the user to directly decide this.

Modifying a form based on its current data is actually a very common scenario. The most common use is to allow different actions when a record is created from when it is only edited. If the user already has an ID, we add or remove certain fields. Every form where you pass in a hidden field with the ID of another object, such as a form to subscribe to a specific event or a form to message a given friend, can be a good case for this.

We will update our form, as follows, to have the state field added or not depending on the country:

public function buildForm(FormBuilderInterface $builder, array $options)

{

$state_options = [

'choices' => [

'AL' => 'Alabama',

// ...

'WY' => 'Wyoming',

]

];

$builder

->add('street')

->add('number')

->add('country', 'choice', [

'choices' => [

'US' => 'USA',

'OTHER' => 'Not USA'

]

])

->add('zip')

;

$builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($state_options){

$address = $event->getData();

if ($address === null) {

return;

}

if ($address->getCountry() == 'US') {

$event->getForm()->add('state', 'choice', $state_options);

}

});

}

This seems to be good; however, if you were to actually try it with an IP address coming from the USA, you will realize that after submitting the form, it is not valid. Let's dig a bit into what happens during the first request (showing the empty form) and the second one (sending data to the form):

Display

Submit

Creates an address with country as the USA.

Creates an address with no specified country.

Builds the form.

Builds the form.

On PRE_SET_DATA, we have an address with country as the US, so we add a field to pick a state.

On PRE_SET_DATA, we have an empty address. This is the data that we passed while instantiating the form. The data submitted by the user is sent on BIND. We don't add the state field.

Done

The form is bound to the submitted data.

Done

The form is validated, but the submitted data has one additional field named state, so it is invalid.

Whenever we modify a form based on its own values, we must make sure to modify it at two points in time:

· Before we set the initial data in the form

· Before we bind the form to actual user-submitted data

This way, we can ensure that the user's data will be validated against the right representation of our form.

We'll add a second listener to our form, as shown in the following code snippet, so that if the data submitted by the user has the USA as a country, we will also allow the list of states on the form:

$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) use ($state_options){

$address = $event->getData();

if ($address['country'] == 'US') {

$event->getForm()->add('state', 'choice', $state_options);

}

});

Note

The event data is an array and not an object. It will be available as an object only after the form has been bound. However, after that, we cannot modify the structure of the form anymore and wouldn't be able to add the state field.

Now, our form can be displayed and submitted as we expect!

Summary

This chapter presented an in-depth view of the possibilities offered to you by the form framework within Symfony. It might seem a complex thing at first, but if you understand the basic parts, it's easy to find your way around.

You can now create your own form widgets that can be used just as any of the base widgets, treating a map as a new type of input field. You also know how to use a data transformer in order to have different representations of the same information that fit within the model, the form, or the view. If you want to practice your form skills, you can try some of the following:

· Create a form for messaging that includes an AJAX field for friend selection

· Create a form that accepts a collection of our coordinate type

Now that we have a good hold on many extension possibilities in Symfony, it is time to get into one of the most technical and difficult topics: Security. There is a lot to be said since security can be understood in many ways and touches many areas of your application, from the forms to how you store records in your database.