Symfony Form Validation with DTO

In the previous blog posts we learned a lot about the Symfony Validator. We know how to create an object with validation constraints. We have seen an implementation of the Validator in our REST API. Now we will take a look at how we can use our ProductDTO in a form with validation.

Installing the Form Component

If you are using Symfony flex you can install the Form  component with the following command:

composer require form

Before we start you also need to install the validator component, create a ProductDTO and define its validation constraints. In that this is a continuation of this blog post.

Creating the Form Class

We will be creating a Form Class as described in the documentation here. It’s always recommended to create a separate class. This way you can easily reuse your form and is your code cleanly separated.

final class ProductType extends AbstractType
{

    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name')
            ->add('price')
            ->add('submit', SubmitType::class)
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ProductDTO::class
        ]);
    }
}

But because we are using an immutable DTO object and not an Entity. We need to do some mapping. To enable us an easy way to map our DTO the the form class, we can implement the DataMapperInterface.

This provides us with 2 new methods.

First we can map data to the form. By converting the iterator to an array we can set the data from our DTO to the correct fields.

/**
 * Maps properties of some data to a list of forms.
 *
 * @param ProductDTO $productDTO Structured data
 * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
 *
 * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
 */
public function mapDataToForms($productDTO, $forms): void
{
    $forms = iterator_to_array($forms);
    $forms['name']->setData($productDTO ? $productDTO->getName() : '');
    $forms['price']->setData($productDTO ? $productDTO->getPrice() : 0);
}

The next method does the opposite. Here we can map the form back to our DTO. Because its immutable, we need to create a new instance of our object now.

/**
 * Maps the data of a list of forms into the properties of some data.
 *
 * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
 * @param ProductDTO $productDTO Structured data
 *
 * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
 */
public function mapFormsToData($forms, &$productDTO): void
{
    $forms = iterator_to_array($forms);
    $productDTO = ProductDTO::create($forms['name']->getData(), $forms['price']->getData());
}

Don’t forget to setDataMapper($this) to the builder. This will tell the FormBuilder to use our DataMapper methods.

The complete class:

final class ProductType extends AbstractType implements DataMapperInterface
{

    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name')
            ->add('price')
            ->add('submit', SubmitType::class)
            ->setDataMapper($this);
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ProductDTO::class,
            'empty_data' => null,
        ]);
    }

    /**
     * Maps properties of some data to a list of forms.
     *
     * @param ProductDTO $productDTO Structured data
     * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
     *
     * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
     */
    public function mapDataToForms($productDTO, $forms): void
    {
        $forms = iterator_to_array($forms);
        $forms['name']->setData($productDTO ? $productDTO->getName() : '');
        $forms['price']->setData($productDTO ? $productDTO->getPrice() : 0);
    }

    /**
     * Maps the data of a list of forms into the properties of some data.
     *
     * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} instances
     * @param ProductDTO $productDTO Structured data
     *
     * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
     */
    public function mapFormsToData($forms, &$productDTO): void
    {
        $forms = iterator_to_array($forms);
        $productDTO = ProductDTO::create($forms['name']->getData(), $forms['price']->getData());
    }
}

Now we can use the form in our controller.

public function add(Request $request): Response
    {
        $form = $this->createForm(ProductType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $productDTO = $form->getData();
            var_dump($productDTO);
        }

        return $this->render('product/add.html.twig', [
            'form' => $form->createView()
        ]);
    }

As you can see. We have now create a form class and this will automatically use the validation constraints defined to our ProductDTO.