Les formulaires

Les formulaires

1 mai 2021

Abordons maintenant cette partie très intéressante qu'est celle des formulaires ! Symfony nous propose un système très complet pour gérer les données soumises par formulaire !

Symfony nous propose une commande afin de générer un formulaire automatiquement. Ce formulaire ne sera pas généré en HTML, mais en une classe PHP. À partir de cette classe, nous pourrons apporter toutes les modifications nécessaires (champs obligatoires, classes CSS, validations des données...).

Nous avons deux possibilités au moment de la génération d'un formulaire : soit le baser sur une entité, afin d'enregistrer ou de mettre à jour des données dans la table concernée, soit le générer sans lien à aucune entité pour créer notre formulaire librement.

Dans un premier temps, voyons comment faire un formulaire lié à une entité.
Dans mon cas, je possède une entité nommée "Agences.php" qui ressemble à ça :

<?php

namespace App\Entity;

use App\Repository\AgencesRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=AgencesRepository::class)
 */
class Agences
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=80)
     */
    private $name;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $address;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created_at;

    /**
     * @ORM\ManyToOne(targetEntity=Villes::class, inversedBy="agences")
     * @ORM\JoinColumn(nullable=false)
     */
    private $ville;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function getAddress(): ?string
    {
        return $this->address;
    }

    public function setAddress(string $address): self
    {
        $this->address = $address;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->created_at;
    }

    public function setCreatedAt(\DateTimeInterface $created_at): self
    {
        $this->created_at = $created_at;

        return $this;
    }

    public function getVille(): ?Villes
    {
        return $this->ville;
    }

    public function setVille(?Villes $ville): self
    {
        $this->ville = $ville;

        return $this;
    }
}

Créer un formulaire Symfony

Générons un formulaire à partir cette entité, en utilisant le terminal de commande :

symfony console make:form AgenceType

# Vous pouvez aussi utiliser une autre commande
# php bin/console make:form AgenceType

Je donne le nom de "AgenceType" en UpperCamelCase à mon formulaire. Sachez que le nom est libre et par convention, se termine par "Type". J'ai choisi le nom "AgenceType" de façon à comprendre rapidement que ce formulaire est relié à l'entité "Agences.php".

Ensuite, une question vous sera posée, vous demandant si vous désirez utiliser une entité ou non. Voulant utiliser "Agences.php", je réponds "Agences". Il n'est pas besoin de spécifier l'extension, "Agences" étant le nom de la classe PHP contenue dans mon entité.

En retour, on me confirme le succès de la création du formulaire et on me donne l'endroit où se situe le fichier nouvellement créé : dans "src/Form/", sous le nom "AgenceType.php". En regardant le contenu de ce fichier, nous pouvons voir qu'il contient différents add() intégrant les noms des propriétés de l'entité choisie.

<?php

namespace App\Form;

use App\Entity\Agences;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AgenceType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('address')
            ->add('created_at')
            ->add('ville')
        ;
    }

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

Chaque add() représente un champ du formulaire et est de type texte pour le moment. Bien évidemment, nous pouvons modifier les types selon nos besoins.

Avant d'aller plus loin, je prends la décision de supprimer le "created_at", car je ne tiens pas à ce que l'utilisateur choisisse la date de création de son contenu ; je me chargerais d'ajouter cette date avant l'insertion en base de données.

<?php

// ...

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('name')
        ->add('address')
        ->add('ville')
    ;
}

// ...

Voilà mon formulaire modifié. Facile non ? ?
Nous pourrions l'utiliser tel quel, il est fonctionnel, mais ajoutons-lui des paramètres et des contraintes de validation pour vérifier les données renvoyées. N'oubliez pas que toute information venant d'un utilisateur doit être vérifiée !

Types de champs

La première chose que j'ai modifiée, ce sont les champs "name" et "address". Je reviendrai sur le champ "ville" un peu plus tard, car c'est un cas particulier qui mérite sa propre partie.

J'ai donné à mes deux champs leur type : "name" restera un champ de type text, mais "address" sera de type textarea. Je vais en profiter pour ajouter un nouveau add() qui correspondra au bouton d'envoi du formulaire.

<?php

// ...

use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

// ...

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('name', TextType::class)
        ->add('address', TextareaType::class)
        ->add('ville')
        ->add('save', SubmitType::class)
    ;
}

// ...

En second paramètre des add(), j'ai donc ajouté deux classes qui sont TextType et TextareaType. Leur nom suffit à comprendre de quoi il retourne. Pour "name", j'ai précisé que je veux le type texte, bien que comme je vous l'ai dit il s'agit du type par défaut ! J'ai fait cela tout simplement parce que pour accéder au troisième argument, je suis obligé de spécifier le second (fonction PHP basique).

Pensez bien à ajouter les namespaces nécessaires en haut du document.

use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

Sachez que l'on peut utiliser tous les types proposés en HTML5 ainsi que d'autres gérés par Symfony.
Vous pouvez retrouver la liste complète ici : https://symfony.com/doc/current/reference/forms/types.html

Options

Le troisième argument de nos add() est un tableau associatif. Il existe plusieurs options disponibles, mais nous allons nous concentrer sur les plus utilisées.

// ...

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('name', TextType::class, [
           'required' => true,
           'label' => "Titre de l'agence"
        ])
        ->add('address', TextareaType::class, [
           'required' => true,
           'label' => "Adresse complète de l'agence"
        ])
        ->add('ville')
        ->add('save', SubmitType::class, [
           'label' => 'Valider'
        ])
    ;
}

// ...

La première option que je vous propose de voir est required. Je demande simplement que mes champs soient obligatoires avant la soumission du formulaire.

Par défaut, ils sont obligatoires si, au moment de la création de votre entité, vous avez spécifié que vous vouliez que la colonne soit non null. La vérification s'effectue dans ce cas coté navigateur. Ce n'est pas très sécurisé, car il est toujours possible de modifier le code HTML depuis l'inspecteur d'éléments.

Ajouter ce required n'est donc pas obligatoire, mais, personnellement, je le précise pour une lecture plus rapide de mon code par la suite. Je peux ainsi voir facilement les champs obligatoires et ceux qui ne le sont pas. À vous de voir ce que vous préférez.

La deuxième option que je souhaite aborder est label. Si vous ne le spécifiez pas, Symfony récupérera le nom de la propriété. Dans mon cas, avec "address" par exemple, Symfony générera un label (une légende pour le champ du formulaire) intitulé "Address".

Je préfère faire un peu de tuning, donc je renomme le label. Je fais de même pour mon bouton de type submit et lui donne un nouveau nom.

Contraintes de validations

Les contraintes de validations sont des classes qui nous permettent de vérifier les informations passées, mais du côté serveur. Il faut toujours effectuer une vérification côté serveur !

// ...

use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

// ...

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('name', TextType::class, [
           'required' => true,
           'label' => "Titre de l'agence",
           'constraints' => [
              new NotBlank([
                 'message' => 'Veuillez saisir un nom'
              ]),
              new Length([
                 'min' => 6,
                 'minMessage' => 'Le nom doit contenir au minimum {{ limit }} caractères'
              ]),
           ]
        ])
        ->add('address', TextareaType::class, [
           'required' => true,
           'label' => "Adresse complète de l'agence",
           'constraints' => [
              new NotBlank([
                 'message' => 'Veuillez saisir une adresse'
              ])
           ]

        ])
        ->add('ville')
        ->add('save', SubmitType::class, [
           'label' => 'Valider'
        ])
    ;
}

// ...

Ma nouvelle option constraints a pour valeur un tableau contenant les différentes classes nécessaires à la validation des données.

Ainsi, pour "name", je vérifie si l'utilisateur a bel et bien envoyé des données et si celles-ci font un minimum de six caractères. En cas d'erreur, le ou les messages correspondants s'afficheront automatiquement sur le formulaire.

Évidemment, je n'ajoute pas de contrainte de validation à mon add('save'), car il s'agit juste d'un bouton d'envoi. ?

Tout comme pour les différents types de champs, il existe plusieurs contraintes de validation, que ce soit pour une adresse e-mail, une date...
Vous trouverez la totalité des contraintes existantes sur cette page : https://symfony.com/doc/current/reference/constraints.html

Formulaire et relations de table

Mon formulaire est presque prêt ! Il faut cependant encore configurer la propriété "ville". Dans mon entité, "ville" est une relation. Tenter de l'afficher tel quel, générera une erreur. Comme il s'agit d'une relation, on ne peut pas simplement afficher un champ texte et demander à l'utilisateur de taper le nom d'une ville. Ce que l'on va faire, c'est récupérer le nom des villes disponibles dans la table "villes" depuis son entité et afficher le tout dans un menu déroulant. Magique !... Ou presque...

// ...

use App\Entity\Villes;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;

//...

->add('ville', EntityType::class, [
  'required' => true,
  'label' => 'Choisir une ville',
  'class' => Villes::class,
  'choice_label' => 'name',
  'constraints' => [
    new NotBlank([
      'message' => 'Veuillez choisir une ville'
    ])
  ]
])

//...

Je vais utiliser la classe EntityType. Je dois fournir deux options avec cette classe :

Premièrement, class, où je précise quelle entité je souhaite utiliser. Ma propriété "ville" étant une relation sur l'entité "Villes", je lui donne cette dernière.

Deuxièmement, l'option choices_label, qui permet de savoir quelles informations afficher dans le menu déroulant. Il faut ici choisir le nom de la propriété qui vous semble la plus appropriée. Pour ma part, dans mon entité "Villes.php", c'est la propriété "name" qui contient le nom des différentes villes, je vais donc la sélectionner.

Comme pour les autres champs, j'ai ajouté l'obligation de choisir une ville avec required, j'ai entré un nouveau label et une contrainte de validation, pour éviter qu'un petit malin me retourne une valeur vide.

Avec ce code, vous obtiendrez un menu déroulant contenant les noms de toutes les villes présentes en table "villes" et vous aurez en valeur leur ID. Au moment de l'insertion, Symfony sera capable d'insérer le bon ID dans la colonne "ville_id". ?

Voilà mon code au complet :

<?php

namespace App\Form;

use App\Entity\Agences;
use App\Entity\Villes;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class AgenceType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
              'required' => true,
              'label' => "Titre de l'agence",
              'constraints' => [
                new NotBlank([
                  'message' => 'Veuillez saisir un nom'
                ]),
                new Length([
                  'min' => 6,
                  'minMessage' => 'Le nom doit contenir au minimum {{ limit }} caractères'
                ]),
              ]
            ])
            ->add('address', TextareaType::class, [
              'required' => true,
              'label' => "Adresse complète de l'agence",
              'constraints' => [
                new NotBlank([
                  'message' => 'Veuillez saisir une adresse'
                ])
              ]
            ])
            ->add('ville', EntityType::class, [
              'required' => true,
              'label' => 'Choisir une ville',
              'class' => Villes::class,
              'choice_label' => 'name',
              'constraints' => [
                new NotBlank([
                  'message' => 'Veuillez choisir une ville'
                ])
              ]
            ])
            ->add('save', SubmitType::class)
        ;
    }

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

Afficher le formulaire

Affichons maintenant notre formulaire dans une vue Twig.
Pour cela, choisissez un contrôleur et créez une nouvelle méthode, sa route et la vue associée.

Ma méthode :

/**
 * @Route("/new", name="new_agency")
 */
public function newAgency()
{
   return $this->render('home/newAgency.html.twig');
}

Ma vue :

{% extends 'base.html.twig' %}

{% block title %}Nouvelle agence!{% endblock %}

{% block body %}
    <h1>Nouvelle agence</h1>
{% endblock %}

Pour le moment, il n'y a rien d'extraordinaire. On va changer tout ça. Revenons sur notre contrôleur, c'est par là que la magie opère. ?

//...

use App\Entity\Agences;
use App\Form\AgenceType;
use Symfony\Component\HttpFoundation\Request;

// ...

/**
 * @Route("/new", name="new_agency")
 */
public function newAgency(Request $request)
{
   $agence = new Agences();
   $form = $this->createForm(AgenceType::class, $agence);
   $form->handleRequest($request);

   return $this->render('home/newAgency.html.twig', [
     'form' => $form->createView()
   ]);
}

Détaillons un peu le code pour mieux comprendre.

$agence = new Agences();

Dans un premier temps, j'ai instancié mon entité "Agences.php". C'est la même entité qui a servi à générer mon formulaire. Je me retrouve avec un objet "$agence", pour le moment vide d'informations.

$form = $this->createForm(AgenceType::class, $agence);

On demande sur cette ligne la création de notre formulaire, en passant en premier paramètre sa structure (soit le fichier que nous avons travaillé en peu plus haut), et en second paramètre, notre objet créé précédemment.

$form->handleRequest($request);

`handleRequest() va permettre d'hydrater l'objet "$agence" avec les données du formulaire au moment de sa soumission, et ce, en les récupérant dans l'objet "$request". Il est important de ne pas oublier d'injecter la dépendance.

return $this->render('home/newAgency.html.twig', [
  'formNewAgency' => $form->createView()
]);

Enfin, je passe une nouvelle variable à ma vue, "$form", ayant pour valeur une méthode qui crée une instance de FormView, contenant toutes les informations nécessaires à sa génération.

Il reste maintenant à afficher le formulaire dans la vue. On peut le faire tout simplement avec le code suivant :

{% extends 'base.html.twig' %}

{% block title %}Nouvelle agence!{% endblock %}

{% block body %}
    <h1>Nouvelle agence</h1>
    {{ form(formNewAgency) }}
{% endblock %}

On va utiliser une fonction Twig appelée form() et lui passer en paramètre la variable "formNewAgency", envoyée depuis notre contrôleur, afin d'afficher visuellement le formulaire et de générer son code HTML.

Pour le moment le formulaire s'affiche, s'envoie, mais absolument rien n'est inséré en base de données, ni vérifié ! Corrigeons ça de suite.

Revenons à notre contrôleur et appliquons un if :

/**
 * @Route("/new", name="new_agency")
 */
public function newAgency(Request $request)
{
   $agence = new Agences();
   $form = $this->createForm(AgenceType::class, $agence);
   $form->handleRequest($request);

   if ($form->isSubmitted() && $form->isValid()) {
      // ...
   }

   return $this->render('home/newAgency.html.twig', [
      'formNewAgency' => $form->createView()
   ]);
}

La condition ajoutée permet de vérifier si le formulaire a été soumis et si les données retournées sont correctes. C'est à ce moment-là que vos contraintes de validation vont se mettre en action. En cas d'erreur, la condition ne sera pas acceptée et le formulaire affichera le ou les messages des erreurs concernées.

if ($form->isSubmitted() && $form->isValid()) {
   $em = $this->getDoctrine()->getManager();
   $em->persist($agence);
   $em->flush();
}

Dans la condition, nous allons instancier le manager de Doctrine, persister les données et enfin les envoyer en bases de données. Rien de plus simple !

Attention ! Mon code génèrera une erreur à ce moment ! Souvenez-vous que j'ai volontairement retiré la date (le created_at) du formulaire et que cette colonne est non nullable. Je dois donc absolument passer une date avant l'insertion.

Pour cela, je vais récupérer mon objet "$agence", qui est maintenant hydraté des données issues de mon formulaire. Grâce au setter, je vais ajouter une date aux autres données avant d'envoyer le tout en base de données.

if ($form->isSubmitted() && $form->isValid()) {
   $em = $this->getDoctrine()->getManager();

   // Insertion de la date du jour dans le setter adéquat
   $agence->setCreatedAt(new DateTime());

   $em->persist($agence);
   $em->flush();
}

Voilà ma méthode au complet :

/**
 * @Route("/new", name="new_agency")
 */
public function newAgency(Request $request)
{
   $agence = new Agences();
   $form = $this->createForm(AgenceType::class, $agence);
   $form->handleRequest($request);

   if ($form->isSubmitted() && $form->isValid()) {
      $em = $this->getDoctrine()->getManager();

      $agence->setCreatedAt(new DateTime());

      $em->persist($agence);
      $em->flush();
   }

   return $this->render('home/newAgency.html.twig', [
      'formNewAgency' => $form->createView()
   ]);
}

Message flash

Tout fonctionne, c'est super, mais pour l'instant, au moment de la validation, nous revenons sur le formulaire rempli de nos informations sans savoir si tout s'est bien passé. ?

Nous allons donc enregistrer un message de succès, rediriger l'utilisateur sur une autre vue et afficher ce fameux message. Pour cela, nous allons utiliser le addFlash().

/**
 * @Route("/new", name="new_agency")
 */
public function newAgency(Request $request)
{
   $agence = new Agences();
   $form = $this->createForm(AgenceType::class, $agence);
   $form->handleRequest($request);

   if ($form->isSubmitted() && $form->isValid()) {
      $em = $this->getDoctrine()->getManager();

      $agence->setCreatedAt(new DateTime());

      $em->persist($agence);
      $em->flush();

      $this->addFlash('success', 'Agence correctement enregistrée !');
      return $this->redirectToRoute('home');
   }

   return $this->render('home/newAgency.html.twig', [
      'formNewAgency' => $form->createView()
   ]);
}

Nous mettons deux lignes supplémentaires dans notre contrôleur. La première ligne est celle-ci :

$this->addFlash('success', 'Agence correctement enregistrée !');

addFlash() prend deux paramètres. Le premier est un nom que l'on va donner à notre message. J'ai choisi "success". Sachez que ce nom est libre.

En deuxième paramètre, je note simplement le message que je souhaite afficher à l'utilisateur.

addFlash() est un raccourci pour enregistrer votre message dans une session, mais avec une particularité. Dès que votre message est lu, il est automatiquement détruit de la session.

return $this->redirectToRoute('home');

redirectToRoute() permet de rediriger l'utilisateur vers une autre page de votre site. En paramètre de cette méthode, vous devez simplement passer le nom d'une route existante.

Attention ! Ne passez pas l'URL de la route, mais bien le nom que vous avez choisi dans l'annotation @Route() !

Il reste encore à afficher le message dans votre vue :

{% for message in app.flashes('success') %}
  <p>{{ message }}</p>
{% endfor %}

Pour cela, il suffit de passer app.flashes() dans une boucle for, en lui donnant en paramètre le nom que vous avez choisi lors de la création du addFlash().

Bonus ! ?

Si vous utilisez Bootstrap 4, sachez qu'il est possible de mettre en forme le formulaire automatiquement avec cette librairie CSS.

Modifiez le fichier de configuration suivant : "config/packages/twig.yaml" et ajoutez la ligne suivante à la fin :

twig:
    # ...
    form_themes: ['bootstrap_4_layout.html.twig']

Respectez l'indentation du fichier de configuration ! Dans le cas contraire, une erreur vous sera retournée.

D'autres thèmes sont disponibles. Retrouvez la configuration ici : https://symfony.com/doc/current/form/form_themes.html#symfony-built-in-form-themes