-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[RFC] [Form] Improve handling of domain objects in forms #37597
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
Comments
Andreas, thanks for creating this detailed issue! I hope it results in some improvements related to this. As a minor comment, I suggested using
|
For the records, as far as I can see parts of this proposal could currently be achieved by using RichModelFormsBundle. If there is a common ground of what we would like to change/add, I am sure that there is code that could be reused in core. |
Thanks for mentioning the bundle directly, @xabbuh! I referenced the readme above but forgot to add the name. |
I don't get the point of this issue. |
One more thing to consider are immutable objects. A form receives an immutable object, and when the form is submitted and valid, it returns another immutable object that contains the submitted data. Such an immutable object could be as simple as this:
So far I've had some success with a custom data mapper class (that implements DataMapperInterface), but it somewhat lacks elegance (and includes a code generator). |
Let's consider the following Entity and DTO: https://gist.github.com/alcaeus/0b66968ee505ab24bdb71fe7c88325a7. As you can see, the only job the DTO has is to map the form to the Category API, as this is not supported by the Form framework. I've left out validation on purpose, which I'll explain later down the line. The form for this DTO is simple, if we ignore the fact that we can't tell the form that it should never try to call a factory if no DTO was provided. A combination of data validation before applying to the entity (kind of a built-in DTO) as well as making the set and get accessors for a field customisable (to correctly map the value to the As for validation, there is one big reason I've tried to keep validation out of the entity: it makes people think that the entity is validated before it is stored. This is obviously incorrect, as the entity manager will not use the validator component to validate an entity when it is persisted. Validation is triggered by the form framework, so if an entity is created without the input of a form, validation is entirely bypassed and invalid data could be stored. There are a few ways to work around this issue:
When thinking about entities, immutable objects make little sense: if the identifier of the entity stays the same, then you are not really dealing with an immutable entity. Even worse, an immutable entity like this would cause two entities with the same identifier, where the UnitOfWork on persist needs to replace a managed object with another, potentially modified version. This isn't feasible at all. As for dealing with an immutable DTO, I don't think this makes sense, as the DTO is created for the sole purpose of holding data for the form and aid in mapping this data to the entity. It is valid for a single form bind only, thus negating any need for immutability. For the sake of the argument, when creating an immutable entity from the Category example above, I'd always make the API behave like for final class Category
{
private string $name;
public static function fromName(string $name): self
{
return new self($name);
}
private function __construct(string $name)
{
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
public function rename(string $name): self
{
return self::fromName($name);
}
} Granted, the form system could handle these better (e.g. by replacing its own data with the returned object instead of applying nested fields individually), but I feel this would expand the scope of this RFC too much. Nonetheless, this is still a valid improvement for the form component that could make working with such objects a lot easier. |
For the benefit of preserving the discussion, here are some points that came up in Slack (all brought up by the great @linaori)
So the reason I didn't talk about a DTO is because this works both with entities and DTOs. When using a DTO, the form will validate data before applying it to the DTO, taking away the need to do basic validation (e.g. are you using a valid email address). The DTO then gets a valid set of data and can do additional work with that data, such as more complex validation that should be done in a second step. The fact that the DTO then needs to be applied to the original entity is no longer the form's concern - it's only job is to map request data to an object, then let the user do something with the object. Remember, the DTO could also be the data that is given to a special entity manager that is handled by the entity manager: $entityManager->apply($entity, $dto); This would take the entity, apply data from the DTO according to some mapping/contract, then persist the entity to mark it for changes. The advantage is that depending on how this is built, changeset computation becomes significantly cheaper, as the dto basically defines what data changes. This does away with the need for specific comparison. However, this is far outside of the scope of this, but a great addition to the whole persistence layer (good luck getting that to play nice with Doctrine though...)
$data->collectionField->add(...); However, the API would very often look like this: $data->addCollectionItem(...); Reason for this is that the Doctrine collections that are passed here are always mutable, so users may not want to expose the collection object to avoid users modifying it at will. On the other hand, exposing all items in an array is similarly dangerous, as it negates any performance benefits one would get from using lazy collections (e.g. only fetch and hydrate data when required to do so). To cite an example, MongoDB returns a cursor with a single batch of data (the size of which can be configured when running the query), so a result collection based on a cursor (e.g. an inverse reference) would not need to be initialised completely and wouldn't consume lots of memory. Only when items are needed will they be fetched from the server as necessary and add to the memory footprint. Hence, I would opt for allowing to change the $builder->add('collection', CollectionType::class, [
'set' => null, // Or anything else that disallows calling the setter for the field (important to not replace managed collections)
'add' => fn (Category $category, Item $item): void => $category->addItem($item),
'remove' => fn (Category $category, item $item): void => $category->collection->remove($item), // Silly, I know, but I wanted to should the default and that the accessors could be significantly different.
]); |
Still, I don't see the problem with a form in proposed DTO. |
The idea is to make the DTO part of the form component, so users only have to add small bits of logic (e.g. a custom setter) to integrate it into a boilerplate dto. Call it a DTOBuilder similar to the FormBuilder if you must. When that is the case, the entity will always be valid, but could theoretically still be validated again after applying data from the DTO (which was proven valid earlier). |
honestly, IMHO we should get rid of furthermore, a usecase i find myself often implementing is combining N form fields to hydrate a single object field, e.g. another, IMHO valid point, was raised in slack to make model / view normalization more explicit. Currently there's a "normalized data" concept in betweens, whereas i think it can work directly like last but least, having a "post submit and validation" event would be nice. To encapsulate more logic in the form type, rather then in the controller after |
@alcaeus Don't assume there is only Doctrine and its Unit of Work, please. The difficulty with Symfony Forms is because of this assumption that provides some expectations of how the model layer behaves. Consider the following (simplified) example of a controller: function controller(Request $request, $id) {
$entityObject = $repository->findById($id);
$origImmutableDto = ImmutableDto::fromEntity($entityObject);
$form = $this->createForm(ImmutableDtoType::class, $origImmutableDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newImmutableDto = $form->getData();
// Do something wiith $origImmutableDto and $newImmutableDto
$newImmutableDto->applyToEntity($entityObject);
}
} In this example we used the immutable objects to communicate with the form and then to update the mutable entity object. However, the repository may not use ORM and the Unit of Work. The following example shows the use of data access object that does not track entities; it only calls select and update queries: function controller(Request $request, $id) {
$origImmutableDto = $daoRepository->findById($id)
$form = $this->createForm(ImmutableDtoType::class, $origImmutableDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newImmutableDto = $form->getData();
// Do something wiith $origImmutableDto and $newImmutableDto
$daoRepository->update($origImmutableDto, $newImmutableDto);
}
} The form in the both cases behaves exactly in the same way — it receives an immutable object and returns a new immutable object. The immutability shields the form from the repository internals and entity behavior. |
As for the validation: I really really like the concept that the form should return valid data only — it is an extremely important change because of the typed properties in PHP 7.4. |
Everyone, thanks for your input. I've taken some of it and incorporated it into the original idea, and I've come up with a Proof-of-concept pull request: #37614. The PR describes basic and advanced usage, so you can see if your scenario would be covered. I encourage you all to leave your feedback, as it is highly appreciated! To address some other points:
Yes, and no. A single
This is one of the examples shown in the PR as advanced usage. The RichModelFormsBundle does this by using the same property path for multiple fields, which doesn't work here. I believe the use-case is covered, but I'd like to hear your opinion.
I believe this is an important idea, but far outside of the scope here: this proposals changes nothing about how view, norm, and model data are handled. I do believe it is worthy of its own discussion though, so please feel free to create a separate issue.
I agree. The current proof of concept allows for simple errors to be handled. A
Apologies, I should know better than that. This PR assumes that the "model layer" of the form is something an accessor can be called on. You can receive a scalar value to access (e.g. a string that has to be run through a decoder to make it accessible), or you can receive an object. You can pass an immutable object, or you can even combine this with a DTO for some extra sugar. Let's take your first example, where the DTO needs to be applied to the entity. In this example, the DTO is mutable: function controller(Request $request, $id) {
$entityObject = $repository->findById($id);
$dto = MutableDto::fromEntity($entityObject);
$form = $this->createForm(MutableDtoType::class, $dto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($entityObject);
$entityManager->flush();
}
} In this case, "all" you have to do is to add a listener on the form that checks if the form was valid on
This allows for maximum flexibility and leaves your looking like any regular form and treat it like it handled the entity directly. Your second example spices this up by passing the original and modified DTO. Here, you would change all 'set' accessors to return the new object, triggering the immutability handling. In a |
I see four cases here:
|
For these cases, I would suggest the following:
|
The question is, which object should know how to map form fields to DTO properties. The form itself should only know that there are nested form fields for the DTO, nothing more. DTO should not be bound to the particular form — it is the form that uses the DTO, not the other way around. Therefore, the form fields are the only remaining thing that should know about the mapping. Or am I missing something? |
Description
This issue originated as a discussion thread in the Symfony Slack. To preserve the conversation and get more input from maintainers and the community, I'm creating this RFC.
People have been arguing for working with domain transfer objects (DTOs) in the form documentation, as there are a few problem to directly bind the form to a managed object (e.g. a Doctrine entity):
Category::getName()
andCategory::rename($name)
is not possibleTo solve this, users can create a DTO to hold the data and apply it to the managed object after it has been validated. However, this potentially adds a bunch of boilerplate code (e.g. a class with public properties and a
apply
method to apply changed data to the form). In many instances, this can be avoided.There is a bundle that adds
read_property_path
andwrite_property_path
to the form component (Example). However, @linaori was quick to point out that using strings can cause issues with IDE integration, e.g. by a refactoring not catching the usage in a form as it's not a code reference. @javiereguiluz then suggested the following:Example
Note that the getter in this case could be omitted since it replicates the default behaviour.
With short closures, the example gets even more concise:
This does not solve the problem of validation only being run after modifying the object, but it gives people more freedom when designing the public API for their domain objects, and may avoid the need for a DTO in some instances.
Future scope
Exception handling
Exceptions while calling any of the closures will lead to a 500 error. This could be avoided by expanding the closure to catch the exception, but then the question is how this can be communicated to the user. The bundle mentioned above introduces a
expected_exception
form option. If an exception of the given type is thrown, the exception is caught and it is added as a form error. While this may offer more flexibility, I think it introduces yet another form option that people have to understand. An alternative would be to always catch aValidationExceptionInterface
interface provided by the form component. TheValidationException
class would provide a generic exception for people to use. This allows doing the following:Change form validation rules
When binding a form, form data is sent through the data transformers, then applied to the object, and last but not least validated by the validation component. This reads constraints from the form as well as from the object and validates all constraints. It may be sensible to change the order when binding:
The order above would allow people to keep form-specific validation logic in the form (which would otherwise be put in a DTO) while doing other, potentially more complex validation after data is applied to the object:
Next steps
For now, I'm looking for feedback from people that have previously created DTOs for their form <-> entity mappings, as well as from users using the bundle mentioned above. I've used the bundle myself, but my last work with Symfony Forms was almost 2 years ago, so I'm a bit outdated when it comes to form. After that, I could offer to build a prototype for the functionality outlined above.
The text was updated successfully, but these errors were encountered: