Utilisation des formulaires Symfony avec les propriétés typées PHP

Publié le 20/05/2023 • Actualisé le 20/05/2023

Dans cet article, nous voyons comment utiliser les formulaires Symfony avec des propriétés typées non-nullable PHP. Nous passons en revue les problèmes possibles et comment les prendre en charge. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 856" (du 22 au 28 mai 2023).

Prérequis

Je présumerai que vous avez des connaissances élémentaires de Symfony et que vous savez comment créer une classe de formulaire étendant la classe AbstractType.

Configuration

  • PHP 8.3
  • Symfony 6.4.5

Introduction

Les propriétés typées PHP ont été ajoutées à PHP dans sa version 7.4. C'est l'une de ces fonctionnalités qui nous interpellent : "Comment pouvait-on faire sans avant ?". Mais, elle vient aussi avec son lot de surprises, par exemple, le nouvel état non initialisé des propriétés de classe et de l'erreur associée que nous avons tous rencontré.

But

Le but de cet article est de montrer comment créer un formulaire associé à une entité contenant des propriétés typées non nullable et voir quelques moyens et astuces pour tout faire fonctionner ensemble correctement.

Le problème

Créons deux propriétés produit et catégorie, les deux ayant une propriété nom qui est de type chaîne et non nullable. Cette propriété ressemble à ceci :

    private const NAME_LENGTH = 255;

    /**
     * Short and main name of the category.
     */
    #[ORM\Column(type: Types::STRING, length: self::NAME_LENGTH, nullable: false)]
    #[Assert\NotBlank]
    #[Assert\Length(max: self::NAME_LENGTH)]
    private string $name;

La propriété est une chaîne, elle n'est pas nullable et est sans valeur par défaut. Elle n'est pas non plus nullable dans la base de données grâce à l'option nullable: false de l'attribut Column de Doctrine. Notons que l'on tire avantage des attributs PHP pour partager la même valeur pour la longeur de chaîne grâce à une constante. Maintenant, créons le formulaire pour la catégorie :

<?php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Category;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Article 254: category form, name only.
 */
final class CategoryType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'required' => false,
            'empty_data' => '',
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Category::class,
            'attr' => [
                'novalidate' => 'novalidate',
            ],
        ]);
    }
}

Ce qui nous donne :

Avec cette configuration, tout marche correctement. Si nous laissons le champ vide, nous avons l'erreur "Cette valeur ne doit pas être vide." N'oublions pas que la contrainte NotBlank empêche aussi l'utilisation de valeur nulle. Maintenant, créons un validateur factice. Pour ce faire, nous pouvons utiliser la contrainte Callback :

    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context): void
    {
        if ($this->name === '') {
            return;
        }

        // check if the name is actually a fake name
        if (u($this->name)->containsAny(['coil'])) {
            $context->buildViolation('This name is fordidden')
                ->atPath('name')
                ->addViolation();
        }
    }

On crée une contrainte factice ou l'on empêche l'utilisation de certaines chaînes (un exemple similaire est utilisé dans la documentation). Si l'on soumet le formulaire sans valeur, cette fois, nous avons la fameuse erreur :

Typed property App\Entity\Category::$name must not be accessed before initialization

En effet, comme le nom n'est pas initialisé, quand on appelle $this->name dans le validateur callback, cette erreur est levée, comment corriger ceci ? 🤔

Les propriétés non initialisées PHP

Cet état a été introduit dans PHP 7.4. Qu'est-ce que ça veut exactement dire ? Si on lit la RFC, il est indiqué :

Si une propriété typée est n'a pas de valeur par défaut, la valeur nulle n'est pas implicite (même si la propriété est nullable). À la place, la propriété est considérée comme étant non initialisée. Les lectures des propriétés généreront une erreur TypeError (à moins que __get() soit définit, voir la section suivante).

Si l'on inspecte un nouvel objet catégorie, on a la sortie suivante :

var_dump(new Category());
object(App\Entity\Category)#2427 (0) {
  ["id":"App\Entity\Category":private]=>
  uninitialized(Symfony\Component\Uid\Uuid)
  ["name":"App\Entity\Category":private]=>
  uninitialized(string)
}

On peut distinguer les propriétés non initialisées. Désormais, revenons à notre validateur de formulaire personnalisé.

Test des propriétés PHP non initialisées

Pour tester si une propriété est initialisée, on peut utiliser la fonction isset(). Cela peut sembler étrange puisque cette fonction existe depuis PHP 4 ! Son but originel est de tester si une variable existe. Ce comportement a été étendu avec PHP 7.4 pour gérer le cas des propriétés non initialisées. Avant PHP 7.4, si une propriété (sans typage donc) n'est pas initialisée, la fonction isset() retourne true. Vous pouvez vérifier ceci dans ce snippet, ou nous voyons aussi comment faire avec la réflexion.

Revenons au validateur personnalisé. On doit donc utiliser la fonction isset() avant de faire le test sur la chaîne vide :

if (!isset($this->name) || $this->name === '') {

Soumettons de nouveau le formulaire avec une valeur vide, oui, cela fonctionne, nous n'avons plus l'erreur de type. Mais, n'oublions pas que la fonction isset() retourne aussi false si la propriété est nulle. Ce n'est pas réellement une bonne pratique dans ce cas, pouvons-nous faire mieux ?

L'option empty_data des formulaires

Connaissez-vous cette option ? Que dit la documentation :

The empty_data option allows you to specify an empty data set for your form class. This empty data set would be used if you submit your form, but haven't called setData() on your form or passed in data when you created your form.

Souvenons-nous qu'un formulaire est une collection d'éléments de formulaire (Form). Cela veut dire que tous les types héritent de cette option générique. Essayons :

$builder->add('name', TextType::class, [
    'required' => false,
    'empty_data' => '',
]);

Retirons le test isset() dans le validateur callback et soumettons le formulaire de nouveau. Oui, ça fonctionne désormais, nous n'avons pas l'erreur sans avoir à utiliser isset().

Mais, ne pouvons-nous pas contourner ce problème grâce à un objet qui aurait déjà la propriété nom initialisée ? Essayons :

$newCategory = (new Category())->setName('');
$form1 = $this->formFactory->create(CategoryType::class, $newCategory)->handleRequest($request);
$data['form1'] = $form1->createView();

On soumet le formulaire avec une valeur par défaut, cette fois, on a l'erreur :

Expected argument of type "string", "null" given at property path "name".

Que se passe-t-il ici ? Parce que l'on a retiré l'option empty_data de l'élément nom, quand le formulaire est soumis, le composant formulaire essaie d'assigner la valeur nulle à la propriété nom, voici le code utilisé (vendor/symfony/property-access/PropertyAccessor.php) :

$object->{$mutator->getName()}($value);

$object est l'objet catégorie. $mutator->getName() retourne setName et la valeur est nulle. Ce qui provoque l'erreur que nous avons puisque le setter n'accepte pas les valeurs nulles. On doit donc garder l'option empty_data pour la propriété nom. OK, mais quid des objets ? Par exemple, si nous avons un produit qui est lié à une catégorie obligatoire ?

Quid des associations Doctrine ?

Créons une entité produit, elle a la même propriété nom ainsi qu'une relation ManyToOne $category vers les catégories que nous venons d'utiliser :

    /**
     * Main category of the product.
     */
    #[ORM\ManyToOne(targetEntity: Category::class)]
    #[ORM\JoinColumn(referencedColumnName: 'id', nullable: false)]
    #[Assert\Sequentially([
        new Assert\NotBlank(),
        new Assert\Callback([self::class, 'validate']),
    ])]
    private Category $category;

Pour créer un produit, le formulaire peut ressembler à ceci, on rend les champs non obligatoires, pour avoir une option vide pour la catégorie :

<?php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Category;
use App\Entity\Product;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Article 254: product form, name + category.
 */
final class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'required' => false,
            'empty_data' => '',
        ]);

        $builder->add('category', EntityType::class, [
            'required' => false,
            'class' => Category::class,
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Product::class,
            'attr' => [
                'novalidate' => 'novalidate',
            ],
        ]);
    }
}

Ce qui nous donne :

Encore une fois, avec cette configuration, ce sera OK et nous n'aurons pas de problème concernant les propriétés non initialisées. J'ai d'abord cru que nous pouvions utiliser la même astuce avec l'option empty_data pour l'élément EntityType. Cependant ce n'est pas aussi simple que de mettre un objet Category factice à l'option empty_data, dans le validateur callback, on ne reçoit pas l'objet factice et la catégorie est toujours non initialisée (voir lien "plus sur le web" en fin d'article).
On doit dans ce cas créer un transformateur de données (voir le lien StackOverflow à la fin de cet article) mais essayons de ne pas alourdir le formulaire et essayant une méthode alternative.

Cette fois, nous utilisons quelque chose d'autre, si l'on regarde la propriété catégorie, les validateurs sont encapsulés dans une contrainte Assert\Sequentially. Comme son nom l'indique, elle permet de faire de la validation séquentiellement et arrête le processus dès qu'une erreur est levée. Donc dans notre cas, le validateur Blank est testé, puis le callback, mais seulement si le premier n'a pas levé d'erreur. Ce qui nous donne :

    public static function validate(Category $object, ExecutionContextInterface $context): void
    {
        // check if the name is actually a fake name
        if ($object->getName() === 'Category 1') {
            $context->buildViolation('This category is fordidden')
                ->atPath('category')
                ->addViolation();
        }
    }

Ce qui est bien ici, c'est que comme le validateur NotBlank a déjà fait son travail, nous sommes sûrs qu'une catégorie a bien été choisie et que l'utilisateur n'a pas laissé le champ vide. Le callback reçoit donc un objet catégorie, puis nous pouvons faire notre validation. On lève une erreur si on a choisit la première catégorie. Si l'on commente le validateur NotBlank alors, on a bien une erreur de type :

App\Entity\Product::validate(): Argument #1 ($object) must be of type App\Entity\Category, null given, called in /strangebuzz.com/vendor/symfony/validator/Constraints/CallbackValidator.php on line 43

PS : Cette dernière section n'est pas comme je l'imaginais au début, j'ai appris de nouvelles choses en testant. C'est exactement pourquoi j'écris pour rendre les choses claires et avoir une meilleure compréhension globale des fonctionnalités fournies par PHP et Symfony. 🙂

Conclusion

Oui, on peut utiliser les propriétés non nullable avec les formulaires Symfony, il y a quelques pièges et choses à savoir, mais cela fonctionne et les modifications à faire restent assez légères. Bien sûr, on peut éviter tous les problèmes en laissant les propriétés nullable. Certains pensent que c'est OK de faire cela. Je ne pense pas, car ça permet la création d'objets invalides du point de vue du domaine. On a une erreur 500 si l'on essaie d'enregistrer une entité avec le nom vide puisque l'option Doctrine nullable: false est utilisée (violation de contrainte d'intégrité SQL).

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'name' cannot be null

Bien sûr, pour éviter cela, on a besoin d'un constructeur qui aurait le nom en tant que paramètre.
On peut aussi utiliser des DTO spécifiques dans les formulaires au lieu d'utiliser directement les entités.
N'oubliez pas, que si vous laissez vos propriétés nullable, vous aurez à gérer cela dans l'ensemble du code de l'application. L'utilisation de propriétés non nullable rend le code plus robuste et plus lisible. Utilisez-les quand c'est possible ! ✨

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc  Plus sur le web  Plus sur Stackoverflow

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : Laurent, Ben Davies, Dan Champion. 👍

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil