Rémi Andieux
Développeur chez MyLittleBox.
Le composant Form est un de ceux qui ont largement contribué au succès et à la popularité de l’écosystème Symfony. Il propose un système puissant et flexible qui permet d’unifier et de simplifier la génération et le traitement de formulaires. Il est aussi connu pour implémenter plusieurs design patterns, qui lui permettent d’être à la fois rapide à utiliser, et suffisamment flexible pour s’adapter à tous les besoins, sans se retrouver bloqué devant de la magie noire impénétrable (le cauchemar de tout développeur).
Je ne m’étendrai pas sur les fonctionnalités de ce composant et de son intégration avec Symfony étant donné sa popularité. Cependant, dans cet article je vais parler de son lien avec notre modèle de données : nos entités.
Je vais prendre un exemple simple, que l’on suivra tout au long de l’article.
Voici ma user story :
En tant que gestionnaire de mon application de vente de camion transporteurs,
quand je suis sur le backoffice de mon application,
je souhaite pouvoir ajouter un modèle tout en renseignant ses informations propres : modèle, marque, année de construction, kilomètres au compteur.
ProTip pour répondre à ce besoin : lire l'excellent l’article de Baptiste sur EasyAdmin 😄
Fin. Merci d’avoir lu !
Avant de rentrer dans le vif du sujet, je tiens quand même à rappeler pourquoi on aime Form, et particulièrement à travers ses choix de conception :
C’est un composant assez facile d’accès. Il est aisé de créer son premier formulaire en quelques lignes en suivant la documentation.
Voici notre entité pour référence :
class HeavyTruck
{
/**
* @Assert\Length("min"=5)
* @var string
*/
private $reference;
/**
* @Assert\Valid
* @var Brand
*/
private $brand;
/**
* @Assert\Length("min"=4, "max"=4)
* @var int
*/
private $buildYear;
/**
* @var float
*/
private $kilometers;
/**
* @return string
*/
public function getReference(): string
{
return $this->reference;
}
/**
* @param string $reference
* @return HeavyTruck
*/
public function setReference(string $reference): HeavyTruck
{
$this->reference = $reference;
return $this;
}
/**
* @return Brand
*/
public function getBrand(): Brand
{
return $this->brand;
}
/**
* @param Brand $brand
* @return HeavyTruck
*/
public function setBrand(Brand $brand): HeavyTruck
{
$this->brand = $brand;
return $this;
}
/**
* @return int
*/
public function getBuildYear(): int
{
return $this->buildYear;
}
/**
* @param int $buildYear
* @return HeavyTruck
*/
public function setBuildYear(int $buildYear): HeavyTruck
{
$this->buildYear = $buildYear;
return $this;
}
/**
* @return float
*/
public function getKilometers(): float
{
return $this->kilometers;
}
/**
* @param float $kilometers
* @return HeavyTruck
*/
public function setKilometers($kilometers): HeavyTruck
{
$this->kilometers = $kilometers;
return $this;
}
}
Et le formulaire associé :
<?php
class HeavyTruckType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('reference')
->add('brand')
->add('buildYear')
->add('kilometers')
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => HeavyTruck::class,
]);
}
}
Je ne parlerai pas ici des options propres à chaque champ car cela ne nous intéresse pas ici, et je veux garder des exemples simples pour me concentrer sur les données et non la configuration.
On note que cette approche basique a l’avantage de regrouper la génération du formulaire et son remplissage, grâce à l’implémentation native du composant PropertyAccess
.
Ce duo entité / formulaire est idéal dans un contexte de RAD car il permet de d’implémenter rapidement des write actions sur son modèle, tout en ayant quelque chose de clair et extensible. Je passe la partie "contrôleur" que vous connaissez bien.
Notre besoin ici est simple, cette “magie” nous suffit amplement.
Mais que se passe-t-il si notre besoin évolue, si on doit faire des traitements plus poussés sur nos champs ? Si on veut utiliser plusieurs champs pour ne remplir qu’une propriété ?
On peut adapter la magie par l’implémentation de plusieurs points d’entrées permettant de mettre sa propre logique de traitement, c’est à dire les
FormEvents
et les DataTransformers
, ce qui est suffisant dans la plupart des cas.
La force du composant Form réside aussi dans son implémentation du design pattern Composite
, qui apporte toute la flexibilité nécessaire pour créer des formulaires complexes.
Revenons sur l’exemple que j’ai fourni en début d’article. C’est un exemple typique qu’on peut trouver dans la documentation de Symfony. Comme je l’ai expliqué, cela fonctionne très bien et est très pratique dans de nombreux cas.
Pourquoi ? Parce que la classe de formulaire est fortement couplée au modèle.
<?php
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => HeavyTruck::class,
]);
}
En spécifiant data_class
directement sur l’entité, voici ce qui va se passer à la soumission du formulaire (je n’ai retenu que ce qui nous intéresse ici) :
1) les données du formulaire sont passées dans chaque “champ”
2) Un objet HeavyTruck
est hydraté avec ces données
3) L’objet hydraté est validé
Ce qui signifie qu’on crée / modifie un objet métier et qu’on valide ses données ensuite. C’est problématique en terme de conception.
Imaginons que dans mon application, pour une raison (que la raison ignore 😄) je souhaite faire un flush()
sur chaque requête (par exemple dans un listener branché sur un kernel event).
Je passe dans mon formulaire, l’objet est hydraté ; la validation s’opère, disons qu’elle renvoie des erreurs. L’objet ne match pas les critères de validations, il ne doit en aucun cas se retrouver persisté auquel cas votre base de donnée se retrouvera corrompue.
Et pourtant… si vous utilisez un ORM et que l’objet qui vient d’être hydraté est déjà managé, et bien Doctrine va computer les changements et ils se retrouveront dans votre base au moment au moment du flush()
!
En fait, même sans avoir de listener qui flush, il suffit qu’un flush()
se soit perdu quelque part avant la fin de la requête (dans un manager, ou juste avant de faire le rendu de votre template, cas classique).
<?php
class TruckController extends AbstractController
// $truck est injecté par un ParamConverter, il est managé par notre ORM
public function editAction(Request $request, Truck $truck)
{
$form = $this->formFactory->createForm(TruckType::class, $truck);
if ($form->isValid()) {
$this->entityManager->flush();
return $this->templating->render('list');
}
// oh un flush perdu
$this->entityManager->flush();
return $this->templating->render('edit');
}
Ceci est un simple exemple pour montrer que le cas peut exister. Il est de notre responsabilité en tant que développeurs de concevoir nos applications afin de limiter au mieux les effets de bord, en appliquant un peu la loi de Murphy.
L’idée à retenir ici est donc qu’il faut éviter d’hydrater un objet métier invalide (= non validé), et que si vous utilisez Form en mode RAD, alors vous ne pourrez pas l’empêcher.
Cependant, comme abordé plus haut, il reste toujours la possibilité d’utiliser des FormEvents
pour appliquer de la logique avant que l’objet soit hydraté.
L’utilisation des FormEvents
n’est pas très intuitive selon moi.
La donnée contenue dans les events n’est pas toujours fournie sous le même format, parfois sous forme de tableau, parfois sous forme d’objet hydraté — ce qui fait sens au vu du workflow du composant mais qui peut être un peu déroutant à l’utilisation.
De plus ces listeners sont fortement couplés à la classe de formulaire elle-même, et il n’est pas très sain d’essayer de les rendre réutilisables. Les DataTransformers
sont plus adaptés à ce cas de figure mais permettent de transformer un champ à la fois.
Il peut être donc un peu fastidieux de passer par ces étapes pour des besoins complexes.
Pour valider les données avant qu’elles ne soient effectivement utilisées, l’utilisation des FormEvents
ne semble pas vraiment adaptée : il faudrait valider alors un array de données en reprenant les règles de validation définies dans l’entité et lever une FormError
manuellement pour chaque… ça reste faisable mais pas vraiment intuitif selon moi.
En bref :
Notre mission : rendre nos formulaires indépendants de nos entités.
Pour l’instant, nous avons directement lié notre formulaire à notre entité.
Pourquoi ne pas envisager de le lier à un objet intermédiaire ?
Quelques avantages que cela représente :
Assez de blabla, voici un exemple :
<?php
class ChangeTruckDetailsDTO
{
/**
* @Assert\Length("min"=5)
* @var string
*/
public $reference;
/**
* @Assert\Valid
* @var Brand
*/
public $brand;
/**
* @Assert\Length("min"=4, "max"=4)
* @var string
*/
public $buildYear;
}
DTO = Data Transfer Object, ce n’est qu’un nommage appelez ça comme vous voulez
On voit ici que le DTO contient nos règles de validation. On peut appliquer ce principe récursivement à nos “sous-classes” de formulaire.
Form va donc valider que le DTO est valide, et on pourra setter nos propriétés de champ manuellement.
Dans notre formulaire, aucune mention n’est faite d’aucune entité.
<?php
class HeavyTruckType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('reference')
->add('brand')
->add('buildYear')
->add('kilometers')
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ChangeTruckDetailsDTO::class,
]);
}
}
Il est ok selon moi d’implémenter une méthode qui permet d'hydrater le DTO avec les données de l’entité, ce qui est bien pratique dans un formulaire de modification, exemple :
<?php
class ChangeTruckDetailsDTO
{
public static function createFromEntity(HeavyTruck $entity)
{
$truck = new static;
$truck->reference = $entity->getReference();
$truck->brand = $entity->getBrand();
$truck->buildYear = $entity->getBuildYear();
return $truck;
}
}
Et on peut ainsi avoir notre formulaire découplé de l’entité !
<?php
class TruckController extends AbstractController
{
public function editAction(Request $request, HeavyTruck $truck)
{
$dto = ChangeTruckDetailsDTO::createFromEntity($truck);
$form = $this->formFactory->createForm(ChangeTruckDetailsDTO::class, $dto);
if ($form->isValid()) {
// on est libre ici, je n'ai pas fait de traitement spécifique mais c'est possible.
$truck->setReference($dto->reference);
$truck->setBrand($dto->brand);
$truck->setBuildYear($dto->buildYear);
$this->entityManager->flush();
return $this->templating->render('list');
}
// oh un flush perdu, mais cette fois $truck n'est pas modifié
$this->entityManager->flush();
return $this->templating->render('edit');
}
}
Tout cela est bien beau, mais on se retrouve avec une classe de plus à gérer.
S'imposer de valider un DTO avant d’hydrater l’entité correspondante implique une dupliquation des règles de validation, et donc de complexifier leur maintenance.
Je pense que c'est mauvaise idée de mettre ces règles uniquement dans le DTO car il devient alors impossible de valider directement l’entité, on pénalise alors tous les autres cas où l’on crée des objets sans passer par le formulaire.
C’est pour moi un vrai frein à cette alternative.
Le fait est qu’honnêtement, il n’est pas si courant d’avoir besoin d’un modèle pour le formulaire qui diffère de celui de l’entité. Du coup il semble un peu overkill de créer un DTO puis de setter manuellement les propriétés alors que Form implémente tout ce qu’il faut pour ça. Et dans le combat conception versus pragmatisme, c’est souvent le pragmatisme qui l'emporte.
Martin Fowler est lui même assez mitigé sur l'utilisation des DTO dans ce genre de contexte : https://martinfowler.com/bliki/LocalDTO.html (en anglais).
Marco Pivetta (Ocramius), lui, recommande cet usage pour les formulaires.
Personnellement, je ne recommande pas d’utiliser cette pratique sur des petites applications qui n’ont pas de gros besoins évolutifs.
Cependant je trouve cette approche intéressante si vous aimeriez avoir un formulaire qui diffère un peu de la façon dont votre objet métier est fait, et/ou que vous portez beaucoup d’importance à votre conception applicatif et que cela ne vous pose pas de problème d’y ajouter cette couche.
Form excelle pour créer des formulaires complexes pour notre modèle, mais pour cela il doit être y être fortement couplé, ce qui n’est pas sans danger pour notre application. On peut ajouter une couche “tampon” avec un DTO mais cela alourdit le développement des formulaires, qui est pourtant assez orienté RAD.
Alors oui, cet article se termine un peu sur un goût amer… mais cela correspond à la réalité !
Je pense qu’il serait intéressant que ce questionnement ait lieu dans le développement du composant Form. Je ferai peut-être une RFC si l’article fait des remous 😛
"Doctrine Best Practices" par Marco Pivetta
"Ne laissez pas les formulaires Symfony influencer votre modèle" par Jeremy Barthe
"Avoiding Entities in Forms" par Iltar van der Berg (en anglais)