Description
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):
- Data is written to the entity without checking for validity first
- Subsequent validation may flag the form as invalid, but doesn't restore original data
- Subsequently flushing the managed object in the manager (e.g. EntityManager when using Doctrine) may write invalid data to the database and potentially affect unrelated managed objects
- The PropertyAccess only supports accessing properties through accessor methods that are rather hardcoded.
- Using separate methods for write and read operations (e.g.
Category::getName()
andCategory::rename($name)
is not possible
To 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
and write_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
$builder->add('name', TextType::class, [
'get' => function (?Category $category): ?string { return $category !== null ? $category->getName() : null; },
'set' => function (Category $category, $value): void { $category->rename($value); },
]);
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:
$builder->add('name', TextType::class, [
'get' => fn (?Category $category): ?string => $category !== null ? $category->getName() : null,
'set' => fn (Category $category, $value): void => $category->rename($value),
]);
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 a ValidationExceptionInterface
interface provided by the form component. The ValidationException
class would provide a generic exception for people to use. This allows doing the following:
- Users can implement the interface in their domain exceptions:
// Exception class
class NameNotAllowedException extends \RuntimeException implements ValidationExceptionInterface
{
// ...
}
// Domain object
class Category
{
public function rename(string $name): void
{
if (...) {
throw NameNotAllowedException::notAllowed($name);
}
...
}
}
- Alternatively, users can handle a specific exception in the closure:
$builder->add('name', TextType::class, [
'set' => function (Category $category, $value): void {
try {
$category->rename($value);
} catch (DomainException $e) {
throw ValidationException::fromPrevious($e);
}
},
]);
- The functionality above could also be provided by a closure:
// DomainValidationHandler.php
public function wrap(string $exceptionClass, Closure $closure): Closure
{
$code = <<<'CODE'
return function (object $object, $value): void {
try {
$closure($object, $value);
} catch (%s $e) {
throw ValidationException::fromPrevious($e);
}
};
CODE;
return eval(sprintf($code, $exceptionClass));
}
// In form
$builder->add('name', TextType::class, [
'set' => DomainValidationHandler::wrap(
DomainException::class,
fn (Category $category, $value): void => $category->rename($value)
),
]);
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:
- Take data from form and run through data transformers. Store transformed data
- Run constraints defined on form fields on model data. Any validation errors are added to the form. If errors are found, binding the form is aborted without changing the object (or creating one if none was passed)
- Apply data to the object. This ensures that any early constraints (e.g. invalid types in the form) can be caught beforehand. Exception handling as described above is optional here.
- Run validation constraints defined on the object. Again, errors are added to the form.
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:
$builder->add('name', TextType::class, [
'set' => DomainValidationHandler::wrap(
DomainException::class,
fn (Category $category, $value): void => $category->rename($value)
),
'constraints' => [
new UniqueEntity([
'entityClass' => Category::class,
'fields' => ['name'],
'id' => // ID of entity being edited will have to be passed from options
],
]);
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.