Value Objects in Symfony Forms

Many times, Symfony developers wonder how to make a form work with value objects. For example, think of a Money object with two fields $amount and $currency:

class Money
{
    private $amount;
    private $currency;

    public function __construct($amount, $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function getAmount() // ...
    public function getCurrency() // ...
}

Can you write a form type for this class without adding the methods setAmount() and setCurrency()? In this post, I will show you how.

Value Objects

Let's clarify what a value object is first. Martin Fowler describes them like this:

You can usually tell them because their notion of equality isn't based on identity, instead two value objects are equal if all their fields are equal.

For our Money object, that means that two objects are equal if and only if the values of their properties ($amount and $currency) match. Other examples are the classes Integer, Float or String in languages that represent scalar values as objects.

In PHP, we can use the == operator to compare two value objects. This operator evaluates to true if and only if the property values of both objects match:

$m1 = new Money(100, 'EUR');
$m2 = new Money(100, 'EUR');
$m3 = new Money(100, 'USD');

var_dump($m1 == $m2); // => true
var_dump($m1 == $m3); // => false

What about typical Order classes? Orders usually have an ID or an order number that defines their identity. In other words, two Order objects are considered equal if and only if their IDs are the same, independent of the values of other properties like $customerName or $items. Such objects are called entities.

Mutability

In general, value objects should be immutable. That means that you should never change the properties of the object after its construction. If you do, this may lead to strange and potentially dangerous side effects! Imagine that two Order objects reference the same Money object for their prices. In practice, this happens very easily:

// 2kg of carrots
$total = $carrots->getKiloPrice()->multiply(2);

$order1->setTotal($total);
$order2->setTotal($total);

If you change the $amount of the Money instance in one of the two orders, for example to add taxes, the price of the other order will change as well! This is usually not what you want. Hence Fowler says:

If you want to change a value object you should replace the object with a new one

By creating new objects, we make sure that a value object always represents the same value.

Some value objects only have few distinct values. A class Currency, for example, can only have a limited amount of values, like EUR or USD. If your application uses many such value objects and has a high memory usage, consider implementing the Flyweight pattern.

Forms and Value Objects

Let's get to the meat of this blog post: We would like to implement a form type MoneyType for submitting a Money object through a Symfony form. How could we do that?

An intuitive first attempt will lead you to this form:

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MoneyType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('amount', 'money', array(
                'divisor' => 100,
            ))
            ->add('currency', 'choice', array(
                'choices' => array('EUR', 'USD'),
            ))
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Webmozart\Money',
        ));
    }
}

Setting the divisor option of Symfony's money type to 100 guarantees that we always store money amounts as integer. For example, if the user enters 23.10, we store 2310 (cents) internally. Using integers instead of floats prevents us from storing invalid monetary values, like 23.1092.

This attempt will fail spectacularly with an exception:

Warning: Missing argument 1 for Webmozart\Money::__construct()

When we submit our form, Symfony tries to create a new Money instance. Since the constructor of that class has required arguments, our application fails. That's natural: Symfony can't know which values to pass to the constructor. But can we teach it?

The empty_data Option

Symfony's empty_data can be used for precisely that. Pass a closure that creates a new instance of your data. The closure receives the submitted FormInterface instance as argument. Use this form to access the values of your form's fields:

use Symfony\Component\Form\FormInterface;

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        'data_class' => 'Webmozart\Money',
        'empty_data' => function (FormInterface $form) {
            return new Money(
                $form->get('amount')->getData(),
                $form->get('currency')->getData()
            );
        },
    ));
}

If you submit the form again, the new Money instance will be created successfully. But what happens if you update an existing Money instance?

Neither the property "amount" nor one of the methods "addAmount()"/"removeAmount()", "setAmount()", "amount()", "__set()" or "__call()" exist and have public access in class "Webmozart\Money".

Again, we receive an exception. Symfony tries to find a setter to update the $amount field, but that setter doesn't exist. We learned before that we should always replace value objects with new instances. How can we do that with Symfony's forms?

Data Mappers

Instead of using the empty_data option – which is only called if we create an object, but not if we update it – we will implement a custom data mapper.

Symfony's data mappers are responsible for mapping the data of a form to its fields and back. For our MoneyType, Symfony's default data mapper will call the following methods when we display a form with an existing Money instance:

$form->get('amount')->setData($money->getAmount());
$form->get('currency')->setData($money->getCurrency());

The properties of the Money are copied to the form simply by calling the appropriate getters.

When we submit the form, the data mapper inverts that behavior:

$money->setAmount($form->get('amount')->getData());
$money->setCurrency($form->get('currency')->getData());

That's where the data mapper fails. The setters setAmount() and setCurrency() don't exist.

To create a custom data mapper we need to implement DataMapperInterface. Let's look at this interface:

namespace Symfony\Component\Form;

interface DataMapperInterface
{
    /**
     * Maps properties of some data to a list of forms.
     *
     * @param mixed           $data  Structured data.
     * @param FormInterface[] $forms A list of {@link FormInterface} instances.
     */
    public function mapDataToForms($data, $forms);

    /**
     * Maps the data of a list of forms into the properties of some data.
     *
     * @param FormInterface[] $forms A list of {@link FormInterface} instances.
     * @param mixed           $data  Structured data.
     */
    public function mapFormsToData($forms, &$data);
}

These methods correspond to the two previous code blocks. The method mapDataToForms() calls setData() on all passed forms by reading the passed $data. Conversely, mapFormsToData() updates $data by reading the data of the passed forms.

A Data Mapper for Value Objects

We will implement DataMapperInterface in our MoneyType to customize the way our Money objects are read, created and updated. We can override the default data mapper by calling setDataMapper() on the form builder:

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormBuilderInterface;

class MoneyType extends AbstractType implements DataMapperInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->setDataMapper($this)
        ;
    }
}

Next we need to implement the methods specified by DataMapperInterface. We will start with mapDataToForms():

public function mapDataToForms($data, $forms)
{
    $forms = iterator_to_array($forms);
    $forms['amount']->setData($data ? $data->getAmount() : 0);
    $forms['currency']->setData($data ? $data->getCurrency() : 'EUR');
}

This method updates the amount and currency fields with the values read from the Money instance. If no such instance exists, we are setting default values instead.

Next we'll add mapFormsToData() to create new Money instances when submitting the form:

public function mapFormsToData($forms, &$data)
{
    $forms = iterator_to_array($forms);
    $data = new Money(
        $forms['amount']->getData(), 
        $forms['currency']->getData()
    );
}

Instead of changing the existing $data, we simply create a new Money instance with the data of the form.

At last, we will set empty_data to null so that we don't create the Money object twice:

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        // ...
        'empty_data' => null,
    ));
}

And that's it! Our MoneyType is ready to use.

Check out the trainings section for Symfony forms trainings. Contact me if you want to organize a training for your company.

Discussion