Using symfony forms without sacrificing your domain
Tags: php, symfony
Published: 2020-09-22 20:45:46

# TL;DR
- Use rich domain models: entities should be in a valid state at all times
- Use a DTO to separate the infrastructure layer from your domain
- Fully strict typed goodness
- Profit!

{% twitter 1197192393482743810 %}

{% twitter 1306934555828461569 %}

Every once in a while a discussion pops up on twitter on how to deal with your model and symfony forms[^Form component], the latter designed in a way that requires your model to accept invalid states. In this post, I will describe how I deal with this using the *command pattern*.

> In object-oriented programming, the command pattern is a behavioral design pattern in which an object is used to encapsulate all information needed to perform an action or trigger an event at a later time.[^command pattern quote]

# The symfony way
A common best practice in symfony is to map your forms directly to your entities and use the validator[^Validator component] to validate the latter after the form has been submitted. This means that it will change the state of your entities before it has verified it should and that your entities must be able to contain invalid state. To map the data from the form to the entity, it uses the property access[^PropertyAccess component] component. This component relies on either getters/setters or public properties to change the state of an entity. All of these things lead to anemic domain models where the domain logic and/or other business rules live everywhere but the domain.

# Taking back control
In order to turn our anemic domain model into a rich one, we should start by moving the domain logic into the entity. The short answer is: we need to remove all the setters and ensure the entity itself is in charge of its own state. This means we cannot map our forms directly to our entities, so what do we map them to? Also, won't whatever we end up mapping the form to run into the same problems as we did with our entities?

# Introducing commands
Instead of directly mapping the form to the entity, we need to map it to a command instead. A command is a message in the form of a DTO that contains everything it needs to be executed and can be used to bridge the gap between the infrastructure layer and the domain. The execution can be done multiple ways, but I will be assuming a command or message bus is used.

In order for the execution to go smoothly, we should ensure the command is either valid or not made at all. This is where we run into similar issues as we did with our entity, so how do we solve them here? Using validator constraints directly applied to the form and a data mapper we can trick symfony into the behaviour we want. The form will still try to map the data before it has been validated, however we are now in control how this data is mapped. This means we choose *not* to map it when the data is invalid. After the mapping step, the form validation kicks in. In case the mapper did not yield a suitable result, the validator will directly validate the form instead. As long as the form has enough constraints to cover all the requirements of the command, you either end up with a valid command or an invalid form.

For the visual learners, here is some example code:

```php
<?php declare(strict_types=1);

namespace App;

use Webmozart\Assert\Assert;

final class YourCommand
{
    private string $foo;

    public function __construct(string $foo)
    {
        // Any assertions needed to ensure a valid command.
        Assert::minLength($foo, 3);
        Assert::maxLength($foo, 50);
        $this->foo = $foo;
    }

    public function getFoo(): string
    {
        return $this->foo;
    }
}
```

```php
<?php declare(strict_types=1);

namespace App;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;

final class YourCommandType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('foo', TextType::class, [
                'constraints' => [
                    // Apply any constraints required for this particular property.
                    new NotBlank(),
                    new Length([
                        'min' => 3,
                        'max' => 50,
                    ]),
                ]
            ])
        ;

        // In case your command needs outside information, e.g. the user performing it, you can require it as a form option and pass it to the constructor of the mapper.
        $builder->setDataMapper(new YourCommandMapper());
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => YourCommand::class,
            'empty_data' => null,
            'constraints' => [
                // This doesn't provide any useful information back to the end user and should not be relied upon. This is only present as a last resort in case a constraint is forgotten.
                new NotNull([
                    'message' => 'Something went wrong, please check your request and try again.',
                ]),
            ],
        ]);
    }
}
```
```php
<?php declare(strict_types=1);

namespace App;

use Symfony\Component\Form\DataMapperInterface;
use Throwable;
use function iterator_to_array;

final class YourCommandMapper implements DataMapperInterface
{
    public function mapDataToForms($data, $forms)
    {
        // Uni-directional is fine in case of an api or xhr request, otherwise, map the required data to the form here.
    }

    public function mapFormsToData($forms, &$data)
    {
        try {
            $forms = iterator_to_array($forms);

            $data = new YourCommand($forms['foo']->getData());
        } catch (Throwable $exception) {
            // Nothing to see here... We just need to catch it so symfony continues and eventually starts validating the form.
        }
    }
}
```

```php
<?php declare(strict_types=1);

namespace App;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class YourController
{
    public function __invoke(Request $request): Response
    {
        $form = $this->createForm(YourCommandType::class);
        $form->submit($request->request->all());

        if ($form->isSubmitted() && $form->isValid()) {
            /** @var YourCommand $command */
            $command = $form->getData();

            // Execute the command
            // e.g. $this->commandBus->handle($command);

            return new Response(null, Response::HTTP_NO_CONTENT);
        }

        // Handle form errors
        $errors = (string) $form->getErrors(true);
        
        return new Response($errors, Response::HTTP_BAD_REQUEST);
    }
}
```

[^Form component]: https://symfony.com/components/Form
[^Validator component]: https://symfony.com/components/Validator
[^PropertyAccess component]: https://symfony.com/components/PropertyAccess

[^command pattern quote]: https://en.wikipedia.org/wiki/Command_pattern