Apprendre Symfony 6 : Formulaires

La création et le traitement des formulaires est une tâche lourde et répétitive qui va être grandement simplifiée par le composant Form de Symfony.

Icône de calendrier
Intermédiaire
13 chapitres

Form Types

2 types de formulaires

Avant toute chose, il est important de souligner que dans une application Symfony, on distingue 2 types de formulaires :

Formulaires simples

Dans certains cas, nous souhaitons créer un formulaire, sans pour autant qu’il soit rattaché à une entité métier. Il peut s’agir :

  • D’un formulaire de contact traditionnel (envoyant un mail et ne stockant pas le message en base de données)
  • D’un formulaire visant à générer un document textuel (devis PDF en ligne, générateur de CGU…)
Formulaires d'entités

La plupart des formulaires affichés sur un site web interagissent avec des bases de données :

  • Inscription d’un utilisateur
  • Formulaire de livraison/facturation sur un site ecommerce
  • Publication d’un post sur un réseau social
  • Publication d’un article de blog
  • Création d’une fiche produit

Qu’il soit simple ou rattaché à une entité, un formulaire peut se construire :

  1. Directement depuis les contrôleurs
  2. Depuis des classes dédiées, nommées « Form Type »

Je ne détaillerai donc la création d’un formulaire qu’avec la seconde option : les Form Types.

Créer un Form Type

Nous pourrions créer un formulaire manuellement mais cela serait dommage de se priver de notre bundle préféré… 😉

copié !
symfony console make:form

Après avoir tapé cette ligne, l’invite de commande vous demande :

  1. Le nom de votre classe. Il doit se terminer par Type. Exemple : ArticleType, ProductType, etc.
  2. Vous avez ensuite la possibilité de rattacher ce formulaire à une entité. Si vous tapez sur ENTRER sans saisir d’entité de référence, alors ce formulaire ne contiendra qu’un champ de démonstration field_name à supprimer pour y spécifier les vôtres.
ContactType.php
copié !
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ContactType extends AbstractType {

	public function buildForm(FormBuilderInterface $builder, array $options): void {
		$builder
			->add('field_name')
		;
	}

	public function configureOptions(OptionsResolver $resolver): void {
		$resolver->setDefaults([
			// Configure your form options here
		]);
	}

}

Les composants AbstractType, FormBuilderInterface et OptionsResolver importés dans cette classe sont utilisés afin de construire nos formulaires.

La méthode qui nous intéresse est buildForm(), c’est à l’intérieur que nous ajouterons des champs à notre formulaire.

Ajouter et supprimer des champs

La méthode add() permet d’ajouter des champs de formulaire. Le premier argument transmis à cette méthode indique le nom que l’on va donner à notre champ. Il a sensiblement le même rôle que l’attribut name d’un formulaire HTML.

Je peux ainsi supprimer le champ de démonstration existant (en supprimant la méthode add() associée) et ajouter les champs souhaités. Par exemple, pour un formulaire de contact :

  • email : email de la personne qui soumet le formulaire
  • message : contenu du message
ContactType.php
copié !
	public function buildForm(FormBuilderInterface $builder, array $options): void {
		$builder
			->add('email')
			->add('message')
		;
	}

Customiser les champs

Les champs ajoutés dans un Form Type sont par défaut des balises <input type="text">. Si vous souhaitez modifier le type d’un champ, vous pourrez le préciser via un second argument à la méthode add().

Symfony fournit, à travers des classes dédiées, une grande liste de types de champs qui peuvent être utilisés dans votre application. Ils devront alors être importés avec des use.

ContactType.php
copié !
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
ContactType.php
copié !
public function buildForm(FormBuilderInterface $builder, array $options): void {
	$builder
		->add('email', EmailType::class)
		->add('message', TextareaType::class)
		->add('save', SubmitType::class)
	;
}

Vous voyez que dans cet exemple nous avons ajouté un champ nommé save qui est en fait le bouton de soumission de notre formulaire.

La méthode add() possède un troisième argument permettant de spécifier un tableau d’options pour notre champ. Il existe de nombreuses options de champs dans Symfony. Il est par exemple possible de modifier le label (par défaut défini selon le nom du champ) ou de définir un message d’aide s’affichant sous le champ.

copié !
	public function buildForm(FormBuilderInterface $builder, array $options): void {
	$builder
		->add('email', EmailType::class, ['label' => 'Adresse email'])
		->add('message', TextareaType::class, ['label' => 'Votre message', 'help' => 'Pensez à renseigner votre numéro de commande.'])
		->add('save', SubmitType::class, ['label' => 'Envoyer'])
	;
}

Si vous rattachez le formulaire à une entité, alors ses champs seront automatiquement mappés aux propriétés de l’entité en question. Mais il est toujours possible de les spécifier manuellement en second argument de la méthode add().

Aussi, avec data_class il est spécifié que le formulaire devra mapper les données saisies au sein d’une entité.

ArticleType.php
copié !
$resolver->setDefaults([
	'data_class' => Article::class,
]);

Voici un exemple de formulaire nommé ArticleType, basé sur une entité Article, définie par un titre, une description, un contenu et une date de publication :

ArticleType.php
copié !
namespace App\Form;

use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

class ArticleType extends AbstractType {

	public function buildForm(FormBuilderInterface $builder, array $options) {
		$builder
			->add('title')
			->add('content')
			->add('description')
			->add('save', SubmitType::class)
		;
	}
	
	public function configureOptions(OptionsResolver $resolver) {
		$resolver->setDefaults([
			'data_class' => Article::class,
		]);
	}

}

Je ne rentrerai pas davantage dans les détails de la configuration des Form Types, mais vous trouverez ici toutes les informations pour les enrichir.

Etape 1 : Construire le formulaire

Création du formulaire

Nos champs étant définis dans une classe à part, il est temps de construire notre formulaire depuis notre contrôleur. Pour cela, on utilisera la méthode createForm().

Pour construire une formulaire basé sur un Form Type, il faut avant toute chose importer le formulaire en question dans notre contrôleur.

BlogController.php
copié !
use App\Form\ArticleType;

Ensuite, nous allons utiliser la méthode createForm(), héritée d’AbstractController pour construire le formulaire. On renseignera dans un premier argument la classe du Form Type à utiliser pour la construction.

BlogController.php
copié !
$form = $this->createForm(ArticleType::class);

Si le formulaire est lié à une entité, il faudra également y renseigner un second argument afin de spécifier un objet à hydrater. Hydrater un objet signifie « remplir ses propriétés avec les données saisies par un utilisateur » (généralement via un formulaire).

Cet objet va se synchroniser avec les champs du formulaire.

Formulaire d'enregistrement

Si on souhaite enregistrer une ressource en BDD, alors on instanciera un objet vide à partir d’une entité.

copié !
$article = new Article(); // Il faut penser à importer la classe avec use
$form = $this->createForm(ArticleType::class, $article);
Formulaire d'édition

Si on souhaite éditer une ressource en BDD, alors on récupèrera un objet existant via le repository. Cela aura pour effet de préremplir les champs.

copié !
$article = $articleRepository->find($id);
$form = $this->createForm(ArticleType::class, $article);

Transmission du formulaire aux templates

Pour transmettre un formulaire à un template, on procédera classiquement en ajoutant un second argument à la méthode render().

Cette méthode convertit notre objet de formulaire en un objet qui aura une représentation visuelle de notre formulaire.

copié !
#[Route('/admin/articles/ajouter', name: 'article_add')]
public function addArticle() {
	$article = new Article();
	$form = $this->createForm(ArticleType::class, $article);
	return $this->render('blog/add_article.html.twig', [
		'form' => $form
	]);
}

Etape 2 : Afficher un formulaire

Fonction form()

Dès lors que le formulaire a été transmis à la vue, on pourra l’afficher avec la fonction Twig form(). Cette fonction prendra pour argument la variable Twig contenant le formulaire.

copié !
{{ form(form) }}

C’est si simple que vous vous demandez peut-être comment nous allons pouvoir spécifier vers quelle page pointe le formulaire une fois soumis (l’attribut HTML action) ainsi que la méthode avec laquelle nous souhaitons envoyer les données (l’attribut HTML method) ?

Notez que par défaut l’URL cible est la même que la page qui affiche le formulaire, et la méthode du formulaire est en POST. Cela nous convient la plupart du temps.

Il est cependant tout à fait possible de les modifier, notamment en spécifiant des options pour le formulaire (attributs action, method…), à travers un troisième argument de la méthode createForm().

Customisation

Le formulaire généré précédemment est :

  1. Assez vilain
  2. Pas personnalisable

Vous vous doutez bien que TWIG a pensé à tout, alors place à la customisation !

Thème CSS

Un thème de formulaire est un gabarit qui contient le code responsable de l’affichage de vos formulaires. C’est lui qui va définir la mise en forme des différents types de champs (textes, checkbox, boutons radio…).

Pour activer un de ces thèmes, il faut se rendre dans le fichier 📄 config/packages/twig.yaml, et ajouter le code suivant :

config/packages/twig.yaml
copié !
twig:
	# ...
	form_themes:
		- 'bootstrap_5_layout.html.twig'

Fonctions Twig

Tout à l’heure nous avions vu qu’il était possible d’afficher notre formulaire avec la seule fonction TWIG form(). En réalité, il en existe de nombreuses autres qui permettent de customiser nos champs.

Ouverture et fermeture

La première chose à faire lorsque vous customisez votre formulaire est d’appeler les fonctions form_start() et form_end() qui vont respectivement ouvrir et fermer le formulaire.

copié !
{{ form_start(form) }}
	{# Customisation du formulaire ... #}
{{ form_end(form) }}
Label

Par défaut, le champ est accompagné d’un label correspondant au nom du champ spécifié lors de la création du formulaire, mais en version humanisée. « phoneNumber » deviendrait « Phone Number ». Vous pouvez l’afficher grâce à la fonction form_label() :

copié !
{{ form_label(form.phoneNumber) }}

En revanche, si vous souhaitez le modifier, vous pouvez également le faire en ajoutant un argument à cette fonction :

copié !
{{ form_label(form.phoneNumber, 'Numéro de téléphone (mobile)') }}
Widget

Pour afficher le champ (input, bouton, liste déroulante, etc.), on fait appel à la fonction form_widget() :

copié !
{{ form_widget(form.phoneNumber) }}

Il est possible d’ajouter des attributs HTML via un second argument à cette fonction :

copié !
{{ form_widget(form.phoneNumber, { 'attr': {'class': 'bigForm'} }) }}

On l’utilise très souvent pour y ajouter des classes, comme dans cet exemple.

Help

Si vous avez défini dans votre Form Type, un message d’aide comme ceci :

copié !
$builder->add('zipCode', null, [
	'help' => 'Votre code postal de naissance et non de résidence.',
]);

Alors, vous pourrez y accéder depuis votre formulaire avec la fonction form_help() :

copié !
{{ form_help(form.phoneNumber) }}
Errors

Il est possible d’afficher les erreurs rencontrées lors de la soumission d’un formulaire avec la fonction form_errors().

Ces erreurs peuvent toutes être affichées à un endroit que vous allez définir de manière globale pour le formulaire, en précisant en argument le formulaire tout entier :

copié !
{{ form_errors(form) }}

Ou bien vous pouvez décider de les rattacher à un champ précis :

copié !
{{ form_errors(form.phoneNumber) }}
Row

Les 4 fonctions Twig form_label, form_widget, form_help et form_errors nous offrent une personnalisation poussée de l’affichage de nos formulaires.

copié !
<i class="fas fa-phone"></i> 
{{ form_label(form.phoneNumber) }}
{{ form_widget(form.phoneNumber) }}
<span class="form-help">
	{{ form_help(form.phoneNumber) }}
</span>
<div class="form-error">
	{{ form_errors(form.phoneNumber) }}
</div>

Ici, cela nous permet par exemple de bénéficier de wrappers pour notre message d’aide et nos messages d’erreur potentiels, ainsi que d’ajouter une icône FontAwesome juste avant le label.

En revanche, si nous ne souhaitons pas avoir une telle personnalisation pour d’autres groupes de champs, alors le fait d’appeler autant de fonctions Twig peut s’avérer lourd.

Heureusement, la fonction form_row() est là pour nous.

Cette fonction est un condensé des fonctions form_label, form_widget, form_help et form_errors.

copié !
{{ form_row(form.phoneNumber) }}

Si vous souhaitez ne changer qu’un label, cela sera aussi possible en ajoutant un argument à cette fonction, sur le même modèle que form_label() :

copié !
{{ form_row(form.phoneNumber, {'label': 'Numéro de téléphone (mobile)'}) }}

Schéma - Fonctions TWIG utiles pour les formulaires

Rest

Attention, si vous n’affichez pas vous-même tous les champs de votre formulaire, la fonction form_end() s’en chargera pour vous. Pour cela elle fera elle-même appel à la fonction form_rest() :

copié !
{{ form_rest(form) }}

Cette fonction est utile pour faire apparaître des champs que vous auriez oublié d’afficher ou encore pour écrire dans la page vos champs cachés. En revanche, si vous ne souhaitez pas les afficher pour une raison valable, vous pouvez alors le préciser de cette manière :

copié !
{{ form_end(form, {'render_rest': false}) }}

Si besoin, vous trouverez ce dont je vous ai parlé dans cette section, de manière plus approfondie, dans la documentation officielle.

Etape 3 : Traitement du formulaire

On sait maintenant créer nos formulaires dans des fichiers spécifiques, les construire dans nos contrôleurs, puis les afficher dans nos templates… mais il nous manque le plus important : le traitement des données soumises.

Faut-il envoyer un mail ? Enregistrer une entité ? Nous allons voir comment le définir.

Soumission du formulaire

Par défaut, un formulaire Symfony envoie les données à la même URL que celle qui l’affiche. La méthode de contrôleur responsable de la construction du formulaire sera ainsi la même que celle en charge de son traitement.

Il va donc falloir différencier dans notre méthode le fait qu’on souhaite afficher notre formulaire ou le traiter.

En PHP natif, on avait tendance à dire :

  • Si la route est appelée en GET, alors je construis et affiche le formulaire.
  • Sinon, si la route est appelée en POST, alors je traite les données.

Côté Symfony, on fera sensiblement la même chose, mais en passant par un intermédiaire : la méthode handleRequest().

La méthode handleRequest() va être appelée dans notre formulaire afin de lire les données de la variable superglobale PHP concernée (c’est-à-dire $_POST ou $_GET, en fonction de la méthode HTTP configurée sur le formulaire).

copié !
$form->handleRequest($request);

Une fois les données de notre formulaire lues, nous allons exécuter la méthode isSubmitted() sur notre formulaire.

copié !
$form->handleRequest($request);
if ($form->isSubmitted()) {
	// Traitement du formulaire...
}

Cette méthode retourne un booléen :

  • false : le formulaire n’a pas été soumis ; on l’affiche.
  • true : le formulaire a été soumis ; on le traite.

Validation des données

Les formulaires permettent aux utilisateurs de saisir de nombreuses informations qui seront la plupart du temps enregistrées en base de données, mais peuvent aussi être exploités « à la volée ». C’est par exemple le cas d’un moteur de recherche, d’un envoi de mail, etc.

Lorsqu’on est face à un formulaire d’ajout d’article, on s’attend généralement à recevoir des chaînes de caractères aux formats suivants :

  • title : non vide / entre 50 et 65 caractères afin d’être SEO-friendly.
  • description : non vide / entre 150 et 165 caractères afin d’être SEO-friendly.

Et pour s’en assurer, il faut mettre en place des contraintes de validation.

Côté client

HTML nous permet via les attributs type, maxlength, pattern et bien d’autres, de spécifier des contraintes du côté front de notre site. Ce premier check de validation est utile car il va pouvoir prévenir l’utilisateur d’un format non-respecté, avant la soumission du formulaire.

Une donnée non valide peut être gênante pour 2 raisons :

  1. Elle ne sera pas, ou peu exploitable en raison de son format.
  2. Elle peut être dangereuse.

Côté serveur

La validation d’un formulaire côté back est une tâche fastidieuse qui demande de faire de nombreux tests sur la nature des données récupérées.

En PHP natif on faisait appel à de nombreuses fonctions au sein de conditions interminables :

copié !
if (!empty($title)
	&& strlen($title) >= 50
	&& strlen($title) <= 65
	&& !empty($content) ... ) {
	// On traite les données
}

Heureusement, tout ça c’est de l’histoire ancienne, car Symfony intègre un composant nommé Validator venant simplifier ce processus.

La validation consiste à associer des contraintes de validation à des propriétés (le plus souvent) ou des méthodes publiques de nos entités, via des attributs PHP.

D’abord, importons le composant Validator au sein de notre entité :

copié !
use Symfony\Component\Validator\Constraints as Assert;

Ensuite, définissons des contraintes de validation sur les propriétés des entités avec l’attribut PHP Assert :

copié !
#[Assert\Length(
	min: 50,
	max: 65,
	minMessage: 'Ce titre est trop court. Rallongez-le un peu pour votre SEO.',
	maxMessage: 'Ce titre est trop long. Raccourcissez-le un peu pour votre SEO.'
)]
#[Assert\NotBlank(message: 'Le titre ne peut pas être vide.')]
private string $title;

Ici nous n’avons utilisé que Length et NotBlank, mais il existe en réalité bien d’autres contraintes de validation.

Vous trouverez dans la documentation officielle, davantage d’informations pour pousser plus loin ce composant très riche.

Enin, il ne nous reste plus qu’à indiquer qu’à la soumission du formulaire, on souhaite également vérifier la validité des champs avec la méthode isValid() :

copié !
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
	// Traitement du formulaire...
}

Traitement des données

Et bien vous savez déjà faire tout ça me semble-t-il, non ? 🤔

Nous allons pouvoir exploiter les compétences acquises au chapitre précédent (entityManager et repositories) pour enregistrer, modifier, supprimer ou encore récupérer des données en fonction des champs saisis dans un formulaire.

Voici un exemple nous permettant d’ajouter un nouvel article en base de données :

ArticleController.php
copié !
	
#[Route('/admin/articles/ajouter', name: 'article_add')]
public function addArticle(Request $request, EntityManagerInterface $em): Response {
	$article = new Article();
	$form = $this->createForm(ArticleType::class, $article);
	
	$form->handleRequest($request);
	if ($form->isSubmitted() && $form->isValid()) {
		$em->persist($article);
		$em->flush();
		return $this->redirectToRoute('list_articles');
	}
	
	return $this->render('article/add.html.twig', [
		'form' => $form
	]);
}

Etant donné qu’ArticleType est basé sur l’entité Article, nous avons défini que le formulaire hydratera automatiquement l’objet $article.

En revanche, si mon Form Type n’est pas basé sur une entité, alors je pourrai récupérer les données du formulaire grâce à la méthode getData().

copié !
$form = $this->createForm(ContactType::class);

$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
	$data = $form->getData();
	// Exploitation de $data...
}
Gestion des relations

Si un champ de formulaire est de type « relation », il est important d’ajouter la méthode public __toString() suivante dans l’entité possédant le champ en question.

copié !
public function __toString() { return $this->name; }

Cette méthode permet d’indiquer ici que si un objet « category » tout entier doit être amené à être affiché, une propriété spécifique permettra de l’identifier (ici, la propriété $name).