Développez votre site web avec le framework Symfony2
Last updated on Thursday, October 23, 2014
  • 5 semaines
  • Moyen

Ce cours est visible gratuitement en ligne.

Videos available in this course

Paperback available in this course

Ce cours existe en eBook.

Certificate of achievement available at the end this course

Got it!

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 annonce, chaque candidature de notre plateforme, 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 !

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 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 une annonce va se baser sur l'objet Advert, objet que nous avons construit lors du chapitre précédent. Tout est cohérent.

Pour la suite de ce chapitre, nous allons utiliser notre objet Advert. 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) :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;

class Advert
{
  private $id;
  private $date;
  private $title;
  private $author;
  private $content;
  private $published = true;
  private $image;
  private $categories;
  private $applications;
  private $updatedAt;
  private $nbApplications = 0;
  private $slug;

  public function __construct()
  {
    $this->date         = new \Datetime();
    $this->categories   = new ArrayCollection();
    $this->applications = new ArrayCollection();
  }
  
  // … Les getters et setters
}
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 $advert->setAuthor('Alexandre'), $advert->setDate(new \Datetime()), etc., c'est hydrater l'objet Advert.

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 Advert, 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 Advert) ;

  • 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 addAction() de notre contrôleur Advert et modifiez-la comme suit :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use OC\PlatformBundle\Entity\Advert;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class AdvertController extends Controller
{
  public function addAction(Request $request)
  {
    // On crée un objet Advert
    $advert = new Advert();

    // On crée le FormBuilder grâce au service form factory
    $formBuilder = $this->get('form.factory')->createBuilder('form', $advert);

    // On ajoute les champs de l'entité que l'on veut à notre formulaire
    $formBuilder
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('content',   'textarea')
      ->add('author',    'text')
      ->add('published', 'checkbox')
      ->add('save',      'submit')
    ;
    // Pour l'instant, pas de candidatures, 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('OCPlatformBundle:Advert:add.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 $advert », puis : « Ajoute les champs date, title, contentauthor et published. » 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. D'une part, un formulaire n'est pas du tout obligé d'hydrater tous les attributs d'un objet. On pourrait très bien ne pas inclure le champ author dans notre formulaire. L'objet, lui, contient toujours l'attribut author, mais il ne sera juste pas hydraté par le formulaire (on peut le définir nous-même, sans le demander au visiteur par exemple). Bon, en l'occurrence, ce n'est pas le comportement que l'on veut (on va considérer l'auteur comme obligatoire pour une annonce), 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.

D'autre part, notez la présence d'un champ de type submit, que j'ai appelé save ici, qui va permettre de créer le bouton de soumission du formulaire. Ce champ n'a rien à voir avec l'objet, on dit qu'il n'est pas mappé avec celui-ci. Je l'ai ici ajouté au formulaire Symfony2, mais vous pouvez tout aussi bien ne pas le mettre ici et écrire à la main le bouton de soumission.

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 $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 Advert/form.html.twig et ajoutez cette ligne là où nous avions laissé un trou :

{# src/OC/PlatformBundle/Resources/views/Advert/form.html.twig #}

<h3>Formulaire d'annonce</h3>

<div class="well">
  {{ form(form) }}
</div>

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

Le formulaire HTML s'affiche bien
Le formulaire HTML s'affiche bien

Grâce à la fonction Twig {{ form() }}, 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.

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 « Published » 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. Regardez bien le code pour récupérer le formBuilder, on a passé notre object $advert en argument. Or, ces valeurs date et published sont définies à la création de l'objet, l'un dans le constructeur et l'autre dans la définition de l'attribut :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

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

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 $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.

Texte

Choix

Date et temps

Divers

Multiple

Caché

text
textarea
email
integer
money
number
password
percent
search
url

choice
entity
country
language
locale
timezone

date
datetime
time
birthday

checkbox
file
radio

collection
repeated

hidden
csrf

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.

Dans le cas d'une entité Doctrine c'est très simple, vous définissez les type de champ de formulaire pour correspondre au type  d'attribut définit avec l'annotation (ou en yaml/xml si vous utilisez un autre type de configuration). Par exemple, pour cette annotation :

<?php
/**
  * @ORM\Column(name="published", type="boolean")
  */
private $published = true;

Il nous faut donc un type de champ qui retourne un boolean, à savoir checkbox :

<?php
$formBuilder->add('published', 'checkbox');

Et ceci est valable pour tous vos attributs.

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. 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. Ces deux actions se font grâce à la méthode handleRequest() 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 et le formulaire attendent. 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 :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use OC\PlatformBundle\Entity\Advert;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class AdvertController extends Controller
{
  public function addAction(Request $request)
  {
    $advert = new Advert();

    // J'ai raccourci cette partie, car c'est plus rapide à écrire !
    $form = $this->get('form.factory')->createBuilder('form', $advert)
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('content',   'textarea')
      ->add('author',    'text')
      ->add('published', 'checkbox')
      ->add('save',      'submit')
      ->getForm()
    ;

    // On fait le lien Requête <-> Formulaire
    // À partir de maintenant, la variable $advert contient les valeurs entrées dans le formulaire par le visiteur
    $form->handleRequest($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 $advert dans la base de données, par exemple
      $em = $this->getDoctrine()->getManager();
      $em->persist($advert);
      $em->flush();

      $request->getSession()->getFlashBag()->add('notice', 'Annonce bien enregistrée.');

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

    // À ce stade, le formulaire n'est pas valide car :
    // - 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 contient des valeurs invalides, donc on l'affiche de nouveau
    return $this->render('OCPlatformBundle:Advert:add.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 (via la méthode isValid), il n'enregistre l'annonce que lorsque tout va bien.

Si vous l'avez bien testé, vous vous êtes rendu compte qu'on est obligés de cocher le champ published. Ce n'est pas tellement le comportement voulu, car on veut pouvoir enregistrer une annonce sans forcément la 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 :

<?php
$formBuilder->add('published', '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 cette 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 navigateur empêche la soumission du formulaire
Le navigateur empêche la soumission du formulaire

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'une annonce, 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 $advert, avant de le passer en argument à la méthode createFormBuilder, comme ceci :

<?php
// On crée une nouvelle annonce
$advert = new Advert;

// 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
$advert->setDate(new \Datetime());

// Et on construit le formBuilder avec cette instance d'annonce
$formBuilder = $this->get('form.factory')->createBuilder('form', $advert);

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

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

<?php
// Récupération d'une annonce déjà existante, d'id $id.
$advert = $this->getDoctrine()
  ->getManager()
  ->getRepository('OCPlatformBundle:Advert')
  ->find($id)
;

// Et on construit le formBuilder avec cette instance de l'annonce, comme précédemment
$formBuilder = $this->get('form.factory')->createBuilder('form', $advert);

// 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, ici adapté pour le CSS Bootstrap de Twitter :

{# src/OC/PlatformBundle/Resources/views/Advert/form.html.twig #}

<h3>Formulaire d'annonce</h3>

<div class="well">
  {{ form_start(form, {'attr': {'class': 'form-horizontal'}}) }}

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

    <div class="form-group">
      {# Génération du label. #}
      {{ form_label(form.title, "Titre de l'annonce", {'label_attr': {'class': 'col-sm-3 control-label'}}) }}

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

      <div class="col-sm-4">
        {# Génération de l'input. #}
        {{ form_widget(form.title, {'attr': {'class': 'form-control'}}) }}
      </div>
    </div>

    {# Idem pour un autre champ. #}
    <div class="form-group">
      {{ form_label(form.content, "Contenu de l'annonce", {'label_attr': {'class': 'col-sm-3 control-label'}}) }}
      {{ form_errors(form.title) }}
      <div class="col-sm-4">
        {{ form_widget(form.content, {'attr': {'class': 'form-control'}}) }}
      </div>
    </div>

  {# Génération du label + error + widget pour un champ #}
  {{ form_row(form.date) }}

  {{ form_row(form.author) }}
  {{ form_row(form.published) }}

  {# Pour le bouton, pas de label ni d'erreur, on affiche juste le widget #}
  {{ form_widget(form.save, {'attr': {'class': 'btn btn-primary'}}) }}

  {# Génération automatique des champs pas encore écrits.
     Dans cet exemple, ce serait le champ CSRF (géré automatiquement par Symfony !)
     et tous les champs cachés (type « hidden »). #}
  {{ form_rest(form) }}
  
  {# Fermeture de la balise <form> du formulaire HTML #}
  {{ form_end(form) }}
</div>

Comme vous pouvez le voir sur la figure suivante, cela rend tout de suite mieux lorsqu'on personnalise un peu le HTML et le CSS ! Faite l'exercice de personnaliser les champs restants : date, author et published.

Nouvelle présentation du formulaire
Nouvelle présentation du formulaire pour les champs titre et contenu

Revenons rapidement sur les fonctions Twig que j'ai utilisées :

  • form_start() affiche la balise d'ouverture du formulaire HTML, soit <form>. Il faut passer la variable du formulaire en premier argument, et les paramètres en deuxième argument. L'index attr des paramètres, et cela s'appliquera à toutes les fonctions suivantes, représente les attributs à ajouter à la balise générée, ici le <form>. Il nous permet d'appliquer une classe CSS au formulaire, ici form-horizontal.

  • form_errors() affiche les erreurs attachées au champ donné en argument. Nous verrons les erreurs de validation dans le prochain chapitre.

  • form_label() affiche le label HTML du champ donné en argument. Le deuxième argument est le contenu du label.

  • form_widget() affiche le champ HTML lui-même (que ce soit <input>, <select>, etc.).

  • form_row() affiche le label, les erreurs et le champ.

  • form_rest() affiche tous les champs manquants du formulaire (dans notre cas, juste le champ CSRF puisque nous avons déjà affiché à la main tous les autres champs).

  • form_end() affiche la balise de fermeture du formulaire HTML, soit </form>.

L'habillage des formulaires est un sujet complexe : personnalisation d'un champ en particulier, de tous les champs d'un même type, etc. Toutes les fonctions Twig que nous avons vu sont également personnalisable. Je vous invite vivement à consulter la documentation à ce sujet qui vous permettra d'aller beaucoup plus loin. 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. Ce champ s'appelle _token dans vos formulaires, vous pouvez le voir si vous affichez la source HTML.

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 AdvertType (par convention).

Définition du formulaire dans AdvertType

AdvertType 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 :

php app/console doctrine:generate:form OCPlatformBundle:Advert

Comme vous pouvez le voir c'est une commande Doctrine, car c'est lui qui a toutes les informations sur notre objet Advert. Maintenant, vous pouvez aller voir le résultat dans le fichier src/OC/PlatformBundle/Form/AdvertType.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 :

<?php
// src/OC/PlatformBundle/Form/AdvertType.php

namespace OC\PlatformBundle\Form;

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

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('author',    'text')
      ->add('content',   'textarea')
      ->add('published', 'checkbox', array('required' => false))
      ->add('save',      'submit')
    ;
  }

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\Advert'
    ));
  }

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

Comme vous pouvez le voir, on n'a fait que déplacer la construction du formulaire, du contrôleur à une classe externe. Cet AdvertType 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 AdvertType. 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 AdvertType, la construction du formulaire côté contrôleur s'effectue grâce à la méthode create() du service form.factory  (et non plus createBuilder()). Cette méthode utilise le composant Form pour construire un formulaire à partir du AdvertType 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 :

<?php
// Dans le contrôleur

$advert = new Advert;
$form = $this->get('form.factory')->create(new AdvertType, $advert);

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 ! La création du formulaire est réduit à une seule ligne.

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

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use OC\PlatformBundle\Entity\Advert;
use OC\PlatformBundle\Form\AdvertType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class AdvertController extends Controller
{
  public function addAction(Request $request)
  {
    $advert = new Advert();
    $form = $this->get('form.factory')->create(new AdvertType(), $advert);

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

      $request->getSession()->getFlashBag()->add('notice', 'Annonce bien enregistrée.');

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

    return $this->render('OCPlatformBundle:Advert:add.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'annonce 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 :

php app/console doctrine:generate:form OCPlatformBundle:Image

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

<?php
// src/OC/PlatformBundle/Form/ImageType.php

namespace OC\PlatformBundle\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' => 'OC\PlatformBundle\Entity\Image'
    ));
  }

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

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 Advert 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'Advert 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, Advert) 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 AdvertType et ajoutez un champ image (du nom de la propriété de notre entité), de type… ImageType, bien sûr !

<?php
// src/OC/PlatformBundle/Form/AdvertType.php

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('author',    'text')
      ->add('content',   'textarea')
      ->add('published', 'checkbox', array('required' => false))
      ->add('image',      new ImageType()) // Ajoutez cette ligne
      ->add('save',      'submit')
    ;
  }

  // …
}

C'est tout ! Allez sur la page d'ajout : /platform/add. 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 ?

Formulaire
Formulaire

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

D'un côté, nous avons l'objet Advert 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 la 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 AdvertType, 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.

Je fais un petit apparté Doctrine sur une erreur qui arrive souvent. Si jamais lorsque vous validez votre formulaire vous avez une erreur de ce type :

A new entity was found through the relationship 'OC\PlatformBundle\Entity\Advert#image'
that was not configured to cascade persist operations for entity:
OC\PlatformBundle\Entity\Image@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
'OC\PlatformBundle\Entity\Image#__toString()' to get a clue.

… c'est que Doctrine ne sait pas quoi faire avec l'entité Image qui est dans l'entité Advert, car vous ne lui avez pas dit de persister cette entité. Si vous avez bien persisté Advert, vous n'avez rien précisé pour Image et Doctrine est un peu perdu. 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($advert->getImage()) dans le contrôleur, avant le flush() ;

  • Soit, et c'est mieux, vous ajoutez une option à l'annotation @ORM\OneToOne dans l'entité Advert, ce que nous avons fait si vous suivez ce cours depuis le début, comme ceci :

/**
 * @ORM\OneToOne(targetEntity="OC\PlatformBundle\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 CategoryType dans le AdvertType principal. Attention, cela veut dire qu'à chaque ajout d'Advert, on aura la possibilité de créer de nouvelles Category. Ce n'est pas le comportement classique qui consiste plutôt à sélectionner des Category 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 CategoryType grâce au générateur :

php app/console doctrine:generate:form OCPlatformBundle:Category

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

<?php
// src/OC/PlatformBundle/Form/CategoryType.php

namespace OC\PlatformBundle\Form;

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

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

  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\Category'
    ));
  }

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

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

<?php
// src/OC/PlatformBundle/Form/AdvertType.php

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('author',    'text')
      ->add('content',   'textarea')
      ->add('published', '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 CategoryType(),
        'allow_add'    => true,
        'allow_delete' => true
      ))
      ->add('save',      'submit')
    ;
  }

On a ici utilisé le type de champ collection, qui permet en réalité de construire une collection (une liste) de n'importe quoi. On précise ce "n'importe quoi" grâce à l'option type : le formluaire sait donc qu'il doit créer une liste de CategoryType, 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 Category qui sont déjà liées à l'Advert.

Assez parlé, testons dès maintenant le résultat. Pour cela, actualisez la page d'ajout d'un annonce. Ah mince, le mot « Categories » est bien inscrit, mais il n'y a rien en dessous. Ce n'est pas un bug, c'est bien voulu par Symfony2. En effet, comme l'entité Advert liée 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 en ajouter à l'infini, et même en 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 :

<div
  id="oc_platformbundle_advert_categories"
  data-prototype="&lt;div&gt;&lt;label class=&quot;required&quot;&gt;__name__label__&lt;/label&gt;&lt;div id=&quot;oc_platformbundle_advert_categories___name__&quot;&gt;&lt;div&gt;                &lt;label for=&quot;oc_platformbundle_advert_categories___name___name&quot; class=&quot;required&quot;&gt;Name&lt;/label&gt;&lt;input type=&quot;text&quot; id=&quot;oc_platformbundle_advert_categories___name___name&quot; name=&quot;oc_platformbundle_advert[categories][__name__][name]&quot; required=&quot;required&quot; /&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;"
>
</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 CategoryType. Voici son contenu sans les entités HTML :

<div>
  <label class="required">__name__label__</label>
  <div id="oc_platformbundle_adverttype_categories___name__">
    <div>
      <label for="oc_platformbundle_adverttype_categories___name___nom" class="required">Name</label>
      <input type="text"
             id="oc_platformbundle_adverttype_categories___name___name"
             name="oc_platformbundle_adverttype[categories][__name__][name]"
             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 name compris dans CategoryType, en fait. Si ce formulaire avait d'autres champs en plus de « name », 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 parle de template car vous pouvez noter la présence de "__name__" à plusieurs reprises. C'est une sorte de variable que nous devrons remplacer par des valeurs différentes à chaque fois qu'on ajoute le champ. En effet, un champ de formulaire HTML doit avoir un nom unique, donc si on souhaite afficher plusieurs champs pour nos catégories, il faut leur donner des noms différents.

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 CategoryType 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 :

{# src/OC/PlatformBundle/Resources/views/Advert/form.html.twig #}

{# Le formulaire reste inchangé #}
<div class="well">
  {{ form(form) }}
</div>

{# On charge la bibliothèque jQuery. Ici, je la prends depuis le CDN google
   mais si vous l'avez en local, changez simplement l'adresse. #}
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.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#oc_platformbundle_advert_categories');

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

    // On ajoute un nouveau champ à chaque clic sur le lien d'ajout.
    $addLink.click(function(e) {
      addCategory($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 automatiquement s'il n'en existe pas déjà un (cas d'une nouvelle annonce par exemple).
    if (index == 0) {
      addCategory($container);
    } else {
      // Pour chaque catégorie déjà existante, on ajoute un lien de suppression
      $container.children('div').each(function() {
        addDeleteLink($(this));
      });
    }

    // La fonction qui ajoute un formulaire Categorie
    function addCategory($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
      addDeleteLink($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 addDeleteLink($prototype) {
      // Création du lien
      $deleteLink = $('<a href="#" class="btn btn-danger">Supprimer</a>');

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

      // Ajout du listener sur le clic du lien
      $deleteLink.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 !

Formulaire opérationnel
Formulaire opérationnel

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

Pour bien visualiser les données que votre formulaire envoie, n'hésitez pas à utiliser le Profiler en cliquant sur la toolbar. Dans l'onglet Request, voici ce que vous pouvez trouver :

Les données envoyées par le formulaire
Les données envoyées par le formulaire

Notez déjà que toutes les données du formulaire sont contenues dans une même variable. En vieux PHP, tout votre formulaire serait contenu dans $_POST['oc_platformbundle_advert']. Notez ensuite comment le __name__ du prototype a été remplacé par notre Javascript : simplement par un chiffre commençant par 0. Ainsi, tous nos champs de catégories ont un nom différent : 0, 1, etc.

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'une annonce 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 de champ 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 :

<?php
// src/OC/PlatformBundle/Form/AdvertType.php

// ...

$builder->add('categories', 'entity', array(
  'class'    => 'OCPlatformBundle:Category',
  'property' => 'name',
  'multiple' => true
));

Rafraîchissez le formulaire et admirez :

On peut ainsi sélectionner une ou plusieurs catégories
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 Category, 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é name, c'est donc via leur nom qu'on liste les catégories dans le select. Sachez que vous pouvez également renseigner display (ou autre !) et créer le getter associé (à savoir getDisplay()) dans l'entité Category, 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é Category) et votre entité Advert (qui attend une liste d'entités Category) 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 :

Les quatre formes
Les quatre formes

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

L'option query_builder

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 annonces publiées. Tout est prévu : il faut jouer avec l'option query_builder.

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, celui qui ne retourne que les annonces publiées :

<?php
// src/OC/PlatformBundle/Entity/AdvertRepository.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\EntityRepository;

class AdvertRepository extends EntityRepository
{
  public function getPublishedQueryBuilder()
  {
    return $this
      ->createQueryBuilder('a')
      ->where('a.published = :published')
      ->setParameter('published', true)
    ;
  }
}

Notez bien que cette méthode retourne un QueryBuilder, et non une Query ou les résultats d'une requête comme on a l'habitude de faire.

Il ne reste maintenant qu'à faire appel à cette méthode depuis l'option query_builder grâce à une closure dont l'argument est le repository, comme ceci :

<?php
// Dans un XxxType

use OC\PlatformBundle\Entity\AdvertRepository;

// ...

$builder->add('advert', 'entity', array(
  'class'         => 'OCPlatformBundle:Advert',
  'property'      => 'title',
  'query_builder' => function(AdvertRepository $repo) {
    return $repo->getPublishedQueryBuilder();
  }
));

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

<?php
'query_builder' => function(AdvertRepository $repo) use($myVar) {
  return $repo->getPublishedQueryBuilder($myVar);
}

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 de simples objets, mais le composant Form a un mécanisme d'héritage dynamique un peu particulier.

L'utilité de l'héritage 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 Advert. Imaginons que le formulaire d'ajout comprenne tous les champs, mais que pour l'édition il soit impossible de modifier la date par exemple. Bien sûr, les applications de ce mécanisme vont bien au-delà.

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

On va donc faire hériter AdvertEditType de AdvertType. Le processus est le suivant :

  1. Copiez-collez le fichier AdvertType.php et renommez la copie en AdvertEditType.php ;

  2. Modifiez le nom de la classe, ainsi que le nom du formulaire dans la méthode getName() ;

  3. Ajouter une méthode getParent  qui retourne une instance du formulaire parent, AdvertType  ;

  4. Remplacez la définition manuelle de tous les champs (les $builder->add()) par une simple ligne pour supprimer le champ date: $builder->remove('date') ;

  5. Enfin, supprimez la méthode setDefaultOptions() qu'il ne sert à rien d'hériter dans notre cas.

Voici ce que cela donne :

<?php
// src/OC/PlatformBundle/Form/AdvertEditType.php

namespace OC\PlatformBundle\Form;

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

class AdvertEditType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder->remove('date');
  }

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

  public function getParent()
  {
    return new AdvertType();
  }
}

Concrètement, la différence entre l'héritage natif PHP et ce qu'on appelle l'héritage de formulaires réside dans la méthode getParent() qui retourne le formulaire parent. Ainsi, lors de la construction de ce formulaire, le composant Form exécutera d'abord la méthode buildForm du formulaire parent, ici AdvertType, avant d'exécuter celle-ci qui vient supprimer le champ date. Au même titre que les type de champs dans la création du formulaire, la valeur du parent peut très bien être "text" (ou autre) : votre champ hériterait donc du champ texte de base.

Maintenant, si vous utilisez le formulaire AdvertEditType, vous ne pourrez pas modifier l'attribut date de l'entité Advert. Objectif atteint ! Prenez le temps de tester ce nouveau formulaire depuis l'action editAction() de notre site, c'est un bon entraînement.

À 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 l'héritage de formulaires 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 AdvertType, c'est la modulation d'un formulaire en fonction de certains paramètres.

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

  • Si l'annonce n'est pas encore publiée, on peut modifier sa valeur de publication lorsqu'on modifie l'annonce;

  • Si l'annonce est déjà publiée, on ne peut plus modifier sa valeur de publication lorsqu'on modifie l'annonce.

C'est un exemple simple, retenez l'idée derrière qui est de construire différemment 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 Advert qui sert de base au formulaire ! Comment savoir si l'annonce est déjà publiée ou non ?

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() :

<?php
// src/OC/PlatformBundle/Form/AdvertType.php

namespace OC\PlatformBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
// N'oubliez pas ces deux use !
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    // Ajoutez ici tous vos champs sauf le champ published
    $builder = ...;

    // On ajoute une fonction qui va écouter un évènement
    $builder->addEventListener(
      FormEvents::PRE_SET_DATA,    // 1er argument : L'évènement qui nous intéresse : ici, PRE_SET_DATA
      function(FormEvent $event) { // 2e argument : La fonction à exécuter lorsque l'évènement est déclenché
        // On récupère notre objet Advert sous-jacent
        $advert = $event->getData();

        // Cette condition est importante, on en reparle plus loin
        if (null === $advert) {
          return; // On sort de la fonction sans rien faire lorsque $advert vaut null
        }

        if (!$advert->getPublished() || null === $advert->getId()) {
          // Si l'annonce n'est pas publiée, ou si elle n'existe pas encore en base (id est null),
          // alors on ajoute le champ published
          $event->getForm()->add('published', 'checkbox', array('required' => false));
        } else {
          // Sinon, on le supprime
          $event->getForm()->remove('published');
        }
      }
    );
  }
}

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'Advert nous permet d'utiliser les valeurs qu'il contient, chose qu'on ne pouvait 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 :

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

Avec ce code, on ne crée qu'un seul objet CategoryType, or celui-ci sera utilisé pour ajouter plusieurs catégories différentes. Il est donc normal de ne pas avoir accès à l'objet $category lors de la construction du formulaire, autrement dit la construction de l'objet CategoryType. 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 d'un nouvel objet Category.

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 combobox : 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 de formulaire ne retourne pas du texte, mais une instance de la classe UploadedFile. Or nous allons stocker dans la base de données seulement l'adresse du fichier, donc du texte pur. 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 :

<?php
// src/OC/PlatformBundle/Entity/Image

namespace OC\PlatformBundle\Entity;

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

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\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;
  
  public function getFile()
  {
    return $this->file;
  }

  public function setFile(UploadedFile $file = null)
  {
    $this->file = $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 de son image. Maintenant on veut plutôt 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 :

<?php
// src/OC/PlatformBundle/Form/ImageType.php

namespace OC\PlatformBundle\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
Champ pour envoyer un fichier

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 :

<?php
// src/OC/PlatformBundle/Entity/Image

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

    // On récupère 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 (relatif au répertoire /web donc)
    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 un appel manuel à cette méthode dans addAction, une fois que le formulaire est valide :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

// …

  public function addAction(Request $request)
  {
    $advert = new Advert();
    $form = $this->createForm(new AdvertType(), $advert);

    if ($form->handleRequest($request)->isValid()) {
      // Ajoutez cette ligne :
      // c'est elle qui déplace l'image là où on veut les stocker
      $advert->getImage()->upload();

      // Le reste de la méthode reste inchangé
      $em = $this->getDoctrine()->getManager();
      $em->persist($advert);
      $em->flush();
      
      // ...
    }
    
    // ...
  }

// …

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 dans la base de données 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 par exemple. En effet, comme le nom du fichier dépend 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é, dans un attribut temporaire.

  • 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 $tempFilename) pour la sauvegarde du nom du fichier. Au final, voici ce que cela donne :

<?php
// src/OC/PlatformBundle/Entity/Image

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\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.

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

Vous devez également modifier la vue view.html.twig qui affiche les images. Nous avions utilisé {{ advert.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 :

<img
  src="{{ asset(advert.image.uploadDir ~ '/' ~ advert.image.id ~ '.' ~ advert.image.url) }}"
  alt="{{ advert.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() :

<?php
// src/OC/PlatformBundle/Entity/Image

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

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

<img 
  src="{{ asset(advert.image.webPath) }}"
  alt="{{ advert.image.alt }}"
/>

Application : les formulaires de notre site

Théorie

Nous avons déjà généré presque tous les formulaires utiles pour notre site, 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.

AdvertType
<?php
// src/OC/PlatformBundle/Form/AdvertType.php

namespace OC\PlatformBundle\Form;

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

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      'date')
      ->add('title',     'text')
      ->add('author',    'text')
      ->add('content',   'textarea')
      ->add('image',      new ImageType())
      ->add('categories', 'entity', array(
        'class'    => 'OCPlatformBundle:Category',
        'property' => 'name',
        'multiple' => true,
        'expanded' => false
      ))
      ->add('save',      'submit')
    ;

    // On ajoute une fonction qui va écouter l'évènement PRE_SET_DATA
    $builder->addEventListener(
      FormEvents::PRE_SET_DATA,
      function(FormEvent $event) {
        // On récupère notre objet Advert sous-jacent
        $advert = $event->getData();

        if (null === $advert) {
          return;
        }

        if (!$advert->getPublished() || null === $advert->getId()) {
          $event->getForm()->add('published', 'checkbox', array('required' => false));
        } else {
          $event->getForm()->remove('published');
        }
      }
    );
  }

  /**
   * @param OptionsResolverInterface $resolver
   */
  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\Advert'
    ));
  }

  /**
   * @return string
   */
  public function getName()
  {
    return 'oc_platformbundle_advert';
  }
}
AdvertEditType
<?php
// src/OC/PlatformBundle/Form/AdvertEditType.php

namespace OC\PlatformBundle\Form;

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

class AdvertEditType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder->remove('date');
  }

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

  public function getParent()
  {
    return new AdvertType();
  }
}
ImageType
<?php
// src/OC/PlatformBundle/Form/ImageType.php

namespace OC\PlatformBundle\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' => 'OC\PlatformBundle\Entity\Image'
    ));
  }

  public function getName()
  {
    return 'oc_platformbundle_image';
  }
}
L'action « ajouter » du contrôleur

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

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

  public function addAction(Request $request)
  {
    $advert = new Advert();
    $form = $this->createForm(new AdvertType(), $advert);

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

      $request->getSession()->getFlashBag()->add('notice', 'Annonce bien enregistrée.');

      return $this->redirect($this->generateUrl('oc_platform_view', array('id' => $advert->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('OCPlatformBundle:Advert:add.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 AdvertEditType et non AdvertType, car on est en mode édition.

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

  public function editAction($id, Request $request)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em->getRepository('OCPlatformBundle:Advert')->find($id);

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

    $form = $this->createForm(new AdvertEditType(), $advert);

    if ($form->handleRequest($request)->isValid()) {
      // Inutile de persister ici, Doctrine connait déjà notre annonce
      $em->flush();

      $request->getSession()->getFlashBag()->add('notice', 'Annonce bien modifiée.');

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

    return $this->render('OCPlatformBundle:Advert:edit.html.twig', array(
      'form'   => $form->createView(),
      'advert' => $advert // Je passe également l'annonce à la vue si jamais elle veut l'afficher
    ));
  }
L'action « supprimer » du contrôleur

Enfin, voici l'action pour supprimer une annonce. 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 une annonce. Vous trouverez plus d'informations sur la faille CSRF sur Wikipédia.

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

  public function deleteAction($id, Request $request)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em->getRepository('OCPlatformBundle:Advert')->find($id);

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

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

    if ($form->handleRequest($request)->isValid()) {
      $em->remove($advert);
      $em->flush();

      $request->getSession()->getFlashBag()->add('info', "L'annonce a bien été supprimée.");

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

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

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

{# src/OC/PlatformBundle/Resources/views/Advert/delete.html.twig #}

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

{% block title %}
  Supprimer une annonce - {{ parent() }}
{% endblock %}

{% block ocplatform_body %}

  <h2>Supprimer une annonce</h2>

  <p>
    Etes-vous certain de vouloir supprimer l'annonce "{{ advert.title }}" ?
  </p>

  {# On met l'id de l'annonce dans la route de l'action du formulaire #}
  <form action="{{ path('oc_platform_delete', {'id': advert.id}) }}" method="post">
    <a href="{{ path('oc_platform_view', {'id': advert.id}) }}" class="btn btn-default">
      <i class="glyphicon glyphicon-chevron-left"></i>
      Retour à l'annonce
    </a>
    {# Ici j'ai écrit le bouton de soumission à la main #}
    <input type="submit" value="Supprimer" class="btn btn-danger" />
    {# Ceci va générer le champ CSRF #}
    {{ form_rest(form) }}
  </form>

{% endblock %}

Le rendu est celui de la figure suivante.

Confirmation de suppression
Confirmation de suppression

Pour conclure

Ce chapitre se termine ici. Son contenu est très imposant mais 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(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.

Example of certificate of achievement
Example of certificate of achievement

Le téléchargement des vidéos de nos cours est accessible pour les membres Premium. Vous pouvez toutefois les visionner en streaming gratuitement.