• 30 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

Ce cours est en vidéo.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

J'ai tout compris !

Créer des formulaires avec Symfony

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

‌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 composantFormde Symfony. 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 composantFormde Symfony arrive à la rescousse !

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

La vision Symfony 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'objetAdvert, objet que nous avons construit lors de la partie précédente. Tout est cohérent.

Pour la suite de ce chapitre, nous allons utiliser notre objetAdvert. 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'objetAdvert.

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 objetAdvert, envoyer un e-mail dans le cas d'un objetContact, 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 objetAdvert) ;

  • Un moyen pour construire un formulaire à partir de cet objet, unFormBuilder, « constructeur de formulaire » en français.

Pour faire nos tests, placez-vous dans l'actionaddAction()de notre contrôleurAdvert 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;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

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(FormType::class, $advert);

    // On ajoute les champs de l'entité que l'on veut à notre formulaire
    $formBuilder
      ->add('date',      DateType::class)
      ->add('title',     TextType::class)
      ->add('content',   TextareaType::class)
      ->add('author',    TextType::class)
      ->add('published', CheckboxType::class)
      ->add('save',      SubmitType::class)
    ;
    // 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 leFormBuilder. 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 champsdate,title,contentauthor etpublished. » 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 champauthor dans notre formulaire. L'objet, lui, contient toujours l'attributauthor, mais il ne sera juste pas hydraté par le formulaire (on pourrait 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 champid: comme il sera rempli automatiquement par Doctrine (grâce à l'auto-incrémentation), le formulaire n'a pas besoin de remplir cet attribut.

Notez également le deuxième argument de chaque méthodeadd, il s'agit du type de champ que l'on veut. Vous pouvez le voir ici, on a un type pour les dates, un autre pour une checkbox, etc. Nous verrons la liste exhaustive des types plus bas. Chaque type est représenté par une classe différente, et ce deuxième argument attend le nom de la classe du type utilisé. Nous faisons donc appel à la constanteclass de l'objet, une constante disponible depuis PHP5.5 qui contient simplement le nom de la classe. Par exemple, au lieu deTextType::class, nous aurions pu mettre'Symfony\Component\Form\Extension\Core\Type\TextType', les deux sont strictement équivalents.

D'autre part, notez la présence d'un champ de typeSubmitType, 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 Symfony, 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$formgé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 : Symfony nous permet d'afficher un formulaire simple en une seule ligne HTML ! Si, si : rendez-vous dans la vueAdvert/form.html.twiget 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 : /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 juste 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 Symfony tout est cohérent. Regardez bien le code pour récupérer leformBuilder, 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()duFormBuilder. 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 » commetext,passwordouselect. Il faut comprendre « type sémantique ». Par exemple, le typeDateType que l'on a utilisé affiche trois champsselectà la suite pour choisir le jour, le mois et l'année. Il existe aussi un typeTimezoneTypepour 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, Symfony 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é

TextType
TextareaType
EmailType
IntegerType
MoneyType
NumberType
PasswordType
PercentType
SearchType
UrlType

RangeType

ChoiceType
EntityType
CountryType
LanguageType
LocaleType
TimezoneType

CurrencyType

DateType
DatetimeType
TimeType
BirthdayType

CheckboxType
FileType
RadioType

CollectionType
RepeatedType

HiddenType
CsrfType

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

<?php
$formBuilder->add('published', CheckboxType::class);

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 boutonsubmitdu 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éthodeisValid()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\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;

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

    // J'ai raccourci cette partie, car c'est plus rapide à écrire !
    $form = $this->get('form.factory')->createBuilder(FormType::class, $advert)
      ->add('date',      DateType::class)
      ->add('title',     TextType::class)
      ->add('content',   TextareaType::class)
      ->add('author',    TextType::class)
      ->add('published', CheckboxType::class, array('required' => false))
      ->add('save',      SubmitType::class)
      ->getForm()
    ;

    // Si la requête est en POST
    if ($request->isMethod('POST')) {
      // 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 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->redirectToRoute('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 Symfony. Vous voyez que ce formulaire gère déjà très bien les erreurs (via la méthodeisValid), 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 champpublished. 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'optionrequiredàfalse, comme suit :

<?php
$formBuilder->add('published', CheckboxType::class, 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 optionrequiredà 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'attributrequired="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éthodecreateFormBuilder, 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(FormType::class, $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 la 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(FormType::class, $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.

Pour afficher les formulaires, Symfony utilise différents thèmes, qui sont en fait des vues pour chaque type de champ : une vue pour le champ text, une vue pour un champ select, etc. Vous serez d'accord avec moi, le thème par défaut que nous utilisons pour l'instant n'est vraiment pas sexy. Heureusement, depuis quelques temps, Symfony contient un thème adapté au framework CSS Bootstrap. Pour l'utiliser, vous devez ajouter l'optionform_themes à la sectiontwig dans votreconfig.yml, comme ceci :

# app/config/config.yml

twig:
    form_themes:
        - 'bootstrap_3_layout.html.twig'

C'est tout ce qu'il faut pour utiliser ce nouveau thème. Actualisez la page et vous aurez le résultat que je vous présente à la figure suivante.

Notre fomulaire est plus sexy avec Bootstrap !
Notre fomulaire est plus sexy avec Bootstrap !

Impressionnant n'est-ce pas ?

Mais utiliser ce nouveau thème ne suffit pas dans bien des cas, il vous faut un moyen d'afficher de façon plus flexible vos champs au sein du formulaire. 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 :

{# 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) }}

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

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

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

    <div class="col-sm-10">
      {# 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-2 control-label'}}) }}
    {{ form_errors(form.content) }}
    <div class="col-sm-10">
      {{ form_widget(form.content, {'attr': {'class': 'form-control'}}) }}
    </div>
  </div>

  {{ 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>

Si vous actualisez : aucun changement ! C'est parce que j'ai repris la structure exact de bootstrap, qui était en réalité déjà utilisée par le thème qu'on a changé plus haut. Pas de changement visuel donc, mais juste de quoi vous montrer comment afficher un à un les différents éléments du formulaire : label, erreurs, champ, etc.

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 HTML 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 en même temps, en respectant la vue définit dans le thème du formulaire que vous utilisez.

  • 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 vues sont également personnalisables. 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 Symfony 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 (il est généré par la méthodeform_rest() , donc à la fin de votre formulaire).

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 champdateavec ces trois balises<select>pour sélectionner le jour, le mois et l'année. Vous préféreriez un jolidatepickeren 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. Il y a beaucoup de syntaxe à connaître je vous l'accorde, mais au final rien de vraiment complexe, et notre formulaire 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éeAdvertType(par convention).

Définition du formulaire dansAdvertType

AdvertTypen'est pas notre formulaire. Comme tout à l'heure, c'est notre constructeur de formulaire. Par convention, on va mettre tous nosxxxType.phpdans le répertoireFormdu bundle. En fait, on va encore utiliser le générateur ici, qui sait générer lesFormTypepour nous, et vous verrez qu'on y gagne !

Exécutez donc la commande suivante :

php bin/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 objetAdvert. Maintenant, vous pouvez aller voir le résultat dans le fichiersrc/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 composantFormva 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

namespace OC\PlatformBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder
      ->add('date',      DateTimeType::class)
      ->add('title',     TextType::class)
      ->add('author',    TextType::class)
      ->add('content',   TextareaType::class)
      ->add('published', CheckboxType::class, array('required' => false))
      ->add('save',      SubmitType::class);
  }

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

Comme vous pouvez le voir, on n'a fait que déplacer la construction du formulaire, du contrôleur à une classe externe. CetAdvertType 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êmeAdvertType. Fini le copier-coller ! Voici la réutilisabilité. ;)

Rappelez-vous également, un formulaire se construit autour d'un objet. Ici, on a indiqué à Symfony quelle était la classe de cet objet grâce à la méthodeconfigureDefaults(), dans laquelle on a défini l'optiondata_class.

Le contrôleur épuré

Avec cetAdvertType, la construction du formulaire côté contrôleur s'effectue grâce à la méthodecreate()du serviceform.factory  (et non pluscreateBuilder()). Cette méthode utilise le composantFormpour construire un formulaire à partir duAdvertType::class passé en argument. On utilise le même mécanisme qu'avec les type de champ natifs. Enfin, 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(AdvertType::class, $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 Symfony :

<?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(AdvertType::class, $advert);

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

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

      return $this->redirectToRoute('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, votreClientest lié à uneAdresse, 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 une adresse puis un client, 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 bin/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\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

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

  public function configureOptions(OptionsResolver $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\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 notreAdvert avec une seuleImage.

  2. Avec une relation multiple, où l'on imbrique plusieurs fois le sous-formulaire dans le formulaire principal. C'est le cas d'unClientqui pourrait enregistrer plusieursAdresse.

Relation simple : imbriquer un seul formulaire

C'est le cas le plus courant, qui correspond à notre exemple de l'Advert et de sonImage. 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 dansAdvertTypeet ajoutez un champimage(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',      DateTimeType::class)
      ->add('title',     TextType::class)
      ->add('author',    TextType::class)
      ->add('content',   TextareaType::class)
      ->add('published', CheckboxType::class, array('required' => false))
      ->add('image',     ImageType::class) // Ajoutez cette ligne
      ->add('save',      SubmitType::class);
  }
  
  // ...
}

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 ?

Les champs pour l'image apparaissent
Les champs pour l'image apparaissent

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

D'un côté, nous avons l'objet Advert qui possède un attributimage. Cet attributimage contient, lui, un objetImage. Il ne peut pas contenir autre chose, à cause du setter associé : celui-ci force l'argument à être un objet de la classeImage.

L'objectif du formulaire est donc de venir injecter dans cet attributimageun objetImage, et pas autre chose ! On l'a vu au début de ce chapitre, un formulaire de typeXxxTyperetourne un objet de classeXxx (pour être précis, un objet de classe défini dans l'optiondata_class de la méthodeconfigureOptions()). Il est donc tout à fait logique de mettre dansAdvertType, un champimagede typeImageType.

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éImagequi 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é pourImage et Doctrine est un peu perdu. Pour corriger l'erreur, il faut dire à Doctrine de persister cet objetImage , suivez simplement les indications du message d'erreur :

  • Soit vous ajoutez manuellement un$em->persist($advert->getImage())dans le contrôleur, avant leflush();

  • Soit, et c'est mieux, vous ajoutez une option à l'annotation@ORM\OneToOnedans 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;

Soumettez le formulaire, vous verrez que l'annonce et son image sont enregistrés en base de données, ce qui veut dire que notre formulaire a bien hydraté nos deux entités.

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 plusieursCategoryTypedans leAdvertTypeprincipal. Attention, cela veut dire qu'à chaque ajout d'Advert, on aura la possibilité de créer de nouvellesCategory. Ce n'est pas le comportement classique qui consiste plutôt à sélectionner desCategory 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 formulaireCategoryTypegrâce au générateur :

php bin/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\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

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

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

Maintenant, il faut rajouter le champcategoriesdans leAdvertType. Il faut pour cela utiliser le typecollectionet 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',      DateTimeType::class)
      ->add('title',     TextType::class)
      ->add('author',    TextType::class)
      ->add('content',   TextareaType::class)
      ->add('published', CheckboxType::class, array('required' => false))
      ->add('image',     ImageType::class)
      /*
       * Rappel :
       ** - 1er argument : nom du champ, ici « categories », car c'est le nom de l'attribut
       ** - 2e argument : type du champ, ici « CollectionType » qui est une liste de quelque chose
       ** - 3e argument : tableau d'options du champ
       */
      ->add('categories', CollectionType::class, array(
        'entry_type'   => CategoryType::class,
        'allow_add'    => true,
        'allow_delete' => true
      ))
      ->add('save',      SubmitType::class);
  }
}

On a ici utilisé le type de champCollectionType, 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'optionentry_type : le formulaire sait donc qu'il doit créer une liste deCategoryType, mais on aurait pu faire une liste de typeTextType : le formulaire aurait donc injecté dans l'attributcategoriesun simple tableau de textes (mais ce n'est pas ce que nous voulons évidemment !).

Ce champ de typeCollectionType comporte plusieurs options en plus du type. Vous notez les optionsallow_addetallow_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 desCategory 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 Symfony. En effet, comme l'entitéAdvert liée au formulaire de base n'a pas encore de catégories associées, le champCollectionType n'a encore rien à afficher ! 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 Symfony a rajoutée en dessous du labelCategorie:

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

<div class="form-group">
  <label class="col-sm-2 control-label required">__name__label__</label>
  <div class="col-sm-10">
    <div id="advert_categories___name__">
      <div class="form-group">
        <label class="col-sm-2 control-label required" for="advert_categories___name___name">Name</label>
        <div class="col-sm-10">
          <input type="text" id="advert_categories___name___name" name="advert[categories][__name__][name]" required="required" class="form-control" />
        </div>
      </div>
    </div>
  </div>
</div>

Vous voyez qu'il contient les balises<label>et<input>, tout ce qu'il faut pour créer le champname compris dansCategoryType, 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 boutonAjouterqui permet d'ajouter à l'infini ce sous-formulaireCategoryTypecontenu dans l'attributdata-prototype;

  • D'ajouter pour chaque sous-formulaire, un boutonSupprimerpermettant 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 globalement le même,
   On ne rajoute que le champ catégorie et le lien pour en ajouter #}
<div class="well">
  {# ... #}
  
  {{ form_row(form.categories) }}
  <a href="#" id="add_category" class="btn btn-default">Ajouter une catégorie</a>
  
  {# ... #}
</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 définit un compteur unique pour nommer les champs qu'on va ajouter dynamiquement
    var index = $container.find(':input').length;

    // On ajoute un nouveau champ à chaque clic sur le lien d'ajout.
    $('#add_category').click(function(e) {
      addCategory($container);

      e.preventDefault(); // évite qu'un # apparaisse dans l'URL
      return false;
    });

    // 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 {
      // S'il existe déjà des catégories, on ajoute un lien de suppression pour chacune d'entre elles
      $container.children('div').each(function() {
        addDeleteLink($(this));
      });
    }

    // La fonction qui ajoute un formulaire CategoryType
    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 template = $container.attr('data-prototype')
        .replace(/__name__label__/g, 'Catégorie n°' + (index+1))
        .replace(/__name__/g,        index)
      ;

      // On crée un objet jquery qui contient ce template
      var $prototype = $(template);

      // 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
      var $deleteLink = $('<a href="#" class="btn btn-danger">Supprimer</a>');

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

      // Ajout du listener sur le clic du lien pour effectivement supprimer la catégorie
      $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 avec nos catégories
Formulaire opérationnel avec nos catégories

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 Form, vous pourrez trouver le résultat de la figure suivante.

Les données envoyées par le formulaire
La structure de notre formulaire

Jetez également un oeil à l'onglet Request / Response, sur la figure suivante.

Les données soumises par notre navigateur
Les données soumises par notre navigateur

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['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'attributcategoriesé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 champEntityType etCollectionType.

Le type de champ EntityType est un type assez puissant, vous allez le voir très vite. Nous allons l'utiliser à la place du typeCollectionType 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 typeEntityType 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 Symfony. Testons-le tout de suite, modifiez le champcategoriescomme suit :

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

use Symfony\Bridge\Doctrine\Form\Type\EntityType;

// ...

$builder
  ->add('categories', EntityType::class, array(
    'class'        => 'OCPlatformBundle:Category',
    'choice_label' => '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'optionclassdéfinit quel est le type d'entité à sélectionner. Ici, on veut sélectionner des entitésCategory, on renseigne donc le raccourci Doctrine pour cette entité (ou son namespace complet).

  • L'optionchoice_label définit comment afficher les entités dans leselectdu 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 optionchoice_label. Ici j'ai renseignéname, c'est donc via leur nom qu'on liste les catégories dans leselect. Sachez que vous pouvez également renseignerdisplay (ou autre !) et créer le getter associé (à savoirgetDisplay()) dans l'entitéCategory, ce sera donc le retour de cette méthode qui sera affiché dans leselect.

  • L'optionmultipledé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ésCategory) 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 optionsmultipleetexpanded:

Les quatre formes
Les quatre formes

Par défaut, les optionsmultipleetexpandedsont àfalse. :)

L'optionquery_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 catégories qui commencent  par une certaine lettre (oui, c'est totalement arbitraire pour l'exemple ;))Tout est prévu : il faut jouer avec l'optionquery_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 (dans notre cas,CategoryRepository) qui retourne le bon QueryBuilder, celui qui ne retourne que les annonces publiées :

<?php
// src/OC/PlatformBundle/Repository/CategoryRepository.php

namespace OC\PlatformBundle\Repository;

use Doctrine\ORM\EntityRepository;

class CategoryRepository extends EntityRepository
{
  public function getLikeQueryBuilder($pattern)
  {
    return $this
      ->createQueryBuilder('c')
      ->where('c.name LIKE :pattern')
      ->setParameter('pattern', $pattern)
    ;
  }
}

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

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

use OC\PlatformBundle\Repository\CategoryRepository;

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    // Arbitrairement, on récupère toutes les catégories qui commencent par "D"
    $pattern = 'D%';
    
    $builder
      // ...
      ->add('categories', EntityType::class, array(
        'class'         => 'OCPlatformBundle:Category',
        'choice_label'  => 'name',
        'multiple'      => true,
        'query_builder' => function(CategoryRepository $repository) use($pattern) {
          return $repository->getLikeQueryBuilder($pattern);
        }
      ))
    ;
  }
}

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 objetsXxxType 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'une 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 deuxXxxTypedistincts :AdvertTypepour l'ajout, etAdvertEditTypepour la modification. Seulement, il est hors de question de répéter la définition de tous les champs dans leAdvertEditType, tout d'abord c'est long, mais surtout si jamais un champ change, on devra modifier à la foisAdvertTypeetAdvertEditType, c'est impensable.

On va donc faire hériterAdvertEditType deAdvertType. Le processus est le suivant :

  1. Copiez-collez le fichierAdvertType.phpet renommez la copie enAdvertEditType.php;

  2. Modifiez le nom de la classe enAdvertEditType ;

  3. Ajouter une méthodegetParent  qui retourne la classe du formulaire parent,AdvertType::class  ;

  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éthodeconfigureOptions()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 getParent()
  {
    return AdvertType::class;
  }
}

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éthodegetParent() qui retourne le formulaire parent. Ainsi, lors de la construction de ce formulaire, le composant Form exécutera d'abord la méthodebuildForm du formulaire parent, iciAdvertType, 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 êtreTextType::class (ou autre) : votre champ hériterait donc du champ texte de base.

Maintenant, si vous utilisez le formulaireAdvertEditType, vous ne pourrez pas modifier l'attributdatede l'entitéAdvert. Objectif atteint ! Prenez le temps de tester ce nouveau formulaire depuis l'actioneditAction()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 plusieursXxxType! 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é Symfony c'est un tout autre formulaire, qui mérite son propreXxxType.

  • 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 simpleAdvertType, 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éthodebuildForm()nous n'avons pas accès aux valeurs de l'objetAdvert 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ènementPRE_SET_DATAqui 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éthodebuildForm():

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

namespace OC\PlatformBundle\Form;

use OC\PlatformBundle\Repository\CategoryRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
// N'oubliez pas ces deux use !
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

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
        }

        // Si l'annonce n'est pas publiée, ou si elle n'existe pas encore en base (id est null)
        if (!$advert->getPublished() || null === $advert->getId()) {
          // Alors on ajoute le champ published
          $event->getForm()->add('published', CheckboxType::class, 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éthodebuildForm(), qui, elle, est exécutée une fois pour toutes, indépendamment de l'objet sous-jacent. Pour mieux visualiser cette unique instance duXxxType, pensez à un champ de typeCollectionType, rappelez-vous sa définition :

<?php
$builder->add('categories', CollectionType::class, array('entry_type' => CategoryType::class);

Avec ce code, on ne crée qu'un seul objetCategoryType, 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'objetCategoryType. C'est pour cela qu'il faut utiliser l'évènementPRE_SET_DATA, qui, lui, est déclenché à chaque fois que le formulaire remplit les valeurs de ses champs par les valeurs d'un nouvel objetCategory.

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 exempleville) dépend de la valeur du premier (par exemplepays).

Le type de champ File pour envoyer des fichiers

Dans cette partie, nous allons apprendre à envoyer un fichier via le typeFileType, 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 champFile

Un champFileType de formulaire ne retourne pas du texte, mais une instance de la classeUploadedFile. 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, iciImage.

Préparer l'objet sous-jacent

Ouvrez donc l'entitéImageet ajoutez l'attribut$filesuivant :

<?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$fileque 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\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

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

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 seule !

Manipuler le fichier envoyé

Une fois le formulaire soumis, il faut bien évidemment s'occuper du fichier envoyé. L'objetUploadedFileque le formulaire nous renvoie simplifie grandement les choses, grâce à sa méthodemove(). Créons une méthodeupload()dans notre objetImagepour 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 iciuploads/img, ce répertoire est relatif au répertoireweb, vous pouvez tout à fait le personnaliser. La méthodegetUploadDir()retourne ce chemin relatif, à utiliser dans vos vues car les navigateurs sont relatifs à notre répertoireweb. La méthodegetUploadRootDir(), 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épertoireweb, il faut remonter pas mal de dossiers, comme vous pouvez le voir. :p

D'autre part, la méthodeupload()s'occupe concrètement de notre fichier. Elle fait l'équivalent dumove_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éImagedans la base de données, la méthodeupload()s'occupe également de renseigner les deux attributs persistés,$urlet$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 dansaddAction, une fois que le formulaire est valide :

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

// …

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

    if ($request->isMethod('POST') && $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éthodeupload()! Bref, vous l'aurez compris, il faut ici réutiliser les évènements Doctrine pour automatiser tout cela. ;)

Automatiser le traitement grâce aux évènements

La manipulation du champ de typeFileType 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 d'une entitéImage par exemple !

On va également en profiter pour modifier le nom donné au fichier qu'on déplace dans notre répertoireweb/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$urlet$altavec 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 enPostRemove, (l'entité étant supprimé, elle n'a plus d'id) on est donc obligé de le sauvegarder enPreRemove: 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éthodeupload()dans les méthodes :

    • preUpload(): pour ce qui est de la génération des attributs$urlet$alt;

    • upload(): pour le déplacement effectif du fichier.

  • On ajoute une méthodepreRemoveUpload()qui sauvegarde le nom du fichier, qui dépend de l'id de l'entité, dans un attribut temporaire.

  • On ajoute une méthoderemoveUpload()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\Table(name="oc_image")
 * @ORM\Entity
 * @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), on ne fait rien
    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), on ne fait rien
    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 dansweb/uploads/img. Supprimez une annonce : l'image jointe sera automatiquement supprimée du répertoire.

Vous devez également modifier la vueview.html.twigqui 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 exemplegetWebPath():

<?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 }}"
/>

d

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 OC\PlatformBundle\Repository\CategoryRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AdvertType extends AbstractType
{
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    // Arbitrairement, on récupère toutes les catégories qui commencent par "D"
    $pattern = 'D%';

    $builder
      ->add('date',      DateTimeType::class)
      ->add('title',     TextType::class)
      ->add('author',    TextType::class)
      ->add('content',   TextareaType::class)
      ->add('image',     ImageType::class)
      ->add('categories', EntityType::class, array(
        'class'         => 'OCPlatformBundle:Category',
        'choice_label'  => 'name',
        'multiple'      => true,
        'query_builder' => function(CategoryRepository $repository) use($pattern) {
          return $repository->getLikeQueryBuilder($pattern);
        }
      ))
      ->add('save',      SubmitType::class)
    ;

    $builder->addEventListener(
      FormEvents::PRE_SET_DATA,
      function(FormEvent $event) {
        $advert = $event->getData();

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

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

  public function configureOptions(OptionsResolver $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\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 getParent()
  {
    return AdvertType::class;
  }
}
ImageType
<?php
// src/OC/PlatformBundle/Form/ImageType.php

namespace OC\PlatformBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

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

  public function configureOptions(OptionsResolver $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'OC\PlatformBundle\Entity\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->get('form.factory')->create(AdvertType::class, $advert);

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

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

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

    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 utiliserAdvertEditTypeet nonAdvertType, car on est en mode édition.

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

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

    $advert = $em->getRepository('OCPlatformBundle:Advert')->find($id);

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

    $form = $this->get('form.factory')->create(AdvertEditType::class, $advert);

    if ($request->isMethod('POST') && $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->redirectToRoute('oc_platform_view', array('id' => $advert->getId()));
    }

    return $this->render('OCPlatformBundle:Advert:edit.html.twig', array(
      'advert' => $advert,
      'form'   => $form->createView(),
    ));
  }
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(Request $request, $id)
  {
    $em = $this->getDoctrine()->getManager();

    $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->get('form.factory')->create();

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

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

      return $this->redirectToRoute('oc_platform_home');
    }
    
    return $this->render('OCPlatformBundle:Advert:delete.html.twig', array(
      'advert' => $advert,
      'form'   => $form->createView(),
    ));
  }

Je vous invite par la même occasion à faire la vuedelete.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 à unFormBuilder, et dans un fichierXxxTypeindé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 auxXxxType.

  • Le type de champCollectionType affiche une liste de champs d'un certain type.

  • Le type de champEntityType 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 champFileType permet l'upload de fichier, et se couple aux entités grâce aux évènements Doctrine.

  • Le code du cours tel qu'il doit être à ce stade est disponible sur la branche iteration-14 du dépot Github.

Exemple de certificat de réussite
Exemple de certificat de réussite