diff --git a/form/data_transfer_objects.rst b/form/data_transfer_objects.rst new file mode 100644 index 00000000000..46c31505dc6 --- /dev/null +++ b/form/data_transfer_objects.rst @@ -0,0 +1,316 @@ +.. index:: + single: Form; Data Transfer Objects + +How to use Data Transfer Objects (DTOs) +======================================= + +Data Transfer Objects can be used by forms to separate entities from the +validation logic of forms. +When entities are used as data classes for a form, validation happens after the +form data has been injected into the entities. +When the validation fails, the invalid data is still left in the entities. +This can lead to invalid data being saved in the database or unexpected +exceptions. + +Let's use the Maker bundle to highlight the differences between using DTOs and +entities. + +.. index:: + single: Installation + +Installation +~~~~~~~~~~~~ + +In applications using :doc:`Symfony Flex `, run this command to +install the Maker bundle before using it: + +.. code-block:: terminal + + $ composer require maker --dev + +You will also need these packages in order to proceed with creating a CRUD +example: + +.. code-block:: terminal + + $ composer require form validator twig-bundle orm-pack security-csrf annotations + +Use the example ``Task`` entity from :doc:`the main forms tutorial ` +and make it a Doctrine entity (this requires adding a primary key ``id``). + +.. code-block:: diff + + // src/Entity/Task.php + namespace App\Entity; + + + use Doctrine\ORM\Mapping as ORM; + + use Symfony\Component\Validator\Constraints as Assert; + + + /** + + * @ORM\Entity + + */ + class Task + { + + /** + + * @ORM\Id() + + * @ORM\GeneratedValue() + + * @ORM\Column(type="integer") + + */ + + private $id; + + /** + + * @ORM\Column(type="string", length=255) + * @Assert\NotBlank() + */ + private $task; + + /** + + * @ORM\Column(type="datetime", nullable=true) + * @Assert\NotBlank() + * @Assert\Type("\DateTime") + */ + private $dueDate; + + + public function getId() + + { + + return $this->id; + + } + + // ... + } + +.. index:: + single: Creating a Data Transfer Object + +Creating a Data Transfer Object +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Now, create a Data Transfer Object for the ``Task`` entity using the maker: + +.. code-block:: terminal + + $ php bin/console make:dto TaskData + + The name of Entity that the DTO will be bound to: + > Task + + Add helper extract/fill methods? (yes/no) [yes]: + > + + Omit generation of getters/setters? (yes/no) [yes]: + > + +.. tip:: + + Ignore the next steps suggested by the command for now, you will generate a + complete CRUD with a different maker instead of a form in the next step. + +If you used the defaults during the dialogue, you will end up with the +following ``TaskData`` class: + +.. code-block:: php + + // src/Form/Data/TaskData.php + namespace App\Form\Data; + + use App\Entity\Task; + use Symfony\Component\Validator\Constraints as Assert; + + /** + * Data transfer object for Task. + * Add your constraints as annotations to the properties. + */ + class TaskData + { + /** + * @Assert\NotBlank() + */ + public $task; + + /** + * @Assert\NotBlank() + * @Assert\Type(type="\DateTime") + */ + public $dueDate; + + /** + * Create DTO, optionally extracting data from a model. + */ + public function __construct(?Task $task = null) + { + if ($task instanceof Task) { + $this->extract($task); + } + } + + /** + * Fill entity with data from the DTO. + */ + public function fill(Task $task): Task + { + $task->setTask($this->task); + $task->setDueDate($this->dueDate) + + return $task; + } + + /** + * Extract data from entity into the DTO. + */ + public function extract(Task $task): self + { + $this->task = $task->getTask(); + $this->dueDate = $task->getDueDate(); + + return $this; + } + } + +Notice the assert annotations? These were copied from the Task entity. +The ``extract`` and ``fill`` methods can be used to populate the DTO with data +from the entity and vice versa. + +.. caution:: + + During the generation of a DTO, validation annotations are copied from the + Entity. + You must ensure that changes to the validations are added in both places + when the entity is used with forms in other places (like + ``SonataAdminBundle`` or ``EasyAdminBundle``). + If the entity is not used at all, it is recommended to move all validations + into the DTO, removing them from the entity class. + +.. index:: + single: Using the DTO in the Form + +Using the DTO in the Form +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the maker to create a simple CRUD application. + +.. code-block:: terminal + + $ php bin/console make:crud Task + +This will generate a bunch of templates, a controller and a form. +First, take a look at the generated ``TaskType`` form. + +Notice that it uses the ``Task`` entity by default. +This means that the form data is injected into the ``Task`` entity directly and validated with the annotations. + +Replace this with ``TaskData`` to prevent the aforementioned problems with an invalid entity. + +.. code-block:: diff + + // src/Form/TaskType.php + namespace App\Form; + + - use App\Entity\Task; + + use App\Form\Data\TaskData; + + use Symfony\Component\Form\Extension\Core\Type\DateType; + // ... + + class TaskType extends AbstractType + { + // ... + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + - 'data_class' => Task::class, + + 'data_class' => TaskData::class, + ]); + } + } + +.. index:: + single: Using the DTO in the Controller + +Using the DTO in the Controller +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Now, look at the ``App\Controller\TaskController`` class, that was generated by ``make:crud`` earlier. +It also uses the ``Task`` entity directly. +This is fine for the ``index()`` and ``show()`` methods, as no data is written there. + +Replace the ``Task`` entity with ``TaskData`` in the ``new()`` and ``edit()`` methods, using the ``fill()`` helper. + +.. code-block:: diff + + // src/Controller/TaskController.php + namespace App\Controller; + + use App\Entity\Task; + + use App\Form\Data\TaskData; + + // ... + + /** + * @Route("/task") + */ + class TaskController extends AbstractController + { + + // ... + + /** + * @Route("/new", name="task_new", methods="GET|POST") + */ + public function new(Request $request): Response + { + - $task = new Task(); + - $form = $this->createForm(TaskType::class, $task); + + $taskData = new TaskData(); + + $form = $this->createForm(TaskType::class, $taskData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $task = $taskData->fill(new Task()); + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($task); + $entityManager->flush(); + + return $this->redirectToRoute('task_index'); + } + + return $this->render('task/new.html.twig', [ + - 'task' => $task, + + 'task' => $taskData, + 'form' => $form->createView(), + ]); + } + +The form handles the data using ``TaskData``, the ``Task`` entity now is only created after validation. + +In ``edit()``, the ``Task`` entity is injected by Symfony's ``ParamConverter``. +Create a new ``TaskData`` object and pass it the ``Task`` entity (internally, the ``extract()`` helper will populate the DTO). +Replace the ``$task`` argument with ``$taskData`` in the ``createForm()`` call, so that the form uses the DTO. + +.. code-block:: diff + + /** + * @Route("/{id}/edit", name="task_edit", methods="GET|POST") + */ + public function edit(Request $request, Task $task): Response + { + - $form = $this->createForm(TaskType::class, $task); + + $taskData = new TaskData($task); + + $form = $this->createForm(TaskType::class, $taskData); + + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $task = $taskData->fill($task); + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('task_edit', ['id' => $task->getId()]); + } + + return $this->render('task/edit.html.twig', [ + 'task' => $task, + 'form' => $form->createView(), + ]); + } + +Now, when the user submits data, it is first validated using ``TaskData`` and only after successful validation passed onto the ``Task`` entity. +``Task`` entities will always be valid. diff --git a/forms.rst b/forms.rst index e859348f40a..19060ec1061 100644 --- a/forms.rst +++ b/forms.rst @@ -46,8 +46,8 @@ going to need to build a form. But before you begin, first focus on the generic class Task { - protected $task; - protected $dueDate; + private $task; + private $dueDate; public function getTask() { @@ -338,13 +338,13 @@ object. /** * @Assert\NotBlank() */ - public $task; + private $task; /** * @Assert\NotBlank() * @Assert\Type("\DateTime") */ - protected $dueDate; + private $dueDate; } .. code-block:: yaml @@ -695,6 +695,14 @@ the choice is ultimately up to you. to modify it, use the :method:`Symfony\\Component\\Form\\FormFactoryInterface::createNamed` method. You can even suppress the name completely by setting it to an empty string. +Using Data Transfer Objects +--------------------------- + +There are some problems when using entities as directly mapped data classes for forms. +These problems can be circumvented by using Data Transfer Objects. +See +:doc:`/form/data_transfer_objects` for info. + Final Thoughts --------------