-
-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Add documentation for using DTOs in form handling #10588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
4361c37
cfb8fcd
b9d3e04
e835adb
fa0c7b6
af805c4
1d8314f
37f755c
62782e8
914faf8
49eaaba
69e0a89
ca587bf
7176199
08f5aa0
391175a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,317 @@ | ||
.. index:: | ||
single: Form; Data Transfer Objects | ||
|
||
How to use Data Transfer Objects (DTO) | ||
====================================== | ||
|
||
Data transfer objects can be used by forms to separate entitites from the | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
validation logic of forms. | ||
Entities should always have a valid state. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be removed - adds no value IMHO |
||
When entities are used as data classes for a form, the data is injected into | ||
the entity and validated. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
When the validation fails, the invalid data is still left in the entity. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This can lead to invalid data being saved in the database. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
You will use the Maker bundle to highlight the differences between using DTOs | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In forms.rst, you is being used. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this sentence sounds strange, "You will etc"...: What? No I won't! :) |
||
and entities. | ||
|
||
.. index:: | ||
single: Installation | ||
|
||
Installation | ||
~~~~~~~~~~~~ | ||
|
||
In applications using :doc:`Symfony Flex </setup/flex>`, run this command to | ||
install the maker bundle before using it: | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
.. code-block:: terminal | ||
|
||
$ composer require maker --dev | ||
|
||
You will also need these packages in order to proceed with creating a crud | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 </forms>`, | ||
make it a doctrine entity (this requires adding a primary key) and add a | ||
validation by adding annotations. | ||
|
||
.. code-block:: diff | ||
|
||
// src/Entity/Task.php | ||
namespace App\Entity; | ||
|
||
+ use Symfony\Component\Validator\Constraints as Assert; | ||
+ use Doctrine\ORM\Mapping as ORM; | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
+ /** | ||
+ * @ORM\Entity | ||
+ */ | ||
class Task | ||
{ | ||
+ /** | ||
+ * @ORM\Id() | ||
+ * @ORM\GeneratedValue() | ||
+ * @ORM\Column(type="integer") | ||
+ */ | ||
+ protected $id; | ||
|
||
+ /** | ||
+ * @ORM\Column(type="string", length=255) | ||
+ * @Assert\NotBlank() | ||
+ */ | ||
protected $task; | ||
|
||
+ /** | ||
+ * @ORM\Column(type="datetime", nullable=true) | ||
+ */ | ||
protected $dueDate; | ||
|
||
public function getTask() | ||
{ | ||
return $this->task; | ||
} | ||
|
||
public function setTask($task) | ||
{ | ||
$this->task = $task; | ||
} | ||
|
||
public function getDueDate() | ||
{ | ||
return $this->dueDate; | ||
} | ||
|
||
public function setDueDate(\DateTime $dueDate = null) | ||
{ | ||
$this->dueDate = $dueDate; | ||
} | ||
} | ||
|
||
.. index:: | ||
single: Creating a data transfer object | ||
|
||
Creating a data transfer object | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
||
Now, create a data transfer object for the ``Task`` entity using the maker: | ||
|
||
.. code-block:: terminal | ||
|
||
$ php bin/console make:dto Task Task | ||
|
||
.. 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(message="This value should not be blank.", payload=null) | ||
*/ | ||
public $task; | ||
|
||
public $dueDate; | ||
|
||
/** | ||
* Create DTO, optionally extracting data from a model. | ||
* | ||
* @param Task|null $task | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
public function __construct(? Task $task = null) | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
if ($task instanceof Task) { | ||
$this->extract($task); | ||
} | ||
} | ||
|
||
/** | ||
* Fill entity with data from the DTO. | ||
* | ||
* @param Task $task | ||
*/ | ||
public function fill(Task $task): Task | ||
{ | ||
$task | ||
->setTask($this->getTask()) | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
->setDueDate($this->getDueDate()) | ||
; | ||
|
||
return $task; | ||
} | ||
|
||
/** | ||
* Extract data from entity into the DTO. | ||
* | ||
* @param Task $task | ||
*/ | ||
public function extract(Task $task): self | ||
{ | ||
$this->setTask($task->getTask()); | ||
$this->setDueDate($task->getDueDate()); | ||
|
||
return $this; | ||
} | ||
} | ||
|
||
Notice the assert annotation? This was 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. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
.. code-block:: diff | ||
|
||
// src/Form/TaskType.php | ||
namespace App\Form; | ||
|
||
- use App\Entity\Task; | ||
+ use App\Form\Data\TaskData; | ||
|
||
... | ||
|
||
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; | ||
|
||
... | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* @Route("/task") | ||
*/ | ||
class TaskController extends AbstractController | ||
{ | ||
|
||
... | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* @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()); | ||
$em = $this->getDoctrine()->getManager(); | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
$em->persist($task); | ||
$em->flush(); | ||
|
||
return $this->redirectToRoute('task_index'); | ||
} | ||
|
||
return $this->render('task/new.html.twig', [ | ||
'task' => $task, | ||
'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 successfull validation passed onto the ``Task`` entity. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
``Task`` entites will always be valid. | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
ckrack marked this conversation as resolved.
Show resolved
Hide resolved
|
||
--------------------------- | ||
|
||
There are some problems when using entities as directly mapped data classes for forms. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe not the word problems, but notice or info Alternatively, you can decouple your entities creation with DTO that are bound to form instead, see more |
||
These problems can be circumvented by using data transfer objects. | ||
See | ||
:doc:`/form/data_transfer_objects` for info. | ||
|
||
Final Thoughts | ||
-------------- | ||
|
||
|
Uh oh!
There was an error while loading. Please reload this page.