Licence CC BY-NC-SA

Créer des formulaires avec Symfony2

Quoi de plus important sur un site web que les formulaires ? En effet, les formulaires sont l'interface entre vos visiteurs et votre contenu. Chaque commentaire, chaque article de blog, etc., tous passent par l'intermédiaire d'un visiteur et d'un formulaire pour exister dans votre base de données.

L'objectif de ce chapitre est donc de vous donner enfin les outils pour créer efficacement ces formulaires grâce à la puissance du composant Form de Symfony2. Ce chapitre va de paire avec le prochain, dans lequel nous parlerons de la validation des données, celles que vos visiteurs vont entrer dans vos nouveaux formulaires.

Gestion des formulaires

L'enjeu des formulaires

Vous avez déjà créé des formulaires en HTML et PHP, vous savez donc que c'est une vraie galère ! À moins d'avoir créé vous-mêmes un système dédié, gérer correctement des formulaires s'avère être un peu mission impossible. Par « correctement », j'entends de façon maintenable, mais surtout réutilisable. Heureusement, le composant Form de Symfony2 arrive à la rescousse !

N'oubliez pas que les composants peuvent être utilisés hors d'un projet Symfony2. Vous pouvez donc reprendre le composant Form dans votre site même si vous n'utilisez pas Symfony2.

Un formulaire Symfony2, qu'est-ce que c'est ?

La vision Symfony2 sur les formulaires est la suivante : un formulaire se construit sur un objet existant, et son objectif est d'hydrater cet objet.

Un objet existant

Il nous faut donc des objets de base avant de créer des formulaires. Mais en fait, ça tombe bien : on les a déjà, ces objets ! En effet, un formulaire pour ajouter un article de blog va se baser sur l'objet Article, objet que nous avons construit lors du chapitre précédent. Tout est cohérent.

Je dis bien « objet » et non « entité Doctrine2 ». En effet, les formulaires n'ont pas du tout besoin d'une entité pour se construire, mais uniquement d'un simple objet. Heureusement, nos entités sont de simples objets avant d'être des entités, donc elles conviennent parfaitement. Je précise ce point pour vous montrer que les composants Symfony2 sont indépendants les uns des autres.

Pour la suite de ce chapitre, nous allons utiliser notre objet Article. C'est un exemple simple qui va nous permettre de construire notre premier formulaire. Je rappelle son code, sans les annotations pour plus de clarté (et parce qu'elles ne nous regardent pas ici) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

namespace Sdz\BlogBundle\Entity;

use Sdz\BlogBundle\Entity\Tag;

class Article
{
  private $id;
  private $date;
  private $titre;
  private $contenu;
  private $auteur;
  private $publication;
  private $image;
  private $categories;
  private $commentaires;

  public function __construct()
  {
    $this->date         = new \Datetime;
    $this->publication  = true;
    $this->categories   = new \Doctrine\Common\Collections\ArrayCollection();
    $this->commentaires = new \Doctrine\Common\Collections\ArrayCollection();
  }

  // … Les getters et setters
}

Rappel : la convention pour le nom des getters/setters est importante : lorsque l'on parlera du champ « auteur », le composant Form utilisera l'objet via les méthodes setAuteur() et getAuteur() (comme le faisait Doctrine2 de son côté). Donc si vous aviez eu set_auteur() ou recuperer_auteur(), cela n'aurait pas fonctionné.

Objectif : hydrater cet objet

Hydrater ? Un terme précis pour dire que le formulaire va remplir les attributs de l'objet avec les valeurs entrées par le visiteur. Faire <?php $article->setAuteur('winzou') ?>, <?php $article->setDate(new \Datetime()), etc., c'est hydrater l'objet Article.

Le formulaire en lui-même n'a donc comme seul objectif que d'hydrater un objet. Ce n'est qu'une fois l'objet hydraté que vous pourrez en faire ce que vous voudrez : enregistrer en base de données dans le cas de notre objet Article, envoyer un e-mail dans le cas d'un objet Contact, etc. Le système de formulaire ne s'occupe pas de ce que vous faites de votre objet, il ne fait que l'hydrater.

Une fois que vous avez compris cela, vous avez compris l'essentiel. Le reste n'est que de la syntaxe à connaître.

Gestion basique d'un formulaire

Concrètement, pour créer un formulaire, il nous faut deux choses :

  • Un objet (on a toujours notre objet Article) ;
  • Un moyen pour construire un formulaire à partir de cet objet, un FormBuilder, « constructeur de formulaire » en français.

Pour faire des tests, placez-vous dans l'action ajouterAction() de notre contrôleur Blog et modifiez-la comme suit :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

use Sdz\BlogBundle\Entity\Article;

// …

public function ajouterAction()
{
  // On crée un objet Article
  $article = new Article();

  // On crée le FormBuilder grâce à la méthode du contrôleur
  $formBuilder = $this->createFormBuilder($article);

  // On ajoute les champs de l'entité que l'on veut à notre formulaire
  $formBuilder
    ->add('date',        'date')
    ->add('titre',       'text')
    ->add('contenu',     'textarea')
    ->add('auteur',      'text')
    ->add('publication', 'checkbox');
  // Pour l'instant, pas de commentaires, catégories, etc., on les gérera plus tard

  // À partir du formBuilder, on génère le formulaire
  $form = $formBuilder->getForm();

  // On passe la méthode createView() du formulaire à la vue afin qu'elle puisse afficher le formulaire toute seule
  return $this->render('SdzBlogBundle:Blog:ajouter.html.twig', array(
    'form' => $form->createView(),
  ));
}

Pour le moment, ce formulaire n'est pas opérationnel. On va pouvoir l'afficher, mais il ne se passera rien lorsqu'on le validera.

Mais avant cette étape, essayons de comprendre le code présenté. Dans un premier temps, on récupère le FormBuilder. Cet objet n'est pas le formulaire en lui-même, c'est un constructeur de formulaire. On lui dit : « Crée un formulaire autour de l'objet $article », puis : « Ajoute les champs date, titre, contenu et auteur. » Et enfin : « Maintenant, donne-moi le formulaire construit avec tout ce que je t'ai dit auparavant. »

Prenons le temps de bien faire la différence entre les attributs de l'objet hydraté et les champs du formulaire. Un formulaire n'est pas du tout obligé d'hydrater tous les attributs d'un objet. On pourrait très bien ne pas utiliser le pseudo pour l'instant par exemple, et ne pas mettre de champ auteur dans notre formulaire. L'objet, lui, contient toujours l'attribut auteur, mais il ne sera juste pas hydraté par le formulaire. Bon, en l'occurrence, ce n'est pas le comportement que l'on veut (on va considérer le pseudo comme obligatoire pour un article), mais sachez que c'est possible. ;) D'ailleurs, si vous avez l’œil, vous avez remarqué qu'on n'ajoute pas de champ id : comme il sera rempli automatiquement par Doctrine (grâce à l'auto-incrémentation), le formulaire n'a pas besoin de remplir cet attribut.

Enfin, c'est avec cet objet $form généré que l'on pourra gérer notre formulaire : vérifier qu'il est valide, l'afficher, etc. Par exemple, ici, on utilise sa méthode <?php $form->createView() qui permet à la vue d'afficher ce formulaire. Concernant l'affichage du formulaire, j'ai une bonne nouvelle pour vous : Symfony2 nous permet d'afficher un formulaire simple en une seule ligne HTML ! Si, si : rendez-vous dans la vue Blog/formulaire.html.twig et ajoutez ces quelques lignes là où nous avions laissé un trou :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{# src/Sdz/BlogBundle/Resources/views/Blog/formulaire.html.twig #}

<h3>Formulaire d'article</h3>

<div class="well">
  <form method="post" {{ form_enctype(form) }}>
    {{ form_widget(form) }}
    <input type="submit" class="btn btn-primary" />
  </form>
</div>

Ensuite, admirez le résultat à l'adresse suivante : http://localhost/Symfony/web/app_dev.php/blog/ajouter. Impressionnant, non ?

Aperçu du formulaire simple

Grâce à la fonction Twig {{ form_widget() }}, on peut afficher un formulaire entier en une seule ligne. Alors bien sûr, il n'est pas forcément à votre goût pour le moment, mais voyez le bon côté des choses : pour l'instant, on est en plein développement, on veut tester notre formulaire. On s'occupera de l'esthétique plus tard. N'oubliez pas également de rajouter les balises <form> HTML et le bouton de soumission, car la fonction n'affiche que l'intérieur du formulaire.

Bon, évidemment, comme je vous l'ai dit, ce code ne fait qu'afficher le formulaire, il n'est pas encore question de gérer sa soumission. Mais patience, on y arrive.

La date sélectionnée par défaut est celle d'aujourd'hui, et la checkbox « Publication » est déjà cochée : comment est-ce possible ?

Bonne question ! Il est important de savoir que ces deux points ne sont pas là par magie, et que dans Symfony2 tout est cohérent. Si vous vous rappelez, on avait défini des valeurs dans le constructeur de l'entité Article :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
// src/Sdz/BlogBundle/Entity/Article.php

// ...

  public function __construct()
  {
    $this->date         = new \Datetime;
    $this->publication  = true;
    // ...
  }

C'est à ce moment qu'est définie la valeur de ces deux attributs, et c'est sur la valeur de ces attributs que se base le formulaire pour remplir ses champs. Voilà l'origine de ces valeurs !

Ajouter des champs

Vous pouvez le voir, ajouter des champs à un formulaire se fait assez facilement avec la méthode <?php $formBuilder->add() du FormBuilder. Les arguments sont les suivants :

  1. Le nom du champ ;
  2. Le type du champ ;
  3. Les options du champ, sous forme de tableau.

Par « type de champ », il ne faut pas comprendre « type HTML » comme text, password ou select. Il faut comprendre « type sémantique ». Par exemple, le type date que l'on a utilisé affiche trois champs select à la suite pour choisir le jour, le mois et l'année. Il existe aussi un type timezone pour choisir le fuseau horaire. Bref, il en existe pas mal et ils n'ont rien à voir avec les types HTML, ils vont bien plus loin que ces derniers ! N'oubliez pas, Symfony2 est magique ! :magicien:

Voici l'ensemble des types de champ disponibles. Je vous dresse ici la liste avec pour chacun un lien vers la documentation : allez-y à chaque fois que vous avez besoin d'utiliser tel ou tel type.

Ayez bien cette liste en tête : le choix d'un type de champ adapté à l'attribut de l'objet sous-jacent est une étape importante dans la création d'un formulaire.

Il est primordial de bien faire correspondre les types de champ du formulaire avec les types d'attributs que contient votre objet. En effet, si le formulaire retourne un booléen alors que votre objet attend du texte, ils ne vont pas s'entendre. La figure suivante montre donc comment on a choisi les types de champ de formulaire selon l'objet Article.

Correspondances entre le formulaire et l'objet

Gestion de la soumission d'un formulaire

Afficher un formulaire c'est bien, mais faire quelque chose lorsqu'un visiteur le soumet, c'est quand même mieux !

  • Pour gérer l'envoi du formulaire, il faut tout d'abord vérifier que la requête est de type POST : cela signifie que le visiteur est arrivé sur la page en cliquant sur le bouton submit du formulaire. Lorsque c'est le cas, on peut traiter notre formulaire.
  • Ensuite, il faut faire le lien entre les variables de type POST et notre formulaire, pour que les variables de type POST viennent remplir les champs correspondants du formulaire. Cela se fait via la méthode bind() du formulaire. Cette méthode dit au formulaire : « Voici la requête d'entrée (nos variables de type POST entre autres). Lis cette requête, récupère les valeurs qui t'intéressent et hydrate l'objet. » Comme vous pouvez le voir, elle fait beaucoup de choses !
  • Enfin, une fois que notre formulaire a lu ses valeurs et hydraté l'objet, il faut tester ces valeurs pour vérifier qu'elles sont valides avec ce que l'objet attend. Il faut valider notre objet. Cela se fait via la méthode isValid() du formulaire.

Ce n'est qu'après ces trois étapes que l'on peut traiter notre objet hydraté : sauvegarder en base de données, envoyer un e-mail, etc.

Vous êtes un peu perdus ? C'est parce que vous manquez de code. Voici comment faire tout ce que l'on vient de dire, dans le contrôleur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

use Sdz\BlogBundle\Entity\Article;

// …

  public function ajouterAction()
  {
    $article = new Article;

    // J'ai raccourci cette partie, car c'est plus rapide à écrire !
    $form = $this->createFormBuilder($article)
                 ->add('date',        'date')
                 ->add('titre',       'text')
                 ->add('contenu',     'textarea')
                 ->add('auteur',      'text')
                 ->add('publication', 'checkbox')
                 ->getForm();

    // On récupère la requête
    $request = $this->get('request');

    // On vérifie qu'elle est de type POST
    if ($request->getMethod() == 'POST') {
      // On fait le lien Requête <-> Formulaire
      // À partir de maintenant, la variable $article contient les valeurs entrées dans le formulaire par le visiteur
      $form->bind($request);

      // On vérifie que les valeurs entrées sont correctes
      // (Nous verrons la validation des objets en détail dans le prochain chapitre)
      if ($form->isValid()) {
        // On l'enregistre notre objet $article dans la base de données
        $em = $this->getDoctrine()->getManager();
        $em->persist($article);
        $em->flush();

        // On redirige vers la page de visualisation de l'article nouvellement créé
        return $this->redirect($this->generateUrl('sdzblog_voir', array('id' => $article->getId())));
      }
    }

    // À ce stade :
    // - Soit la requête est de type GET, donc le visiteur vient d'arriver sur la page et veut voir le formulaire
    // - Soit la requête est de type POST, mais le formulaire n'est pas valide, donc on l'affiche de nouveau

    return $this->render('SdzBlogBundle:Blog:ajouter.html.twig', array(
      'form' => $form->createView(),
    ));
  }

Si le code paraît long, c'est parce que j'ai mis plein de commentaires ! Prenez le temps de bien le lire et de bien le comprendre : vous verrez, c'est vraiment simple. N'hésitez pas à le tester. Essayez de ne pas remplir un champ pour observer la réaction de Symfony2. Vous voyez que ce formulaire gère déjà très bien les erreurs, il n'enregistre l'article que lorsque tout va bien.

N'hésitez pas à tester votre formulaire en ajoutant des articles ! Il est opérationnel, et les articles que vous ajoutez sont réellement enregistrés en base de données.

Si vous l'avez bien testé, vous vous êtes rendu compte qu'on est obligés de cocher le champ publication. Ce n'est pas tellement le comportement voulu, car on veut pouvoir enregistrer un article sans forcément le publier (pour finir la rédaction plus tard par exemple). Pour cela, nous allons utiliser le troisième argument de la méthode $formBuilder->add() qui correspond aux options du champ. Les options se présentent sous la forme d'un simple tableau. Pour rendre le champ facultatif, il faut définir l'option required à false, comme suit :

1
2
<?php
$formBuilder->add('publication', 'checkbox', array('required' => false));

Rappelez-vous donc : un champ de formulaire est requis par défaut. Si vous voulez le rendre facultatif, vous devez préciser l'option required à la main.

Un mot également sur la validation que vous rencontrez depuis le navigateur : impossible de valider le formulaire si un champ obligatoire n'est pas rempli :

Le champ « Publication » était obligatoire

Pourtant, nous n'avons pas utilisé de JavaScript ! C'est juste du HTML5. En mettant l'attribut required="required" à une balise <input>, le navigateur interdit la validation du formulaire tant que cet input est vide. Pratique ! Mais attention, cela n'empêche pas de faire une validation côté serveur, au contraire. En effet, si quelqu'un utilise votre formulaire avec un vieux navigateur qui ne supporte pas le HMTL5, il pourra valider le formulaire sans problème.

Gérer les valeurs par défaut du formulaire

L'un des besoins courants dans les formulaires, c'est de mettre des valeurs prédéfinies dans les champs. Cela peut servir pour des valeurs par défaut (préremplir la date, par exemple) ou alors lors de l'édition d'un objet déjà existant (pour l'édition d'un article, on souhaite remplir le formulaire avec les valeurs de la base de données).

Heureusement, cela se fait très facilement. Il suffit de modifier l'instance de l'objet, ici $article, avant de le passer en argument à la méthode createFormBuilder, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php
// On crée un nouvel article
$article = new Article;

// Ici, on préremplit avec la date d'aujourd'hui, par exemple
// Cette date sera donc préaffichée dans le formulaire, cela facilite le travail de l'utilisateur
$article->setDate(new \Datetime());

// Et on construit le formBuilder avec cette instance d'article
$formBuilder = $this->createFormBuilder($article);

// N'oubliez pas d'ajouter les champs comme précédemment avec la méthode ->add()

Et si vous voulez modifier un article déjà enregistré en base de données, alors il suffit de le récupérer avant la création du formulaire, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
// Récupération d'un article déjà existant, d'id $id.
$article = $this->getDoctrine()
                ->getRepository('Sdz\BlogBundle\Entity\Article')
                ->find($id);

// Et on construit le formBuilder avec cette instance d'article, comme précédemment
$formBuilder = $this->createFormBuilder($article);

// N'oubliez pas d'ajouter les champs comme précédemment avec la méthode ->add()

Personnaliser l'affichage d'un formulaire

Jusqu'ici, nous n'avons pas du tout personnalisé l'affichage de notre formulaire. Voyez quand même le bon côté des choses : on travaillait côté PHP, on a pu avancer très rapidement sans se soucier d'écrire les balises <input> à la main, ce qui est long et sans intérêt.

Mais bon, à un moment donné, il faut bien mettre la main à la pâte et faire des formulaires dans le même style que son site. Pour cela, je ne vais pas m'étendre, mais voici un exemple qui vous permettra de faire à peu près tout ce que vous voudrez :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<form action="{{ path('votre_route') }}" method="post" {{ form_enctype(form) }}>

{# Les erreurs générales du formulaire. #}
{{ form_errors(form) }}

<div>
  {# Génération du label. #}
  {{ form_label(form.titre, "Titre de l'article") }}

  {# Affichage des erreurs pour ce champ précis. #}
  {{ form_errors(form.titre) }}

  {# Génération de l'input. #}
  {{ form_widget(form.titre) }}
</div>

{# Idem pour un autre champ. #}
<div>
  {{ form_label(form.contenu, "Contenu de l'article") }}
  {{ form_errors(form.contenu) }}
  {{ form_widget(form.contenu) }}
</div>

{# Génération des champs pas encore écrits.
   Dans cet exemple, ce serait « date », « auteur » et « publication »,
   mais aussi le champ CSRF (géré automatiquement par Symfony !)
   et tous les champs cachés (type « hidden »). #}
{{ form_rest(form) }}

</form>

Pour plus d'information concernant l'habillage des formulaires, je vous invite à consulter la documentation à ce sujet. Cela s'appelle en anglais le form theming.

Qu'est-ce que le CSRF ?

Le champ CSRF, pour Cross Site Request Forgeries, permet de vérifier que l'internaute qui valide le formulaire est bien celui qui l'a affiché. C'est un moyen de se protéger des envois de formulaire frauduleux (plus d'informations sur le CSRF). C'est un champ que Symfony2 rajoute automatiquement à tous vos formulaires, afin de les sécuriser sans même que vous vous en rendiez compte. ;)

Créer des types de champ personnalisés

Il se peut que vous ayez envie d'utiliser un type de champ précis, mais que ce type de champ n'existe pas par défaut. Heureusement, vous n'êtes pas coincés, vous pouvez vous en sortir en créant votre propre type de champ. Vous pourrez ensuite utiliser ce champ comme n'importe quel autre dans vos formulaires.

Imaginons par exemple que vous n'aimiez pas le rendu du champ date avec ces trois balises <select> pour sélectionner le jour, le mois et l'année. Vous préféreriez un joli datepicker en JavaScript. La solution ? Créer un nouveau type de champ !

Je ne vais pas décrire la démarche ici, mais sachez que cela existe et que la documentation traite ce point.

Externaliser la définition de ses formulaires

Vous savez enfin créer un formulaire. Ce n'était pas très compliqué, nous l'avons rapidement fait et ce dernier se trouve être assez joli. Mais vous souvenez-vous de ce que j'avais promis au début : nous voulions un formulaire réutilisable ; or là, tout est dans le contrôleur, et je vois mal comment le réutiliser ! Pour cela, il faut détacher la définition du formulaire dans une classe à part, nommée ArticleType (par convention).

Définition du formulaire dans ArticleType

ArticleType n'est pas notre formulaire. Comme tout à l'heure, c'est notre constructeur de formulaire. Par convention, on va mettre tous nos xxxType.php dans le répertoire Form du bundle. En fait, on va encore utiliser le générateur ici, qui sait générer les FormType pour nous, et vous verrez qu'on y gagne !

Exécutez donc la commande suivante :

1
php app/console doctrine:generate:form SdzBlogBundle:Article

Comme vous pouvez le voir c'est une commande Doctrine, car c'est lui qui a toutes les informations sur notre objet Article. Maintenant, vous pouvez aller voir le résultat dans le fichier src/Sdz/BlogBundle/Form/ArticleType.php.

On va commencer tout de suite par améliorer ce formulaire. En effet, vous pouvez voir que les types de champ ne sont pas précisés : le composant Form va les deviner à partir des annotations Doctrine qu'on a mis dans l'objet. Ce n'est pas une bonne pratique, car cela peut être source d'erreur, c'est pourquoi je vous invite dès maintenant à remettre explicitement les types comme on avait déjà fait dans le contrôleur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
// src/Sdz/BlogBundle/Form/ArticleType.php

namespace Sdz\BlogBundle\Form;

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

class ArticleType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',        'date')
      ->add('titre',       'text')
      ->add('contenu',     'textarea')
      ->add('auteur',      'text')
      ->add('publication', 'checkbox', array('required' => false))
    ;
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Sdz\BlogBundle\Entity\Article'
    ));
  }

  public function getName()
  {
    return 'sdz_blogbundle_articletype';
  }
}

J'ai également supprimé les champs image et categories, que nous verrons différemment plus loin dans ce chapitre.

Comme vous pouvez le voir, on n'a fait que déplacer la construction du formulaire, du contrôleur à une classe externe. Cet ArticleType correspond donc en fait à la définition des champs de notre formulaire. Ainsi, si l'on utilise le même formulaire sur plusieurs pages différentes, on utilisera ce même ArticleType. Fini le copier-coller ! Voici la réutilisabilité. ;)

Rappelez-vous également, un formulaire se construit autour d'un objet. Ici, on a indiqué à Symfony2 quel était cet objet grâce à la méthode setDefaultOptions(), dans laquelle on a défini l'option data_class.

Le contrôleur épuré

Avec cet ArticleType, la construction du formulaire côté contrôleur s'effectue grâce à la méthode createForm() du contrôleur (et non plus createFormBuilder()). Cette méthode utilise le composant Form pour construire un formulaire à partir du ArticleType passé en argument. Depuis le contrôleur, on récupère donc directement un formulaire, on ne passe plus par le constructeur de formulaire comme précédemment. Voyez par vous-mêmes :

1
2
3
4
5
<?php
// Dans le contrôleur

$article = new Article;
$form = $this->createForm(new ArticleType, $article);

En effet, si l'on s'est donné la peine de créer un objet à l'extérieur du contrôleur, c'est pour que ce contrôleur soit plus simple. C'est réussi !

Au final, en utilisant cette externalisation et en supprimant les commentaires, voici à quoi ressemble la gestion d'un formulaire dans Symfony2 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

use Sdz\BlogBundle\Entity\Article;
// N'oubliez pas d'ajouter le ArticleType
use Sdz\BlogBundle\Form\ArticleType;

// …

public function ajouterAction()
{
  $article = new Article;
  $form = $this->createForm(new ArticleType, $article);

  $request = $this->get('request');
  if ($request->getMethod() == 'POST') {
    $form->bind($request);

    if ($form->isValid()) {
      $em = $this->getDoctrine()->getManager();
      $em->persist($article);
      $em->flush();

      return $this->redirect($this->generateUrl('sdzblog_accueil'));
    }
  }

  return $this->render('SdzBlogBundle:Blog:ajouter.html.twig', array(
    'form' => $form->createView(),
  ));
}

Plutôt simple, non ? Au final, votre code métier, votre code qui fait réellement quelque chose, se trouve là où l'on a utilisé l'EntityManager. Pour l'exemple, nous n'avons fait qu'enregistrer l'article en base de données, mais c'est ici que vous pourrez envoyer un e-mail, ou effectuer toute autre action dont votre site internet aura besoin.

Les formulaires imbriqués

Intérêt de l'imbrication

Pourquoi imbriquer des formulaires ?

C'est souvent le cas lorsque vous avez des relations entre vos objets : vous souhaitez ajouter un objet A, mais en même temps un autre objet B qui est lié au premier. Exemple concret : vous voulez ajouter un client à votre application, votre Client est lié à une Adresse, mais vous avez envie d'ajouter l'adresse sur la même page que votre client, depuis le même formulaire. S'il fallait deux pages pour ajouter client puis adresse, votre site ne serait pas très ergonomique. Voici donc toute l'utilité de l'imbrication des formulaires !

Un formulaire est un champ

Eh oui, voici tout ce que vous devez savoir pour imbriquer des formulaires entre eux. Considérez un de vos formulaires comme un champ, et appelez ce simple champ depuis un autre formulaire ! Bon, facile à dire, mais il faut savoir le faire derrière.

D'abord, créez le formulaire de notre entité Image. Vous l'aurez compris, on peut utiliser le générateur ici, exécutez donc cette commande :

1
php app/console doctrine:generate:form SdzBlogBundle:Image

En explicitant les types des champs, cela donne le code suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
// src/Sdz/BlogBundle/Form/ImageType.php

namespace Sdz\BlogBundle\Form;

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

class ImageType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('url', 'text')
      ->add('alt', 'text')
    ;
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Sdz\BlogBundle\Entity\Image'
    ));
  }

  public function getName()
  {
    return 'sdz_blogbundle_imagetype';
  }
}

Ensuite, il existe deux façons d'imbriquer ce formulaire :

  1. Avec une relation simple où l'on imbrique une seule fois un sous-formulaire dans le formulaire principal. C'est le cas le plus courant, celui de notre Article avec une seule Image.
  2. Avec une relation multiple, où l'on imbrique plusieurs fois le sous-formulaire dans le formulaire principal. C'est le cas d'un Client qui pourrait enregistrer plusieurs Adresse.

Relation simple : imbriquer un seul formulaire

C'est le cas le plus courant, qui correspond à notre exemple de l'Article et de son Image. Pour imbriquer un seul formulaire en étant cohérent avec une entité, il faut que l'entité du formulaire principal (ici, Article) ait une relation One-To-One ou Many-To-One avec l'entité (ici, Image) dont on veut imbriquer le formulaire.

Une fois que vous savez cela, on peut imbriquer nos formulaires. C'est vraiment simple : allez dans ArticleType et ajoutez un champ image (du nom de la propriété de notre entité), de type… ImageType, bien sûr !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
// src/Sdz/BlogBundle/Form/ArticleType.php

class ArticleType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',        'date')
      ->add('titre',       'text')
      ->add('contenu',     'textarea')
      ->add('auteur',      'text')
      ->add('publication', 'checkbox', array('required' => false))
      ->add('image',        new ImageType()) // Rajoutez cette ligne
    ;
  }

  // …
}

C'est tout ! Allez sur la page d'ajout : /blog/ajouter. Le formulaire est déjà à jour (voir figure suivante), avec une partie « Image » où l'on peut remplir les deux seuls champs de ce formulaire, les champs « Url » et « Alt ». C'était d'une facilité déconcertante, n'est-ce pas ?

Le formulaire est à jour

Réfléchissons bien à ce qu'on vient de faire.

D'un côté, nous avons l'entité Article qui possède un attribut image. Cet attribut image contient, lui, un objet Image. Il ne peut pas contenir autre chose, à cause du setter associé : celui-ci force l'argument à être un objet de classe Image.

L'objectif du formulaire est donc de venir injecter dans cet attribut image un objet Image, et pas autre chose ! On l'a vu au début de ce chapitre, un formulaire de type XxxType retourne un objet de classe Xxx. Il est donc tout à fait logique de mettre dans ArticleType, un champ image de type ImageType.

Sachez qu'il est bien entendu possible d'imbriquer les formulaires à l'infini de cette façon. La seule limitation, c'est de faire quelque chose de compréhensible pour vos visiteurs, ce qui est tout de même le plus important.

Si lorsque vous validez votre formulaire vous avez une erreur de ce type :

1
2
3
4
5
6
7
A new entity was found through the relationship 'Sdz\BlogBundle\Entity\Article#categories'
that was not configured to cascade persist operations for entity:
Sdz\BlogBundle\Entity\Categorie@000000000579b29e0000000061a76c55. To solve this issue:
Either explicitly call EntityManager#persist() on this unknown entity or configure cascade
persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If
you cannot find out which entity causes the problem implement
'Sdz\BlogBundle\Entity\Categorie#__toString()' to get a clue.

… c'est que Doctrine ne sait pas quoi faire avec l'entité Image qui est dans l'entité Article, car vous ne lui avez pas dit de persister cette entité. Pour corriger l'erreur, il faut dire à Doctrine de persister cet objet Image bien sûr, suivez simplement les indications du message d'erreur :

  • Soit vous ajoutez manuellement un $em->persist($article->getImage()) dans le contrôleur, avant le flush() ;
  • Soit vous ajoutez une option à l'annotation @ORM\OneToOne dans l'entité Article, ce que nous avons fait si vous suivez ce cours depuis le début, comme ceci :
1
2
3
4
/**
 * @ORM\OneToOne(targetEntity="Sdz\BlogBundle\Entity\Image", cascade={"persist"})
 */
private $image;

C'est fini pour l'imbrication simple d'un formulaire dans un autre. Passons maintenant à l'imbrication multiple.

Relation multiple : imbriquer un même formulaire plusieurs fois

On imbrique un même formulaire plusieurs fois lorsque deux entités sont en relation Many-To-One ou Many-To-Many.

On va prendre l'exemple ici de l'imbrication de plusieurs CategorieType dans le ArticleType principal. Attention, cela veut dire qu'à chaque ajout d'Article, on aura la possibilité de créer de nouvelles Categorie. Ce n'est pas le comportement classique qui consiste plutôt à sélectionner des Categorie existantes. Ce n'est pas grave, c'est pour l'exemple, sachant que plus loin dans ce chapitre on étudie également la manière de sélectionner ces catégories.

Tout d'abord, créez le formulaire CategorieType grâce au générateur :

1
php app/console doctrine:generate:form SdzBlogBundle:Categorie

Voici ce que cela donne après avoir explicité les champs encore une fois :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
// src/Sdz/BlogBundle/Form/CategorieType.php

namespace Sdz\BlogBundle\Form;

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

class CategorieType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('nom', 'text') // Ici, explicitez le type du champ
    ;
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Sdz\BlogBundle\Entity\Categorie'
    ));
  }

  public function getName()
  {
    return 'sdz_blogbundle_categorietype';
  }
}

Maintenant, il faut rajouter le champ categories dans le ArticleType. Il faut pour cela utiliser le type collection et lui passer quelques options, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
// src/Sdz/BlogBundle/Form/ArticleType.php

class ArticleType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',        'date')
      ->add('titre',       'text')
      ->add('contenu',     'textarea')
      ->add('auteur',      'text')
      ->add('publication', 'checkbox', array('required' => false))
      ->add('image',        new ImageType())
      /*
       * Rappel :
       ** - 1er argument : nom du champ, ici « categories », car c'est le nom de l'attribut
       ** - 2e argument : type du champ, ici « collection » qui est une liste de quelque chose
       ** - 3e argument : tableau d'options du champ
       */
      ->add('categories', 'collection', array('type'         => new CategorieType(),
                                              'allow_add'    => true,
                                              'allow_delete' => true))
    ;
  }

  // ...
}

On a ici utilisé le type de champ collection, qui permet en réalité de construire une collection (une liste) de n'importe quoi. Ici on a dit grâce à l'option type qu'il doit créer une liste de CategorieType, mais on aurait pu faire une liste de type text : le formulaire aurait donc injecté dans l'attribut categories un simple tableau de textes.

Ce champ de type collection comporte plusieurs options en plus du type. Vous notez les options allow_add et allow_delete, qui autorisent au formulaire d'ajouter des entrées en plus dans la collection, ainsi que d'en supprimer. En effet, on pourrait tout à fait ne pas autoriser ces actions, ce qui aurait pour effet de ne permettre que la modification des Categorie qui sont déjà liées à l'Article.

Assez parlé, testons dès maintenant le résultat. Pour cela, actualisez la page d'ajout d'un article. Ah mince, le mot « Categorie » est bien inscrit, mais il n'y a rien en dessous. :D Ce n'est pas un bug, c'est bien voulu par Symfony2. En effet, comme l'entité Article lié au formulaire de base n'a pas encore de catégories, le champ collection n'a encore rien à afficher ! Et si on veut créer des catégories, il ne peut pas savoir à l'avance combien on veut en créer : 1, 2, 3 ou plus ?

La solution, sachant qu'on doit pouvoir ajouter à l'infini, et même supprimer, est d'utiliser du JavaScript. OK, cela ne nous fait pas peur !

D'abord, affichez la source de la page et regardez l'étrange balise <div> que Symfony2 a rajoutée en dessous du label Categorie :

1
2
3
4
5
6
7
8
9
<div id="sdz_blogbundle_articletype_categories" data-prototype="<div><label
class="required">__name__label__</label>
<div id="sdz_blogbundle_articletype_categories___name__"><div>
<label for="sdz_blogbundle_articletype_categories___name___nom"
class="required">Nom</label><input type="text" 
id="sdz_blogbundle_articletype_categories___name___nom"
name="sdz_blogbundle_articletype[categories][__name__][nom]" required="required" />
</div></div></div>">
</div>

Notez surtout l'attribut data-prototype. C'est en fait un attribut (au nom arbitraire) rajouté par Symfony2 et qui contient ce à quoi doit ressembler le code HTML pour ajouter un formulaire CategorieType. Voici son contenu sans les entités HTML :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<div>
  <label class="required">__name__label__</label>
  <div id="sdz_blogbundle_articletype_categories___name__">
    <div>
      <label for="sdz_blogbundle_articletype_categories___name___nom" class="required">Nom</label>
      <input type="text"
             id="sdz_blogbundle_articletype_categories___name___nom"
             name="sdz_blogbundle_articletype[categories][__name__][nom]"
             required="required" />
    </div>
  </div>
</div>

Vous voyez qu'il contient les balises <label> et <input>, tout ce qu'il faut pour créer le champ nom compris dans CategorieType, en fait. Si ce formulaire avait d'autres champs en plus de « nom », ceux-ci apparaîtraient ici également.

Du coup, on le remercie, car grâce à ce template ajouter des champs en JavaScript est un jeu d'enfant. Je vous propose de faire un petit script JavaScript dont le but est :

  • D'ajouter un bouton Ajouter qui permet d'ajouter à l'infini ce sous-formulaire CategorieType contenu dans l'attribut data-prototype ;
  • D'ajouter pour chaque sous-formulaire, un bouton Supprimer permettant de supprimer la catégorie associée.

Voici ce que je vous ai préparé, un petit script qui emploie la bibliothèque jQuery, mettez-le pour l'instant directement dans la vue du formulaire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
{# src/Sdz/BlogBundle/Resources/views/Blog/formulaire.html.twig #}

{# Le formulaire reste inchangé #}
<div class="well">
  <form method="post" {{ form_enctype(form) }}>
    {{ form_widget(form) }}
    <input type="submit" class="btn btn-primary" />
  </form>
</div>

{# On charge la bibliothèque jQuery. Ici, je la prends depuis le site jquery.com,
   mais si vous l'avez en local, changez simplement l'adresse. #}
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>

{# Voici le script en question : #}
<script type="text/javascript">
$(document).ready(function() {
  // On récupère la balise <div> en question qui contient l'attribut « data-prototype » qui nous intéresse.
  var $container = $('div#sdz_blogbundle_articletype_categories');

  // On ajoute un lien pour ajouter une nouvelle catégorie
  var $lienAjout = $('<a href="#" id="ajout_categorie" class="btn">Ajouter une catégorie</a>');
  $container.append($lienAjout);

  // On ajoute un nouveau champ à chaque clic sur le lien d'ajout.
  $lienAjout.click(function(e) {
    ajouterCategorie($container);
    e.preventDefault(); // évite qu'un # apparaisse dans l'URL
    return false;
  });

  // On définit un compteur unique pour nommer les champs qu'on va ajouter dynamiquement
  var index = $container.find(':input').length;

  // On ajoute un premier champ directement s'il n'en existe pas déjà un (cas d'un nouvel article par exemple).
  if (index == 0) {
    ajouterCategorie($container);
  } else {
    // Pour chaque catégorie déjà existante, on ajoute un lien de suppression
    $container.children('div').each(function() {
      ajouterLienSuppression($(this));
    });
  }

  // La fonction qui ajoute un formulaire Categorie
  function ajouterCategorie($container) {
    // Dans le contenu de l'attribut « data-prototype », on remplace :
    // - le texte "__name__label__" qu'il contient par le label du champ
    // - le texte "__name__" qu'il contient par le numéro du champ
    var $prototype = $($container.attr('data-prototype').replace(/__name__label__/g, 'Catégorie n°' + (index+1))
                                                        .replace(/__name__/g, index));

    // On ajoute au prototype un lien pour pouvoir supprimer la catégorie
    ajouterLienSuppression($prototype);

    // On ajoute le prototype modifié à la fin de la balise <div>
    $container.append($prototype);

    // Enfin, on incrémente le compteur pour que le prochain ajout se fasse avec un autre numéro
    index++;
  }

  // La fonction qui ajoute un lien de suppression d'une catégorie
  function ajouterLienSuppression($prototype) {
    // Création du lien
    $lienSuppression = $('<a href="#" class="btn btn-danger">Supprimer</a>');

    // Ajout du lien
    $prototype.append($lienSuppression);

    // Ajout du listener sur le clic du lien
    $lienSuppression.click(function(e) {
      $prototype.remove();
      e.preventDefault(); // évite qu'un # apparaisse dans l'URL
      return false;
    });
  }
});
</script>

Appuyez sur F5 sur la page d'ajout et admirez le résultat (voir figure suivante). Voilà qui est mieux !

Le formulaire multiple est opérationnel et dynamique

Et voilà, votre formulaire est maintenant opérationnel ! Vous pouvez vous amuser à créer des articles contenant plein de nouvelles catégories en même temps.

Un type de champ très utile : entity

Je vous ai prévenu que ce qu'on vient de faire sur l'attribut categories était particulier : sur le formulaire d'ajout d'un article nous pouvons créer des nouvelles catégories et non sélectionner des catégories déjà existantes. Ce paragraphe n'a rien à voir avec l'imbrication de formulaire, mais je me dois de vous en parler maintenant pour que vous compreniez bien la différence entre les types de champ entity et collection.

Le type entity est un type assez puissant, vous allez le voir très vite. Nous allons l'utiliser à la place du type collection qu'on vient de mettre en place. Vous connaîtrez ainsi les deux types, libre à vous ensuite d'utiliser celui qui convient le mieux à votre cas.

Le type entity permet donc de sélectionner des entités. D'un <select> côté formulaire HTML, vous obtenez une ou plusieurs entités côté formulaire Symfony2. Testons-le tout de suite, modifiez le champ categories comme suit :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
// src/Szd/BlogBundle/Form/ArticleType.php

// ...

$builder->add('categories', 'entity', array(
  'class'    => 'SdzBlogBundle:Categorie',
  'property' => 'nom',
  'multiple' => true)
);

Rafraîchissez le formulaire et admirez : On peut ainsi sélectionner une ou plusieurs catégories

Les options du type de champ

Alors, quelques explications sur les options de ce type de champ :

  • L'option class définit quel est le type d'entité à sélectionner. Ici, on veut sélectionner des entités Categorie, on renseigne donc le raccourci Doctrine pour cette entité (ou son namespace complet).
  • L'option property définit comment afficher les entités dans le select du formulaire. En effet, comment afficher une catégorie ? Par son nom ? Son id ? Un mix des deux ? Ce n'est pas à Symfony de le deviner, on lui précise donc grâce à cette option property. Ici j'ai renseigné nom, c'est donc via leur nom qu'on liste les catégories dans le select. Sachez que vous pouvez également renseigner affichage (ou autre !) et créer le getter associé (à savoir getAffichage()) dans l'entité Categorie, ce sera donc le retour de cette méthode qui sera affiché dans le select.
  • L'option multiple définit qu'on parle ici d'une liste de catégories, et non d'une catégorie unique. Cette option est très importante, car, si vous l'oubliez, le formulaire (qui retourne une entité Categorie) et votre entité Article (qui attend une liste d'entités Categorie) ne vont pas s'entendre !

Alors, intéressant, ce type de champ, n'est-ce pas ?

Et encore, ce n'est pas fini. Si la fonctionnalité de ce type (sélectionner une ou plusieurs entités) est unique, le rendu peut avoir quatre formes en fonction des options multiple et expanded :

Options

Multiple = false

Multiple = true

Expanded = false

<select> avec une seule option sélectionnable

<select> avec plusieurs options sélectionnables

Expanded = true

<radio>

<checkbox>

Par défaut, les options multiple et expanded sont à false. :)

L'option querybuilder

Comme vous avez pu le constater, toutes les catégories de la base de données apparaissent dans ce champ. Or parfois ce n'est pas le comportement voulu. Imaginons par exemple un champ où vous souhaitez afficher uniquement les articles _publié_s. Tout est prévu : il faut jouer avec l'option querybuilder.

Cette option porte bien son nom puisqu'elle permet de passer au champ un QueryBuilder, que vous connaissez depuis la partie sur Doctrine. Tout d'abord, créons une méthode dans le repository de l'entité du champ qui retourne le bon QueryBuilder :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
// src/Sdz/BlogBundle/Entity/ArticleRepository.php

namespace Sdz\BlogBundle\Entity;
use Doctrine\ORM\EntityRepository;

class ArticleRepository extends EntityRepository
{
  public function getSelectList()
  {
    $qb = $this->createQueryBuilder('a')
               ->where('a.publication = 1'); // On filtre sur l'attribut publication

    // Et on retourne simplement le QueryBuilder, et non la Query, attention
    return $qb;
  }

  // ...
}

Et on l'envoie dans l'option querybuilder grâce à une closure dont l'argument est le repository, comme ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
// Dans un XxxType

$builder->add('article', 'entity', array(
  'class'        => 'SdzBlogBundle:Article',
  'property'     => 'titre',
  'querybuilder' => function(\Sdz\BlogBundle\Entity\ArticleRepository $r) {
    return $r->getSelectList();
  }
));

Si vous n'êtes pas habitués aux closures, voici la méthode pour passer un argument de votre formulaire jusqu'à la méthode du repository, il vous faut utiliser le use() de PHP, comme ceci :

1
2
3
4
<?php
'querybuilder' => function(ArticleRepository $r) use($uneVariable) {
  return $r->getSelectList($uneVariable);
}

Souvenez-vous de cette syntaxe, elle vous servira très certainement un jour. ;)

Aller plus loin avec les formulaires

L'héritage de formulaire

Je souhaiterais vous faire un point sur l'héritage de formulaire. En effet, nos formulaires, représentés par les objets XxxType, sont avant tout de simples objets ! On peut donc tout à fait utiliser les mécanismes habituels en POO.

L'utilité dans le cadre des formulaires, c'est de pouvoir construire des formulaires différents, mais ayant la même base. Pour faire simple, je vais prendre l'exemple des formulaires d'ajout et de modification d'un Article. Imaginons que le formulaire d'ajout comprenne tous les champs, mais que pour l'édition il soit impossible de modifier la date par exemple.

Comme nous sommes en présence de deux formulaires distincts, on va faire deux XxxType distincts : ArticleType pour l'ajout, et ArticleEditType pour la modification. Seulement, il est hors de question de répéter la définition de tous les champs dans le ArticleEditType, tout d'abord c'est long, mais surtout si jamais un champ change, on devra modifier à la fois ArticleType et ArticleEditType, c'est impensable.

On va donc faire hériter ArticleEditType de ArticleType. Le processus est le suivant :

  1. Copiez-collez le fichier ArticleType.php et renommez la copie en ArticleEditType.php ;
  2. Modifiez le nom de la classe, ainsi que le nom du formulaire dans la méthode getName() ;
  3. Remplacez la définition manuelle de tous les champs (les $builder->add()) par un appel à la méthode parente : <?php parent::buildForm($builder, $options) ;
  4. Rajoutez cette ligne à la suite pour supprimer le champ date et ainsi ne pas le faire apparaître lors de l'édition : <?php $builder->remove('date') ;
  5. Enfin, supprimez la méthode setDefaultOptions() qu'il ne sert à rien d'hériter.

Voici ce que cela donne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// src/Sdz/BlogBundle/Form/ArticleEditType.php

namespace Sdz\BlogBundle\Form;

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

class ArticleEditType extends ArticleType // Ici, on hérite de ArticleType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    // On fait appel à la méthode buildForm du parent, qui va ajouter tous les champs à $builder
    parent::buildForm($builder, $options);

    // On supprime celui qu'on ne veut pas dans le formulaire de modification
    $builder->remove('date');
  }

  // On modifie cette méthode car les deux formulaires doivent avoir un nom différent
  public function getName()
  {
    return 'sdz_blogbundle_articleedittype';
  }
}

Nous avons utilisé ici des notions de POO en PHP, qui n'ont rien du tout de spécifique à Symfony2. Avant d'être des formulaires, des entités et autres, nos objets sont de simples objets PHP avec lesquels on peut faire tout ce que PHP nous autorise. ;) Gardez toujours cela en tête.

Maintenant, si vous utilisez le formulaire ArticleEditType, vous ne pourrez pas modifier l'attribut date de l'entité Article. Objectif atteint ! Prenez le temps de tester ce nouveau formulaire depuis l'action modifierAction() de notre blog.

À retenir

Plusieurs choses à retenir de cet héritage de formulaire :

  • D'une part, si vous avez besoin de plusieurs formulaires : faites plusieurs XxxType ! Cela ne mange pas de pain, et vous évite de faire du code impropre derrière en mettant des conditions hasardeuses. Le raisonnement est simple : si le formulaire que vous voulez afficher à votre internaute est différent (champ en moins, champ en plus), alors côté Symfony2 c'est un tout autre formulaire, qui mérite son propre XxxType.
  • D'autre part, pensez à bien utiliser la notion PHP d'héritage pour éviter de dupliquer du code. Si faire plusieurs formulaires est une bonne chose, dupliquer les champs à droite et à gauche ne l'est pas. Centralisez donc la définition de vos champs dans un formulaire, et utilisez l'héritage pour le propager aux autres.

Construire un formulaire différemment selon des paramètres

Un autre besoin qui se fait sentir lors de l'élaboration de formulaires un peu plus complexes que notre simple ArticleType, c'est la modulation d'un formulaire en fonction de certains paramètres.

Par exemple, on pourrait empêcher de dépublier un article une fois qu'il est publié. Le comportement serait le suivant :

  • Si l'article n'est pas encore publié, on peut modifier sa valeur de publication lorsqu'on modifie l'article ;
  • Si l'article est déjà publié, on ne peut plus modifier sa valeur de publication lorsqu'on modifie l'article.

C'est un exemple simple, retenez l'idée derrière qui est de construire le formulaire suivant les valeurs de l'objet sous-jacent. Ce n'est pas aussi évident qu'il n'y paraît, car dans la méthode buildForm() nous n'avons pas accès aux valeurs de l'objet Article qui sert de base au formulaire !

Pour arriver à nos fins, il faut utiliser les évènements de formulaire. Ce sont des évènements que le formulaire déclenche à certains moments de sa construction. Il existe notamment l'évènement PRE_SET_DATA qui est déclenché juste avant que les champs ne soient remplis avec les valeurs de l'objet (les valeurs par défaut donc). Cet évènement permet de modifier la structure du formulaire.

Sans plus attendre, voici à quoi ressemble notre nouvelle méthode buildForm() :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

namespace Sdz\BlogBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
// Ajoutez ces deux use :
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;

class ArticleType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      // … Ajoutez ici tous les champs stables, mais pas le champ publication
    ;

    $factory = $builder->getFormFactory();

    // On ajoute une fonction qui va écouter l'évènement PRE_SET_DATA
    $builder->addEventListener(
      FormEvents::PRE_SET_DATA, // Ici, on définit l'évènement qui nous intéresse
      function(FormEvent $event) use ($factory) { // Ici, on définit une fonction qui sera exécutée lors de l'évènement
        $article = $event->getData();
        // Cette condition est importante, on en reparle plus loin
        if (null === $article) {
          return; // On sort de la fonction lorsque $article vaut null
        }
        // Si l'article n'est pas encore publié, on ajoute le champ publication
        if (false === $article->getPublication()) {
          $event->getForm()->add(
            $factory->createNamed('publication', 'checkbox', null, array('required' => false))
          );
        } else { // Sinon, on le supprime
          $event->getForm()->remove('publication');
        }
      }
    );
  }

  // …
}

Il y a beaucoup de syntaxe dans ce code, mais il est au fond abordable, et vous montre les possibilités qu'offrent les évènements de formulaire.

La fonction qui est exécutée par l'évènement prend en argument l'évènement lui-même, la variable $event. Depuis cet objet évènement, vous pouvez récupérer d'une part l'objet sous-jacent, via $event->getData(), et d'autre part le formulaire, via $event->getForm().

Récupérer l'Article nous permet d'utiliser les valeurs qu'il contient, chose qu'on ne peut pas faire d'habitude dans la méthode buildForm(), qui, elle, est exécutée une fois pour toutes, indépendamment de l'objet sous-jacent. Pour mieux visualiser cette unique instance du XxxType, pensez à un champ de type collection, rappelez-vous sa définition :

1
2
<?php
$builder->add('categories', 'collection', array('type' => new CategorieType());

Avec ce code, on ne crée qu'un seul objet CategorieType, or celui-ci sera utilisé pour ajouter plusieurs catégories. Il est donc normal de ne pas avoir accès à l'objet $categorie lors de la construction du formulaire, autrement dit la construction de l'objet CategorieType. C'est pour cela qu'il faut utiliser l'évènement PRE_SET_DATA, qui, lui, est déclenché à chaque fois que le formulaire remplit les valeurs de ses champs par les valeurs de l'objet $categorie.

Je reviens sur la condition if (null == $article) dans la fonction. En fait, à la première création du formulaire, celui-ci exécute sa méthode setData() avec null en argument. Cette occurrence de l'évènement PRE_SET_DATA ne nous intéresse pas, d'où la condition pour sortir de la fonction lorsque $event->getData() vaut null. Ensuite, lorsque le formulaire récupère l'objet ($article dans notre cas) sur lequel se construire, il réexécute sa méthode setData() avec l'objet en argument. C'est cette occurrence-là qui nous intéresse.

Sachez qu'il est également possible d'ajouter non pas une simple fonction à exécuter lors de l'évènement, mais un service ! Tout cela et bien plus encore est décrit dans la documentation des évènements de formulaire. N'hésitez pas à vous documenter dessus, car c'est cette méthode des évènements qui permet également la création des fameuses combo box : deux champs <select> dont le deuxième (par exemple ville) dépend de la valeur du premier (par exemple pays).

Le type de champ File pour envoyer des fichiers

Dans cette partie, nous allons apprendre à envoyer un fichier via le type File, ainsi qu'à le persister via les évènements Doctrine (j'espère que vous ne les avez pas déjà oubliés !).

Le type de champ File

Un champ File ne retourne pas du texte, mais une instance de la classe UploadedFile. Pour cette raison, il faut utiliser un attribut à part dans l'entité sous-jacente au formulaire, ici Image.

Préparer l'objet sous-jacent

Ouvrez donc l'entité Image et ajoutez l'attribut $file suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
// src/Sdz/BlogBundle/Entity/Image.php

namespace Sdz\BlogBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="Sdz\BlogBundle\Entity\ImageRepository")
 */
class Image
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

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

  private $file;

  // …
}

Notez bien que je n'ai pas mis d'annotation pour Doctrine : ce n'est pas cet attribut $file que nous allons persister par la suite, on ne met donc pas d'annotation. Par contre, c'est bien cet attribut qui servira pour le formulaire, et non les autres.

Adapter le formulaire

Passons maintenant au formulaire. Nous avions construit un champ de formulaire sur l'attribut $url, dans lequel l'utilisateur devait mettre directement l'URL internet de son image. Maintenant on veut lui permettre d'envoyer un fichier depuis son ordinateur.

On va donc supprimer le champ sur $url (et sur $alt, on va pouvoir le générer dynamiquement) et en créer un nouveau sur $file :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php
// src/Sdz/BlogBundle/Form/ImageType.php

namespace Sdz\BlogBundle\Form;

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

class ImageType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('file', 'file')
    ;
  }

  // …
}

Le rendu de votre formulaire est déjà bon. Essayez de vous rendre sur la page d'ajout, vous allez voir le champ d'upload de la figure suivante.

Champ pour envoyer un fichier

Lorsque vous utilisez des formulaires avec des envois de fichiers, n'oubliez jamais de préciser l'enctype dans la balise HTML du formulaire, comme ceci : <form method="post" {{ form_enctype(form) }}>.

Bon, par contre évidemment le formulaire n'est pas opérationnel. La sauvegarde du fichier envoyé ne va pas se faire toute seul !

Manipuler le fichier envoyé

Une fois le formulaire soumis, il faut bien évidemment s'occuper du fichier envoyé. L'objet UploadedFile que le formulaire nous renvoie simplifie grandement les choses, grâce à sa méthode move(). Créons une méthode upload() dans notre objet Image pour s'occuper de tout cela :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
// src/Sdz/BlogBundle/Entity/Image.php

  public function upload()
  {
    // Si jamais il n'y a pas de fichier (champ facultatif)
    if (null === $this->file) {
      return;
    }

    // On garde le nom original du fichier de l'internaute
    $name = $this->file->getClientOriginalName();

    // On déplace le fichier envoyé dans le répertoire de notre choix
    $this->file->move($this->getUploadRootDir(), $name);

    // On sauvegarde le nom de fichier dans notre attribut $url
    $this->url = $name;

    // On crée également le futur attribut alt de notre balise <img>
    $this->alt = $name;
  }

  public function getUploadDir()
  {
    // On retourne le chemin relatif vers l'image pour un navigateur
    return 'uploads/img';
  }

  protected function getUploadRootDir()
  {
    // On retourne le chemin relatif vers l'image pour notre code PHP
    return __DIR__.'/../../../../web/'.$this->getUploadDir();
  }

Plusieurs choses dans ce code.

D'une part, on a défini le répertoire dans lequel stocker nos images. J'ai mis ici uploads/img, ce répertoire est relatif au répertoire web, vous pouvez tout à fait le personnaliser. La méthode getUploadDir() retourne ce chemin relatif, à utiliser dans vos vues car les navigateurs sont relatifs à notre répertoire web. La méthode getUploadRootDir(), quant à elle, retourne le chemin vers le même fichier, mais en absolu. Vous le savez __DIR__ représente le répertoire absolu du fichier courant, ici notre entité, du coup pour atteindre le répertoire web, il faut remonter pas mal de dossiers, comme vous pouvez le voir. :p

D'autre part, la méthode upload() s'occupe concrètement de notre fichier. Elle fait l'équivalent du move_uploaded_file() que vous pouviez utiliser en PHP pur. Ici j'ai choisi pour l'instant de garder le nom du fichier tel qu'il était sur le PC du visiteur, ce n'est évidemment pas optimal, car si deux fichiers du même nom sont envoyés, le second écrasera le premier !

Enfin, d'un point de vue persistance de notre entité Image dans la base de données, la méthode upload() s'occupe également de renseigner les deux attributs persistés, $url et $alt. En effet, l'attribut $file, qui est le seul rempli par le formulaire, n'est pas du tout persisté.

Bien entendu, cette méthode ne s'exécute pas toute seule, il faut l'exécuter à la main depuis le contrôleur. Rajoutez donc la ligne 8 du code suivant dans la méthode ajouterAction() :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

// …

if ($form->isValid()) {
  // Ici : On traite manuellement le fichier uploadé
  $article->getImage()->upload();

  // Puis, le reste de la méthode, qu'on avait déjà fait
  $em = $this->getDoctrine()->getManager();
  $em->persist($article);
  $em->flush();

  // Enfin, éventuelle redirection vers la page du nouvel article créé
  return $this->redirect($this->generateUrl('sdzblog_voir', array('id' => $article->getId())));
}

// …

Il est impératif d'exécuter la méthode upload() avant de persister l'entité, car sinon les attributs $url et $alt ne seront pas définis à l'exécution du flush, et cela créerait une erreur (ils ne peuvent pas être null dans la base de données).

Si vous commencez à bien penser « découplage », ce que nous venons de faire ne devrait pas vous plaire. Le contrôleur ne devrait pas avoir à agir juste parce que nous avons un peu modifié le comportement de l'entité Image. Et imaginez qu'un jour nous oubliions d'exécuter manuellement cette méthode upload() ! Bref, vous l'aurez compris, il faut ici réutiliser les évènements Doctrine2 pour automatiser tout cela. ;)

Automatiser le traitement grâce aux évènements

La manipulation du champ de type File que nous venons de faire est bonne, mais son implémentation est juste un peu maladroite. Il faut automatiser cela grâce aux évènements Doctrine. Mais ce n'est pas que de l'esthétisme, c'est impératif pour gérer tous les cas… comme la suppression par exemple !

On va également en profiter pour modifier le nom donné au fichier qu'on déplace dans notre répertoire web/uploads/img. Le fichier va prendre comme nom l'id de l'entité, suffixé de son extension évidemment.

Quels évènements utiliser ?

C'est une question qu'il faut toujours se poser consciencieusement, car le comportement peut changer du tout au tout suivant les évènements choisis. Dans notre cas, il y a en réalité quatre actions différentes à exécuter :

  • Avant l'enregistrement effectif dans la base de données : il faut remplir les attributs $url et $alt avec les bonnes valeurs suivant le fichier envoyé. On doit impérativement le faire avant l'enregistrement, pour qu'ils puissent être enregistrés eux-mêmes en base de données. Pour cette action, il faut utiliser les évènements : - PrePersist
    • PreUpdate
  • Juste après l'enregistrement : il faut déplacer effectivement le fichier envoyé. On ne le fait pas avant, car l'enregistrement peut échouer. En cas d'échec de l'enregistrement de l'entité en base de données, il ne faudrait pas se retrouver avec un fichier orphelin sur notre disque. On attend donc que l'enregistrement se fasse effectivement avant de déplacer le fichier. Pour cette action, il faut utiliser les évènements : - PostPersist
    • PostUpdate
  • Juste avant la suppression : il faut sauvegarder le nom du fichier dans un attribut non persisté, $filename. En effet, le nom du fichier dépendant de l'id, on n'y aura plus accès en PostRemove, on est donc obligé de le sauvegarder en PreRemove : peu pratique mais obligatoire. Pour cette action, il faut utiliser l'évènement : - PreRemove
  • Juste après la suppression : il faut supprimer le fichier qui était associé à l'entité. Encore une fois, on ne le fait pas avant la suppression, car si l'entité n'est au final pas supprimée, on aurait alors une entité sans fichier. Pour cette action, il faut utiliser l'évènement : - PostRemove

Implémenter les méthodes des évènements

La méthode est la suivante :

  • On éclate l'ancien code de la méthode upload() dans les méthodes :
    • preUpload() : pour ce qui est de la génération des attributs $url et $alt ;
    • upload() : pour le déplacement effectif du fichier.
  • On ajoute une méthode preRemoveUpload() qui sauvegarde le nom du fichier qui dépend de l'id de l'entité.
  • On ajoute une méthode removeUpload() qui supprime effectivement le fichier grâce au nom enregistré.

N'oubliez pas de rajouter un attribut (ici j'ai mis $filename) pour la sauvegarde du nom du fichier. Au final, voici ce que cela donne :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<?php
// src/Sdz/BlogBundle/Entity/Image.php

namespace Sdz\BlogBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
// N'oubliez pas ce use :
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * @ORM\Entity(repositoryClass="Sdz\BlogBundle\Entity\ImageRepository")
 * @ORM\HasLifecycleCallbacks
 */
class Image
{
  private $file;

  // On ajoute cet attribut pour y stocker le nom du fichier temporairement
  private $tempFilename;

  // On modifie le setter de File, pour prendre en compte l'upload d'un fichier lorsqu'il en existe déjà un autre
  public function setFile(UploadedFile $file)
  {
    $this->file = $file;

    // On vérifie si on avait déjà un fichier pour cette entité
    if (null !== $this->url) {
      // On sauvegarde l'extension du fichier pour le supprimer plus tard
      $this->tempFilename = $this->url;

      // On réinitialise les valeurs des attributs url et alt
      $this->url = null;
      $this->alt = null;
    }
  }

  /**
   * @ORM\PrePersist()
   * @ORM\PreUpdate()
   */
  public function preUpload()
  {
    // Si jamais il n'y a pas de fichier (champ facultatif)
    if (null === $this->file) {
      return;
    }

    // Le nom du fichier est son id, on doit juste stocker également son extension
    // Pour faire propre, on devrait renommer cet attribut en « extension », plutôt que « url »
    $this->url = $this->file->guessExtension();

    // Et on génère l'attribut alt de la balise <img>, à la valeur du nom du fichier sur le PC de l'internaute
    $this->alt = $this->file->getClientOriginalName();
  }

  /**
   * @ORM\PostPersist()
   * @ORM\PostUpdate()
   */
  public function upload()
  {
    // Si jamais il n'y a pas de fichier (champ facultatif)
    if (null === $this->file) {
      return;
    }

    // Si on avait un ancien fichier, on le supprime
    if (null !== $this->tempFilename) {
      $oldFile = $this->getUploadRootDir().'/'.$this->id.'.'.$this->tempFilename;
      if (file_exists($oldFile)) {
        unlink($oldFile);
      }
    }

    // On déplace le fichier envoyé dans le répertoire de notre choix
    $this->file->move(
      $this->getUploadRootDir(), // Le répertoire de destination
      $this->id.'.'.$this->url   // Le nom du fichier à créer, ici « id.extension »
    );
  }

  /**
   * @ORM\PreRemove()
   */
  public function preRemoveUpload()
  {
    // On sauvegarde temporairement le nom du fichier, car il dépend de l'id
    $this->tempFilename = $this->getUploadRootDir().'/'.$this->id.'.'.$this->url;
  }

  /**
   * @ORM\PostRemove()
   */
  public function removeUpload()
  {
    // En PostRemove, on n'a pas accès à l'id, on utilise notre nom sauvegardé
    if (file_exists($this->tempFilename)) {
      // On supprime le fichier
      unlink($this->tempFilename);
    }
  }

  public function getUploadDir()
  {
    // On retourne le chemin relatif vers l'image pour un navigateur
    return 'uploads/img';
  }

  protected function getUploadRootDir()
  {
    // On retourne le chemin relatif vers l'image pour notre code PHP
    return __DIR__.'/../../../../web/'.$this->getUploadDir();
  }

  // …
}

Et voilà, votre upload est maintenant totalement opérationnel.

Bien sûr, vous devez supprimer l'appel à $article->getImage()->upload() qu'on avait mis à la main dans le contrôleur. Cette ligne n'est plus utile maintenant que tout est fait automatiquement grâce aux évènements !

Vous pouvez vous amuser avec votre système d'upload. Créez des articles avec des images jointes, vous verrez automatiquement les fichiers apparaître dans web/uploads/img. Supprimez un article : l'image jointe sera automatiquement supprimée du répertoire.

Pour que l'entité Image liée à un article soit supprimée lorsque vous supprimez l'entité Article, assurez-vous que l'action remove soit en cascade. Pour cela, votre annotation sur l'attribut $image dans votre entité Article devrait ressembler à ceci : @ORM\OneToOne(targetEntity="Sdz\BlogBundle\Entity\Image", cascade={"persist", "remove"}).

Attention à ne pas laisser la possibilité à vos visiteurs d'envoyer n'importe quel type de fichier sur votre site internet ! Il est impératif d'ajouter une règle de validation @Assert\File pour limiter les types de fichiers, et ne pas laisser une faille de sécurité béante. On aborde les règles de validation dans le prochain chapitre.

Vous devez également modifier la vue article.html.twig qui affiche les images. Nous avions utilisé {{ image.url }}, mais ce n'est plus bon puisque l'on ne stocke plus que l'extension du fichier dans l'attribut $url. Il faudrait donc mettre le code suivant :

1
2
3
4
<img
  src="{{ asset(article.image.uploadDir ~ '/' ~ article.image.id ~ '.' ~ article.image.url) }}"
  alt="{{ article.image.alt }}"
/>

En fait, comme vous pouvez le voir, c'est assez long à écrire dans la vue. Il est donc intéressant d'ajouter une méthode qui fait tout cela dans l'entité, par exemple getWebPath() :

1
2
3
4
5
6
7
<?php
// src/Sdz/BlogBundle/Entity/Image.php

  public function getWebPath()
  {
    return $this->getUploadDir().'/'.$this->getId().'.'.$this->getUrl();
  }

Et du coup, dans la vue, il ne reste plus que :

1
2
3
4
<img 
  src="{{ asset(article.image.webPath) }}"
  alt="{{ article.image.alt }}"
/>

Application : les formulaires de notre blog

Théorie

Nous avons déjà généré presque tous les formulaires utiles pour notre blog, mais nous n'avons pas entièrement adapté les actions du contrôleur pour les rendre pleinement opérationnelles.

Je vous invite donc à reprendre tout notre contrôleur, et à le modifier de telle sorte que toutes ses actions soient entièrement fonctionnelles, vous avez toutes les clés en main maintenant ! Je pense notamment aux actions de modification et de suppression, que nous n'avons pas déjà faites dans ce chapitre. Au boulot ! Essayez d'implémenter vous-mêmes la gestion du formulaire dans les actions correspondantes. Ensuite seulement, lisez la suite de ce paragraphe pour avoir la solution.

Pratique

Je vous remets déjà tous les formulaires pour être sûr qu'on parle de la même chose.

ArticleType

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
// src/Sdz/BlogBundle/Form/ArticleType.php

namespace Sdz\BlogBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;

class ArticleType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      'date')
      ->add('titre',     'text')
      ->add('contenu',   'textarea')
      ->add('auteur',    'text')
      ->add('image',     new ImageType())
      /*
       * Rappel :
       ** - 1er argument : nom du champ, ici « categories » car c'est le nom de l'attribut
       ** - 2e argument : type du champ, ici « collection » qui est une liste de quelque chose
       ** - 3e argument : tableau d'options du champ
       */
      /*->add('categories',  'collection', array('type'         => new CategorieType(),
                                               'allow_add'    => true,
                                               'allow_delete' => true))*/
      ->add('categories', 'entity', array(
        'class'    => 'SdzBlogBundle:Categorie',
        'property' => 'nom',
        'multiple' => true,
        'expanded' => false
      ))
    ;

    // On récupère la factory (usine)
    $factory = $builder->getFormFactory();

    // On ajoute une fonction qui va écouter l'évènement PRE_SET_DATA
    $builder->addEventListener(
      FormEvents::PRE_SET_DATA, // Ici, on définit l'évènement qui nous intéresse
      function(FormEvent $event) use ($factory) { // Ici, on définit une fonction qui sera exécutée lors de l'évènement
        $article = $event->getData();
        // Cette condition est importante, on en reparle plus loin
        if (null === $article) {
          return; // On sort de la fonction lorsque $article vaut null
        }
        // Si l'article n'est pas encore publié, on ajoute le champ publication
        if (false === $article->getPublication()) {
          $event->getForm()->add(
            $factory->createNamed('publication', 'checkbox', null, array('required' => false))
          );
        } else { // Sinon, on le supprime
          $event->getForm()->remove('publication');
        }
      }
    );
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Sdz\BlogBundle\Entity\Article'
    ));
  }

  public function getName()
  {
    return 'sdz_blogbundle_articletype';
  }
}

ArticleEditType

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// src/Sdz/BlogBundle/Form/ArticleEditType.php

namespace Sdz\BlogBundle\Form;

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

class ArticleEditType extends ArticleType // Ici, on hérite de ArticleType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    // On fait appel à la méthode buildForm du parent, qui va ajouter tous les champs à $builder
    parent::buildForm($builder, $options);

    // On supprime celui qu'on ne veut pas dans le formulaire de modification
    $builder->remove('date');
  }

  // On modifie cette méthode, car les deux formulaires doivent avoir un nom différent
  public function getName()
  {
    return 'sdz_blogbundle_articleedittype';
  }
}

ImageType

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
// src/Sdz/BlogBundle/Form/ImageType.php

namespace Sdz\BlogBundle\Form;

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

class ImageType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('file', 'file')
    ;
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Sdz\BlogBundle\Entity\Image'
    ));
  }

  public function getName()
  {
    return 'sdz_blogbundle_imagetype';
  }
}

L'action « ajouter » du contrôleur

On a déjà fait cette action, je vous la remets ici comme référence :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

  public function ajouterAction()
  {
    $article = new Article;

    // On crée le formulaire grâce à l'ArticleType
    $form = $this->createForm(new ArticleType(), $article);

    // On récupère la requête
    $request = $this->getRequest();

    // On vérifie qu'elle est de type POST
    if ($request->getMethod() == 'POST') {
      // On fait le lien Requête <-> Formulaire
      $form->bind($request);

      // On vérifie que les valeurs entrées sont correctes
      // (Nous verrons la validation des objets en détail dans le prochain chapitre)
      if ($form->isValid()) {
        // On enregistre notre objet $article dans la base de données
        $em = $this->getDoctrine()->getManager();
        $em->persist($article);
        $em->flush();

        // On définit un message flash
        $this->get('session')->getFlashBag()->add('info', 'Article bien ajouté');

        // On redirige vers la page de visualisation de l'article nouvellement créé
        return $this->redirect($this->generateUrl('sdzblog_voir', array('id' => $article->getId())));
      }
    }

    // À ce stade :
    // - Soit la requête est de type GET, donc le visiteur vient d'arriver sur la page et veut voir le formulaire
    // - Soit la requête est de type POST, mais le formulaire n'est pas valide, donc on l'affiche de nouveau

    return $this->render('SdzBlogBundle:Blog:ajouter.html.twig', array(
      'form' => $form->createView(),
    ));
  }

L'action « modifier » du contrôleur

Voici l'une des actions que vous deviez faire tout seuls. Ici pas de piège, il fallait juste penser à bien utiliser ArticleEditType et non ArticleType, car on est en mode édition.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

  public function modifierAction(Article $article)
  {
    // On utiliser le ArticleEditType
    $form = $this->createForm(new ArticleEditType(), $article);

    $request = $this->getRequest();

    if ($request->getMethod() == 'POST') {
      $form->bind($request);

      if ($form->isValid()) {
        // On enregistre l'article
        $em = $this->getDoctrine()->getManager();
        $em->persist($article);
        $em->flush();

        // On définit un message flash
        $this->get('session')->getFlashBag()->add('info', 'Article bien modifié');

        return $this->redirect($this->generateUrl('sdzblog_voir', array('id' => $article->getId())));
      }
    }

    return $this->render('SdzBlogBundle:Blog:modifier.html.twig', array(
      'form'    => $form->createView(),
      'article' => $article
    ));
  }

L'action « supprimer » du contrôleur

Enfin, voici l'action pour supprimer un article. On la protège derrière un formulaire presque vide. Je dis « presque », car le formulaire va automatiquement contenir un champ CSRF, c'est justement ce que nous recherchons en l'utilisant, pour éviter qu'une faille permette de faire supprimer un article. Vous trouverez plus d'informations sur la faille CSRF sur Wikipédia.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

  public function supprimerAction(Article $article)
  {
    // On crée un formulaire vide, qui ne contiendra que le champ CSRF
    // Cela permet de protéger la suppression d'article contre cette faille
    $form = $this->createFormBuilder()->getForm();

    $request = $this->getRequest();
    if ($request->getMethod() == 'POST') {
      $form->bind($request);

      if ($form->isValid()) {
        // On supprime l'article
        $em = $this->getDoctrine()->getManager();
        $em->remove($article);
        $em->flush();

        // On définit un message flash
        $this->get('session')->getFlashBag()->add('info', 'Article bien supprimé');

        // Puis on redirige vers l'accueil
        return $this->redirect($this->generateUrl('sdzblog_accueil'));
      }
    }

    // Si la requête est en GET, on affiche une page de confirmation avant de supprimer
    return $this->render('SdzBlogBundle:Blog:supprimer.html.twig', array(
      'article' => $article,
      'form'    => $form->createView()
    ));
  }

Je vous invite par la même occasion à faire la vue supprimer.html.twig. Voici ce que j'obtiens de mon côté :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{# src/Sdz/BlogBundle/Resources/views/Blog/supprimer.html.twig #}

{% extends "SdzBlogBundle::layout.html.twig" %}

{% block title %}
  Supprimer un article - {{ parent() }}
{% endblock %}

{% block sdzblog_body %}

  <h2>Supprimer un article</h2>

  <p>
    Etes-vous certain de vouloir supprimer l'article "{{ article.titre }}" ?
  </p>

  {# On met l'id de l'article dans la route de l'action du formulaire #}
  <form action="{{ path('sdzblog_supprimer', {'id': article.id}) }}" method="post">
    <a href="{{ path('sdzblog_voir', {'id': article.id}) }}" class="btn">
      <i class="icon-chevron-left"></i>
      Retour à l'article
    </a>
    <input type="submit" value="Supprimer" class="btn btn-danger" />
    {{ form_rest(form) }}
  </form>

{% endblock %}

Le rendu est celui de la figure suivante.

Confirmation de suppression

Pour conclure

Ce chapitre se termine ici. Son contenu est très imposant mais assez cohérent. Dans tous les cas, et plus encore pour ce chapitre, vous devez absolument vous entraîner en parallèle de votre lecture, pour bien assimiler et être sûrs de bien comprendre toutes les notions.

Mais bien entendu, vous ne pouvez pas vous arrêter en si bon chemin. Maintenant que vos formulaires sont opérationnels, il faut bien vérifier un peu ce que vos visiteurs vont y mettre comme données ! C'est l'objectif du prochain chapitre, qui traite de la validation des données, justement. Il vient compléter le chapitre actuel, continuez donc la lecture !


En résumé

  • Un formulaire se construit sur un objet existant, et son objectif est d'hydrater cet objet.
  • Un formulaire se construit grâce à un FormBuilder, et dans un fichier XxxType indépendant.
  • En développement, le rendu d'un formulaire se fait en une seule ligne grâce à la méthode {{ form_widget(form) }}.
  • Il est possible d'imbriquer les formulaires grâce aux XxxType.
  • Le type de champ collection affiche une liste de champs d'un certain type.
  • Le type de champ entity retourne une ou plusieurs entités.
  • Il est possible d'utiliser le mécanisme d'héritage pour créer des formulaires différents mais ayant la même base.
  • Le type de champ File permet l'upload de fichier, et se couple aux entités grâce aux évènements Doctrine.