DEV Community

Johan de Ruijter
Johan de Ruijter

Posted on

Using symfony forms without sacrificing your domain

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!

Every once in a while a discussion pops up on twitter on how to deal with your model and symfony forms1, 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.2

The symfony way

A common best practice in symfony is to map your forms directly to your entities and use the validator3 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 access4 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 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
<?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.',
                ]),
            ],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode
<?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.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
<?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);
    }
}
Enter fullscreen mode Exit fullscreen mode

  1. https://symfony.com/components/Form 

  2. https://en.wikipedia.org/wiki/Command_pattern 

  3. https://symfony.com/components/Validator 

  4. https://symfony.com/components/PropertyAccess 

Discussion (2)

Collapse
ashm_18 profile image
ash-m • Edited on

Just a note: You're missing the obligatory extends AbstractController required for $this->createForm()

Personally, I'm not a fan of service locators or static factory methods in the Controller, though I'm willing to bet you can probably typehint the FormBuilder interface if you really want to inject it. That said, not sure about using ->submit(), but I guess that gets around some of the necessary extensions provided when going the default route (ie, the official docs recommend using ->handleRequest() which I don't know is possible without the HttpExtension, and then at that point you probably want to configure that outside of the controller somewhere)

That's my 2 cents. What are you're thoughts?

Collapse
neznajka profile image
Maris Locmelis • Edited on

have read this post. I can't understand why people so often love to write custom code, making things more complex than they should be. why you are choosing enterprise framework if you love write code ? chose Laravel // chose Phalcon // chose YII2 (or whatever the correct name is). Symfony life-cycle is controlled using Listeners. and when you are working with callback based system, making sure this system won't have invalid data will add majority of complexity. I don't argue that it is useless. but if you don't have complex case, why you need make some strange behavior with ValueObjects ? make setters make getters write clean code and read the code around you. don't need to live with bunch of incapsulated things do that like that. okay we will make saving data to database with Form + Entity + DTO and will add also 5 DTOS for external transfer, just in case some time it will help us.