Skip to content

4. La vue et son modèle

Jusqu'ici, les actions fabriquaient elles-mêmes leur réponse : du texte, du JSON, quelques balises HTML écrites dans une chaîne de caractères. Dans une application MVC, c'est le rôle d'une vue : un fichier qui décrit la page HTML, avec des emplacements pour les données que lui transmet l'action, le modèle de la vue.

[Symfony] utilise le moteur de vues [Twig], créé par la même équipe. Une vue [Twig] est un fichier dont le nom se termine par [.html.twig] (le format produit, puis le moteur), rangé dans le dossier [templates] de l'application. C'est un fichier texte, ici du HTML, dans lequel trois sortes de balises sont interprétées sur le serveur :

  • [{{ expression }}] : la valeur d'une expression, insérée dans la page ;
  • [{% instruction %}] : une instruction (tag) : condition ([{% if %}]), boucle ([{% for %}]), héritage de gabarit ([{% extends %}], [{% block %}]), inclusion ([{% include %}])... ;
  • [{# commentaire #}] : un commentaire, qui n'apparaît pas dans la page produite.

Tout le reste est recopié tel quel. Les expressions ne sont pas écrites en [PHP], mais dans le langage de [Twig], plus simple et plus sûr : [modele.titre] désigne la propriété [titre] de l'objet [modele] (ou sa méthode [getTitre()], ou la clé [titre] d'un tableau : [Twig] cherche lui-même) ; les filtres transforment une valeur ([nom|upper] met un nom en majuscules) ; les fonctions calculent ([path('accueil')] fabrique une URL). [Twig] ressemble ainsi à [Jinja2] ([Flask]), dont il s'est inspiré, ou au moteur de [Django] ; il se distingue de [Blade] ([Laravel]) et de [Razor] ([ASP.NET Core]), qui mêlent HTML et code du langage hôte. Une vue [Twig] ne peut pas exécuter n'importe quel code [PHP] : elle ne sait qu'afficher les données qu'on lui donne. C'est voulu : la logique de l'application reste dans les classes. Une vue n'est pas interprétée à chaque requête : à sa première utilisation, [Twig] la compile en une classe [PHP], rangée dans le dossier [var/cache/dev/twig], qu'il réutilise ensuite tant que la vue n'est pas modifiée. Nous verrons à quoi ressemble ce fichier.

4.1. Exemple 13 : une première vue

13-premiere-vue/
├── config/
│   ├── packages/
│   │   ├── framework.yaml
│   │   └── twig.yaml
│   └── bundles.php
├── src/
│   ├── Controller/
│   │   └── VuesController.php
│   ├── Modele/
│   │   └── PageModele.php
│   └── Kernel.php
├── templates/
│   └── vues/
│       ├── inconnu.html.twig
│       └── index.html.twig
└── ...

4.1.1. La configuration : config/bundles.php et config/packages/twig.yaml

[Twig] est intégré à [Symfony] par un bundle, le TwigBundle, qu'il faut ajouter à la liste des bundles de l'application (exemple 01) :

1
2
3
4
5
6
7
<?php
// config/bundles.php : les bundles de l'application ; le TwigBundle intègre le
// moteur de vues Twig
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

et configurer :

1
2
3
4
# config/packages/twig.yaml : la configuration du TwigBundle
twig:
    # le dossier des vues
    default_path: '%kernel.project_dir%/templates'

La seule clé indique le dossier des vues : [templates], dans le dossier du projet. C'est d'ailleurs la valeur par défaut. Dans un projet ordinaire, ces deux fichiers sont écrits par [Symfony Flex] quand on installe [Twig] ([composer require twig], cf. introduction).

4.1.2. Le modèle de la vue : src/Modele/PageModele.php

<?php
// src/Modele/PageModele.php : le modèle de la vue, les données que l'action transmet à la vue

namespace App\Modele;

readonly class PageModele
{
    public function __construct(
        public string $titre,
        public string $message,
        public string $heure,
    ) {
    }
}

Le modèle de la vue est une simple classe immuable (cf. exemple 03) : les trois informations que la vue affichera. Rien n'oblige à décrire le modèle d'une vue par une classe : on pourrait passer trois variables séparées à la vue. Mais une classe documente ce que la vue attend, et permet à l'éditeur de vérifier le code de l'action. C'est la démarche que suivra l'étude de cas, où chaque vue importante a sa classe de modèle.

4.1.3. Le contrôleur : src/Controller/VuesController.php

<?php
// src/Controller/VuesController.php : des actions qui rendent une VUE

namespace App\Controller;

use App\Modele\PageModele;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class VuesController extends AbstractController
{
    // render : le moteur Twig fabrique la page à partir de la vue
    // templates/vues/index.html.twig et des variables passées dans le tableau (ici
    // une seule, [modele]) ; la page produite est le corps de la réponse
    #[Route('/', name: 'index')]
    public function index(): Response
    {
        $modele = new PageModele(
            'Ma première vue',
            'Cette page a été fabriquée par Twig, sur le serveur.',
            date('H:i:s'));
        return $this->render('vues/index.html.twig', ['modele' => $modele]);
    }

    // la même vue avec un autre modèle ; ou une autre vue, avec le code 404
    #[Route('/bonjour/{nom}', name: 'bonjour')]
    public function bonjour(string $nom): Response
    {
        if ($nom === 'inconnu') {
            // le troisième paramètre de render : la réponse à compléter, ici avec le code 404
            return $this->render('vues/inconnu.html.twig', ['nom' => $nom],
                new Response(status: Response::HTTP_NOT_FOUND));
        }
        $modele = new PageModele(
            "Bonjour $nom",
            "Cette fois, le titre de la page vient de l'URL.",
            date('H:i:s'));
        return $this->render('vues/index.html.twig', ['modele' => $modele]);
    }
}
  • lignes 20-23 : l'action construit le modèle de la vue. La fonction [date] de [PHP] met en forme l'heure courante : [H] les heures, [i] les minutes, [s] les secondes ;
  • ligne 24 : la méthode [render] d'[AbstractController] fait fabriquer la page par [Twig]. Le premier paramètre est le nom de la vue : le chemin du fichier, relatif au dossier [templates]. Le second paramètre est un tableau associatif, dont chaque clé devient une variable de la vue, ici une seule, [modele]. [render] rend un objet [Response], dont le corps est la page fabriquée (de type [text/html]) ;
  • lignes 31-35 : une autre vue, avec une autre variable ([nom]). Le troisième paramètre de [render] est une réponse à compléter : ici, une réponse de code 404, pour une page d'erreur « maison ». [new Response(status: ...)] passe le code par un paramètre nommé, les autres paramètres gardant leur valeur par défaut ;
  • lignes 36-40 : la même vue que l'action [index], avec un autre modèle. C'est l'intérêt de la séparation entre action et vue : une vue peut servir à plusieurs actions.

4.1.4. La vue : templates/vues/index.html.twig

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  {# {{ ... }} : la valeur d'une expression, insérée dans la page #}
  <title>{{ modele.titre }}</title>
</head>
<body>
  <h1>{{ modele.titre }}</h1>
  <p>{{ modele.message }}</p>
  <p>Page fabriquée à {{ modele.heure }}.</p>
</body>
</html>
  • ligne 5 : un commentaire [Twig] ;
  • ligne 6 : [{{ modele.titre }}] insère la valeur de la propriété [titre] de la variable [modele]. Le point est l'opérateur d'accès de [Twig], là où [PHP] écrit [$modele->titre].

Tout ce qui est inséré par [{{ }}] est échappé : les caractères [<], [>], [&], ["] et ['] sont remplacés par leurs entités HTML. C'est le rôle que jouait la fonction [htmlspecialchars] dans l'exemple 09, mais cette fois il est automatique : une vue ne peut donc pas, par inadvertance, insérer dans la page du HTML fourni par un utilisateur.

4.1.5. La vue : templates/vues/inconnu.html.twig

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [{{ nom }}] ici.</h1>
</body>
</html>

Une vue n'est pas obligée de recevoir un objet : la variable [nom] est une chaîne.

4.1.6. Exécution

Image

Image

L'heure affichée est celle du fuseau horaire configuré dans [PHP] (la directive [date.timezone] du fichier [php.ini]), ici le temps universel (UTC) ; l'étude de cas montrera comment la fixer pour l'application. La réponse brute montre le travail de [Twig] : les balises [{{ }}] ont été remplacées par leurs valeurs, le commentaire a disparu.

curl -i http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
    <title>Ma première vue</title>
</head>
<body>
  <h1>Ma première vue</h1>
  <p>Cette page a été fabriquée par Twig, sur le serveur.</p>
  <p>Page fabriquée à 09:31:47.</p>
</body>
</html>

La page d'erreur « maison », puis un nom contenant du HTML ([<b>martin], encodé dans l'URL) :

curl -i http://localhost:8000/bonjour/inconnu
curl http://localhost:8000/bonjour/%3Cb%3Emartin
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=UTF-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inconnu</title>
</head>
<body>
  <h1>Personne ne s'appelle [inconnu] ici.</h1>
</body>
</html>
...
    <title>Bonjour &lt;b&gt;martin</title>
...
  <h1>Bonjour &lt;b&gt;martin</h1>
  <p>Cette fois, le titre de la page vient de l&#039;URL.</p>
...

La balise [<b>] a été échappée, sans que l'action ni la vue aient rien eu à faire (l'apostrophe aussi : [&#039;]). Voici enfin la vue compilée par [Twig], telle qu'on la trouve dans le dossier [var/cache/dev/twig] (sous un nom calculé, comme [9e/9e42179990bdaebfeb73b2435744b0e7.php]), réduite à l'essentiel :

/* vues/index.html.twig */
class __TwigTemplate_aa4414ddec1d8b74a5b0f81e8faede95 extends Template
{
    ...
    protected function doDisplay(array $context, array $blocks = []): iterable
    {
        // line 1
        yield "<!doctype html>
<html lang=\"fr\">
<head>
  <meta charset=\"utf-8\">
  ";
        // line 6
        yield "  <title>";
        yield (string) $this->escaper->escape(CoreExtension::getAttribute($this->env, $this->source,
            ... $context["modele"] ..., "titre", ...), "html", null, true);
        yield "</title>
...

La vue est devenue une classe [PHP], dont la méthode [doDisplay] produit la page morceau par morceau ([yield]). Chaque [{{ modele.titre }}] est devenu un appel de [CoreExtension::getAttribute] (qui cherche la propriété, la méthode ou la clé [titre]), passé à la méthode [escape] (l'échappement). Une vue [Twig] n'est donc qu'une façon plus sûre et plus lisible d'écrire un fichier [PHP] qui fabrique du HTML.

4.2. Exemple 14 : le langage Twig

Cet exemple réunit dans une seule vue les principales constructions de [Twig].

4.2.1. Le modèle et l'action

Le modèle de la vue est décrit par trois classes immuables, chacune dans son fichier ([PHP] n'impose pas une classe par fichier, mais le chargeur de classes de [Composer], lui, cherche la classe [App\Modele\Produit] dans le fichier [src/Modele/Produit.php]) :

<?php
// src/Modele/Produit.php : un produit

namespace App\Modele;

readonly class Produit
{
    public function __construct(
        public string $nom,
        public float $prix,
        public bool $enStock,
    ) {
    }
}
<?php
// src/Modele/Adresse.php : une adresse

namespace App\Modele;

readonly class Adresse
{
    public function __construct(
        public string $rue,
        public string $ville,
    ) {
    }
}
<?php
// src/Modele/BasesModele.php : le modèle de la vue de l'exemple 14

namespace App\Modele;

readonly class BasesModele
{
    /**
     * @param list<Produit> $produits
     * @param list<Produit> $panier
     */
    public function __construct(
        public string $titre,
        public string $nom,
        public ?string $surnom,
        public string $html,
        public bool $connecte,
        public string $devise,
        public array $produits,
        public array $panier,
        public Adresse $adresse,
    ) {
    }
}

Le modèle contient des valeurs de types variés : chaînes, [null], booléen, tableau d'objets, tableau vide, objet contenant un autre objet. Les prix sont ici de type [float], pour simplifier les calculs de la vue (l'exemple 06 a montré pourquoi on leur préfère des centimes entiers).

<?php
// src/Controller/BasesController.php : une vue qui utilise les principales constructions de Twig

namespace App\Controller;

use App\Modele\Adresse;
use App\Modele\BasesModele;
use App\Modele\Produit;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BasesController extends AbstractController
{
    #[Route('/', name: 'index', methods: ['GET'])]
    public function index(): Response
    {
        $modele = new BasesModele(
            titre: 'Les bases de Twig',
            nom: 'Martin',
            surnom: null,
            html: '<b>ceci est du HTML</b>',
            connecte: true,
            devise: '€',
            produits: [
                new Produit('Clavier', 25, true),
                new Produit('Souris', 12.5, false),
                new Produit('Écran', 149, true),
            ],
            panier: [],
            adresse: new Adresse('12 rue des Lilas', 'Angers'));
        return $this->render('bases/index.html.twig', ['modele' => $modele]);
    }
}

L'action construit le modèle avec des paramètres nommés (cf. exemple 02) : chaque valeur est précédée du nom du paramètre auquel elle est destinée, ce qui rend l'appel lisible malgré le nombre de paramètres.

4.2.2. La vue : templates/bases/index.html.twig

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>{{ modele.titre }}</title>
</head>
<body>
  {# set : une variable de la vue ; le filtre reduce additionne les prix, le filtre length compte les produits #}
  {% set total = modele.produits|reduce((somme, p) => somme + p.prix, 0) %}
  {% set nombre = modele.produits|length %}
  <h1>{{ modele.titre }}</h1>
  {# 1. afficher une valeur du modèle ; null s'affiche comme une chaîne vide ;
     le filtre default remplace une valeur vide. Ce commentaire Twig n'apparaît
     pas dans la page produite #}
  <p>1. Nom : {{ modele.nom }} - surnom : [{{ modele.surnom }}] [{{ modele.surnom|default('aucun') }}]</p>
  {# 2. {{ }} échappe le HTML ; le filtre raw l'insère tel quel #}
  <p>2a. {{ modele.html }}</p>
  <p>2b. {{ modele.html|raw }}</p>
  {# 3. une condition #}
  {% if modele.connecte %}
    <p>3. Vous êtes connecté.</p>
  {% else %}
    <p>3. Vous n'êtes pas connecté.</p>
  {% endif %}
  {# 4. une boucle : la variable loop décrit l'état de la boucle #}
  <p>4. Nos produits :</p>
  <ul>
    {% for produit in modele.produits %}
      <li>n° {{ loop.index0 }} : {{ produit.nom }}, {{ produit.prix }} {{ modele.devise }}
        {{ produit.enStock ? '(en stock)' : '(épuisé)' }}
        {% if loop.first %} - le premier{% endif %}
        {% if loop.last %} - le dernier, vu par {{ modele.nom }}{% endif %}
      </li>
    {% endfor %}
  </ul>
  {# 5. la liste vide : le bloc else d'une boucle for #}
  <p>5. Votre panier :
    {% for p in modele.panier %}<span>{{ p.nom }}</span>{% else %} il est vide.{% endfor %}
  </p>
  {# 6. des expressions calculées, les variables de set, et le filtre number_format #}
  <p>6. {{ nombre }} produits, pour un total de {{ total }} {{ modele.devise }} ;
    prix moyen : {{ (total / nombre)|number_format(2, ',', ' ') }} {{ modele.devise }}</p>
  {# 7. un objet dans un objet, et un filtre qui met en majuscules #}
  <p>7. Adresse : {{ modele.adresse.rue }}, {{ modele.adresse.ville|upper }}</p>
  {# 8. verbatim : un texte que Twig recopie sans l'interpréter #}
  <p>8. Des accolades affichées telles quelles : {% verbatim %}{{ modele.nom }}{% endverbatim %}</p>
</body>
</html>
  • lignes 9-10 : l'instruction [{% set %}] crée une variable de la vue. Le total des prix est calculé par le filtre [reduce], qui réduit un tableau à une valeur (comme [array_reduce], exemple 06) : sa fonction reçoit la valeur accumulée et l'élément courant ; [(somme, p) => somme + p.prix] est une fonction fléchée de [Twig]. Le filtre [length] rend le nombre d'éléments d'un tableau (ou de caractères d'une chaîne). Ces calculs sont à réserver à la présentation : le calcul appartient normalement à l'action ;
  • ligne 15 : une valeur [null] s'affiche comme une chaîne vide. Le filtre [default] remplace une valeur vide (ou absente) par une valeur par défaut ;
  • lignes 17-18 : [{{ }}] échappe le HTML (2a) ; le filtre [raw] insère au contraire la valeur telle quelle, sans échappement (2b). [raw] est à réserver à du HTML produit par l'application elle-même, jamais à une saisie de l'utilisateur ;
  • lignes 20-24 : une condition : [{% if %}], [{% elseif %}], [{% else %}], [{% endif %}]. Chaque bloc est fermé par une instruction [{% end... %}] ;
  • lignes 28-34 : une boucle [{% for ... in ... %}] ... [{% endfor %}]. À l'intérieur, la variable spéciale [loop] décrit l'état de la boucle : [loop.index0] (le numéro de l'itération, à partir de 0), [loop.index] (à partir de 1), [loop.first], [loop.last] (vrais à la première et à la dernière itération), [loop.length] (le nombre d'éléments). Les variables extérieures à la boucle ([modele.nom]) restent accessibles ;
  • ligne 30 : l'opérateur ternaire [condition ? a : b] ([Twig] accepte aussi [condition ? a], qui rend une chaîne vide si la condition est fausse) ;
  • ligne 38 : une boucle [for] peut avoir un bloc [{% else %}], exécuté si la liste est vide. C'est plus simple qu'un [{% if %}] qui testerait la longueur de la liste ;
  • lignes 41-42 : des expressions calculées ([total / nombre]), et les variables de [set]. Le filtre [number_format] met un nombre en forme : deux décimales, la virgule comme séparateur décimal, l'espace comme séparateur des milliers. Nous verrons à l'exemple 16 une façon bien meilleure de mettre en forme des nombres et des montants ;
  • ligne 44 : le point traverse les objets ([modele.adresse.ville]), et le filtre [upper] met une chaîne en majuscules (en traitant correctement les caractères accentués) ;
  • ligne 46 : l'instruction [{% verbatim %}] recopie son contenu sans l'interpréter : c'est utile quand une page contient des accolades destinées à un framework [JavaScript].

[Twig] a une cinquantaine de filtres ([lower], [capitalize], [trim], [join], [split], [slice], [sort], [date], [json_encode], [escape]...), une quinzaine de fonctions ([range], [min], [max], [random]...) et des tests ([is defined], [is empty], [is null], [is iterable], [is even]...) ; les bundles de [Symfony] en ajoutent ([path], [asset], [form_row], [is_granted]...), et l'application peut écrire les siens (exemple 16).

4.2.3. Exécution

Image

La page produite :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Les bases de Twig</title>
</head>
<body>
        <h1>Les bases de Twig</h1>
    <p>1. Nom : Martin - surnom : [] [aucun]</p>
    <p>2a. &lt;b&gt;ceci est du HTML&lt;/b&gt;</p>
  <p>2b. <b>ceci est du HTML</b></p>
        <p>3. Vous êtes connecté.</p>
      <p>4. Nos produits :</p>
  <ul>
          <li>n° 0 : Clavier, 25 €
        (en stock)
         - le premier              </li>
          <li>n° 1 : Souris, 12.5 €
        (épuisé)
                      </li>
          <li>n° 2 : Écran, 149 €
        (en stock)
                 - le dernier, vu par Martin      </li>
      </ul>
    <p>5. Votre panier :
     il est vide.  </p>
    <p>6. 3 produits, pour un total de 186.5 € ;
    prix moyen : 62,17 €</p>
    <p>7. Adresse : 12 rue des Lilas, ANGERS</p>
    <p>8. Des accolades affichées telles quelles : {{ modele.nom }}</p>
</body>
</html>

Les commentaires et les instructions ont disparu, mais ils laissent derrière eux leurs espaces : [Twig] retire le saut de ligne qui suit une balise, mais pas l'indentation qui la précède. C'est sans conséquence : le navigateur ignore ces blancs. (Un tiret dans une balise, [{%- ... -%}] ou [{{- ... -}}], retirerait les blancs qui l'entourent.) Un nombre [float] inséré dans la page est écrit avec un point décimal ([12.5]) ; [number_format] a écrit la virgule française.

4.3. Exemple 15 : gabarit, fragments, fichiers statiques

Les pages d'un site partagent le plus souvent une même structure : un entête, une barre de navigation, un pied de page, les mêmes feuilles de style. Plutôt que de répéter cette structure dans chaque vue, on l'écrit une fois, dans un gabarit (layout), que les pages étendent. Cet exemple utilise aussi [Bootstrap], le framework CSS le plus répandu, pour la présentation.

15-gabarit/
├── config/
│   ├── packages/
│   │   └── twig.yaml
│   └── services.yaml
├── public/
│   └── css/
│       └── styles.css
├── src/
│   ├── Controller/
│   │   └── PagesController.php
│   ├── Modele/
│   │   └── Membre.php
│   └── Kernel.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   ├── macros.html.twig
│   └── pages/
│       ├── accueil.html.twig
│       ├── brute.html.twig
│       └── equipe.html.twig
└── ...

4.3.1. Les fichiers statiques

Une page HTML fait référence à d'autres fichiers : feuilles de style, scripts [JavaScript], images. Ces fichiers, que le serveur envoie tels quels, sont dits statiques. Avec [Symfony], ils sont rangés dans le dossier [public], que le serveur web publie tel quel (exemple 01) : le fichier [public/css/styles.css] est disponible à l'URL [/css/styles.css], sans que [Symfony] intervienne.

/* quelques réglages en plus de Bootstrap */
body {
  background-color: #f5f7fa;
}

/* le pied de page du gabarit */
.pied {
  margin-top: 2rem;
  font-size: 0.85rem;
  color: #6c757d;
}

/* les messages d'erreur, en bas de page, sur fond rose léger */
.zone-erreur {
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef;
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

Quelques règles CSS en plus de [Bootstrap] : un fond gris clair, le style du pied de page, et celui d'une zone d'erreurs qui servira aux exemples suivants.

[Bootstrap], lui, est utilisé par les exemples 15 à 28. Plutôt que d'en mettre une copie dans le dossier [public] de chacun, les exemples le partagent : il est dans le dossier [exemples/commun/bootstrap]. Ce dossier n'est pas publié par le serveur web : un contrôleur, commun à tous ces exemples, sert ses fichiers. C'est le fichier [exemples/commun/BootstrapController.php] :

<?php
// commun/BootstrapController.php : un contrôleur qui sert les fichiers de Bootstrap,
// partagés par les exemples 15 à 28 (le dossier exemples/commun/bootstrap), sous
// l'URL /bootstrap/... Chacun de ces exemples le déclare dans son fichier
// config/services.yaml

namespace Commun;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;

class BootstrapController
{
    #[Route('/bootstrap/{fichier}', name: 'bootstrap', requirements: ['fichier' => Requirement::CATCH_ALL],
        methods: ['GET'])]
    public function servir(string $fichier): BinaryFileResponse
    {
        $dossier = realpath(__DIR__.'/bootstrap');
        // realpath : le chemin absolu du fichier demandé, sans les . et les .. ;
        // false si le fichier n'existe pas
        $chemin = realpath($dossier.'/'.$fichier);
        // un fichier qui existe, et qui est bien DANS le dossier (pas de ../ pour en sortir)
        if ($chemin === false || ! str_starts_with($chemin, $dossier.DIRECTORY_SEPARATOR) || ! is_file($chemin)) {
            throw new NotFoundHttpException("fichier inconnu : $fichier");
        }
        // une réponse qui envoie le contenu d'un fichier ; son type, d'après son extension
        $reponse = new BinaryFileResponse($chemin);
        $types = ['css' => 'text/css', 'js' => 'text/javascript', 'map' => 'application/json'];
        $reponse->headers->set('Content-Type', $types[pathinfo($chemin, PATHINFO_EXTENSION)] ?? 'application/octet-stream');
        return $reponse;
    }
}
  • ligne 7 : la classe est dans l'espace de noms [Commun], que le fichier [exemples/composer.json] associe au dossier [exemples/commun] (cf. introduction) : le chargeur de classes de [Composer] la trouve donc, quel que soit l'exemple ;
  • lignes 16-17 : une route dont le paramètre [{fichier}] accepte n'importe quelle fin de chemin, barres obliques comprises (exemple 02) : l'URL [/bootstrap/css/bootstrap.min.css] désigne le fichier [css/bootstrap.min.css] du dossier. La route est nommée [bootstrap] ;
  • lignes 20-27 : servir des fichiers désignés par l'URL demande une précaution : une URL comme [/bootstrap/..%2F..%2F15-gabarit%2F.env] (les [/] codés [%2F]) ne doit pas donner accès à un fichier situé hors du dossier. La fonction [realpath] de [PHP] calcule le chemin absolu d'un fichier, débarrassé de ses [.] et de ses [..], ou rend [false] si le fichier n'existe pas. On vérifie que le chemin obtenu commence bien par celui du dossier ([str_starts_with]), et qu'il désigne un fichier ([is_file]), sinon une erreur 404. [DIRECTORY_SEPARATOR] est le séparateur des chemins du système : [\] sous [Windows], [/] ailleurs ;
  • lignes 29-32 : un objet [BinaryFileResponse] est une réponse qui envoie le contenu d'un fichier. Le type du contenu est choisi d'après l'extension du fichier ([pathinfo] la fournit), pour qu'une feuille de style soit bien annoncée comme [text/css] : un navigateur refuse une feuille de style annoncée comme autre chose.

Chaque exemple qui utilise [Bootstrap] déclare ce contrôleur comme un service, dans son fichier [config/services.yaml] :

# config/services.yaml : les services de l'application (cf. exemple 06)
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # le contrôleur qui sert Bootstrap, commun aux exemples 15 à 28 (cf. exemple 15)
    Commun\BootstrapController: ~
  • ligne 11 : le service [Commun\BootstrapController], sans réglage particulier ([~] est la valeur [null] en [YAML]) : il reçoit ceux de [_defaults], et [autoconfigure] en fait un contrôleur, dont la route rejoint celles de l'application (exemple 02).

4.3.2. Le gabarit : templates/layout.html.twig

<!doctype html>
{# le gabarit des pages : les pages l'ÉTENDENT, et remplissent ses blocs #}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# block : un emplacement que la page peut remplir ; ici avec un contenu par défaut #}
  <title>{% block titre %}Accueil{% endblock %} - {{ nom_site }}</title>
  {# path() : l'URL de la route nommée [bootstrap] ; asset() : l'URL d'un fichier de public/ #}
  <link rel="stylesheet" href="{{ path('bootstrap', {fichier: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3">
    {# insertion du fichier fragments/navbar.html.twig #}
    {% include 'fragments/navbar.html.twig' %}
    <main>
      {# le contenu de la page demandée par l'action est inséré ici #}
      {% block contenu %}{% endblock %}
    </main>
    {# app : la variable que Symfony donne à toutes les vues (la requête en cours...) ;
       nom_site : une variable globale (cf. config/packages/twig.yaml) #}
    <footer class="pied">Page [{{ app.request.pathInfo }}] - © {{ 'now'|date('Y') }} {{ nom_site }}</footer>
  </div>
</body>
</html>
  • ligne 6 : la balise [viewport] demande aux navigateurs des téléphones d'afficher la page à la largeur de l'écran, ce qu'attend [Bootstrap] ;
  • ligne 8 : l'instruction [{% block %}] marque un emplacement nommé, que les pages qui étendent le gabarit peuvent remplir, en définissant un bloc de même nom. Le contenu écrit entre [{% block titre %}] et [{% endblock %}] est le contenu par défaut de l'emplacement, utilisé par une page qui ne définit pas ce bloc. [nom_site] est une variable globale, présente dans toutes les vues (voir [config/packages/twig.yaml], plus loin) ;
  • lignes 10-11 : la fonction [path] fabrique l'URL d'une route nommée (exemple 04), avec les valeurs de ses paramètres, données dans un tableau associatif de [Twig] ([{fichier: '...'}], l'équivalent de [['fichier' => '...']] en [PHP]). La fonction [asset] fabrique l'URL d'un fichier du dossier [public]. Écrire les URL à la main ([/css/styles.css]) fonctionnerait aussi, mais [path] et [asset] s'adaptent si l'application est installée sous un préfixe d'URL ;
  • ligne 16 : [{% include %}] insère le contenu d'une autre vue, ici la barre de navigation. La vue incluse voit les variables de la vue qui l'inclut ;
  • ligne 19 : l'emplacement que chaque page remplit avec son propre contenu ;
  • ligne 23 : la variable [app] est présente dans toutes les vues : [Symfony] y range la requête en cours ([app.request]), la session ([app.session]), l'utilisateur connecté ([app.user], chapitre sur la sécurité), le nom de la route ([app.current_route])... [app.request.pathInfo] est le chemin de l'URL demandée. Le filtre [date] met une date en forme ; la chaîne ['now'] désigne l'instant présent.

La classe [container] de [Bootstrap] centre le contenu et limite sa largeur ; [py-3] ajoute une marge verticale intérieure (padding) : [Bootstrap] fournit des centaines de classes de ce genre, qu'on combine dans l'attribut [class]. La variable globale [nom_site] est déclarée dans la configuration de [Twig] :

1
2
3
4
5
6
# config/packages/twig.yaml : la configuration du TwigBundle
twig:
    default_path: '%kernel.project_dir%/templates'
    # des variables GLOBALES, présentes dans toutes les vues
    globals:
        nom_site: 'Mon site'

Les variables de la clé [globals] sont ajoutées à toutes les vues, sans que les actions aient à les fournir. Une variable globale peut aussi être un service (['@App\Service\...']), dont les vues appelleront les méthodes.

4.3.3. La barre de navigation : templates/fragments/navbar.html.twig

{# un fragment : un morceau de page, inséré par include #}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">{{ nom_site }}</span>
  <div class="navbar-nav">
    {# path() : l'URL d'une route, désignée par son nom #}
    <a class="nav-link" href="{{ path('accueil') }}">Accueil</a>
    <a class="nav-link" href="{{ path('equipe') }}">Équipe</a>
    <a class="nav-link" href="{{ path('brute') }}">Page sans gabarit</a>
  </div>
</nav>

Un fragment : un morceau de page, qui n'a pas de sens seul. Les classes [navbar], [navbar-brand], [nav-link] sont celles de la barre de navigation de [Bootstrap]. Les liens sont fabriqués par [path], à partir des noms des routes.

4.3.4. Une macro : templates/macros.html.twig

{# des MACROS : des morceaux de page qui ont des paramètres, comme des fonctions #}

{# une carte Bootstrap, avec un nom et une fonction #}
{% macro carte(nom, fonction = '') %}
  <div class="card">
    <div class="card-body">
      <h5 class="card-title">{{ nom }}</h5>
      <p class="card-text">{{ fonction }}</p>
    </div>
  </div>
{% endmacro %}
  • ligne 4 : une macro est un morceau de page qui a des paramètres : c'est la fonction de [Twig]. Elle se déclare par [{% macro nom(paramètres) %}] ... [{% endmacro %}] ; un paramètre peut avoir une valeur par défaut ([fonction = '']). Un fichier peut réunir plusieurs macros. La carte ([card]) est un composant de [Bootstrap].

Une macro ne voit que ses paramètres (et les variables globales) : elle ne voit pas les variables de la vue qui l'appelle. C'est ce qui la rend réutilisable. (La bibliothèque [Symfony UX Twig Component] propose des composants plus riches, des classes [PHP] associées à une vue, que l'on emploie comme des balises HTML : [<twig:Carte nom="..." />] ; nous n'en aurons pas besoin.)

4.3.5. Les pages

{# cette page ÉTEND le gabarit : elle ne fournit que le contenu de ses blocs #}
{% extends 'layout.html.twig' %}
{# les macros du fichier macros.html.twig, sous le nom m #}
{% import 'macros.html.twig' as m %}

{% block contenu %}
  <h1>Bienvenue</h1>
  <p>Cette page ne contient que son propre contenu : l'entête, la barre de
    navigation et le pied de page viennent du gabarit <code>layout.html.twig</code>.</p>
  {# la macro [carte], appelée avec deux paramètres écrits sur place #}
  {{ m.carte('Une macro', 'appelée avec des paramètres écrits sur place') }}
{% endblock %}
  • ligne 2 : la page étend le gabarit : [Twig] fabrique la page du gabarit, en remplaçant ses blocs par ceux que la page définit. Dans une vue qui étend un gabarit, tout le contenu doit être dans des blocs : le texte écrit en dehors n'apparaît pas dans la page produite ;
  • ligne 4 : [{% import %}] importe les macros d'un fichier, sous un nom au choix ([m]) ;
  • lignes 6-12 : le bloc [contenu], qui remplit l'emplacement de même nom. La page ne définit pas le bloc [titre] : l'emplacement garde son contenu par défaut, « Accueil » ;
  • ligne 11 : l'appel de la macro [carte], comme une fonction : [m.carte(...)], dans des [{{ }}], puisqu'elle produit du texte à insérer. Sa sortie n'est pas échappée une seconde fois : [Twig] sait qu'elle est déjà du HTML sûr (ses paramètres, eux, ont été échappés dans la macro).
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}

{% block titre %}Notre équipe{% endblock %}

{% block contenu %}
  <h1>Notre équipe</h1>
  <div class="row g-3">
    {% for membre in membres %}
      <div class="col-md-4">
        {# les paramètres de la macro sont des expressions quelconques #}
        {{ m.carte(membre.nom, membre.fonction) }}
      </div>
    {% endfor %}
  </div>
{% endblock %}
  • ligne 4 : le bloc [titre], sur une ligne ;
  • lignes 8-15 : une carte par membre de l'équipe, dans une grille de [Bootstrap] ([row], [col-md-4] : trois colonnes sur un écran moyen ou large, une seule sur un écran étroit) ;
  • ligne 12 : les paramètres de la macro sont des expressions quelconques.

Le contrôleur ne présente pas de nouveauté :

<?php
// src/Controller/PagesController.php : trois pages

namespace App\Controller;

use App\Modele\Membre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class PagesController extends AbstractController
{
    #[Route('/', name: 'accueil')]
    public function accueil(): Response
    {
        return $this->render('pages/accueil.html.twig');
    }

    #[Route('/equipe', name: 'equipe')]
    public function equipe(): Response
    {
        $membres = [
            new Membre('Marie Pélissier', 'Directrice'),
            new Membre('Jacques Bromard', 'Développeur'),
            new Membre('Philippe Jandot', 'Graphiste'),
        ];
        return $this->render('pages/equipe.html.twig', ['membres' => $membres]);
    }

    #[Route('/brute', name: 'brute')]
    public function brute(): Response
    {
        return $this->render('pages/brute.html.twig');
    }
}

La troisième page, enfin, n'utilise pas le gabarit :

<!doctype html>
{# cette vue n'utilise pas le gabarit : c'est une page complète #}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Page brute</title>
</head>
<body>
  <p>Cette page n'utilise pas le gabarit : ni Bootstrap, ni barre de navigation.</p>
  <p><a href="{{ path('accueil') }}">Retour à l'accueil</a></p>
</body>
</html>

4.3.6. Exécution

Image

Image

Image

La page de l'équipe, telle que la reçoit le navigateur :

curl http://localhost:8000/equipe
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Notre équipe - Mon site</title>
    <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="container py-3">
        <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Mon site</span>
  <div class="navbar-nav">
        <a class="nav-link" href="/">Accueil</a>
    <a class="nav-link" href="/equipe">Équipe</a>
    <a class="nav-link" href="/brute">Page sans gabarit</a>
  </div>
</nav>
    <main>
              <h1>Notre équipe</h1>
  <div class="row g-3">
          <div class="col-md-4">
                  <div class="card">
    <div class="card-body">
      <h5 class="card-title">Marie Pélissier</h5>
      <p class="card-text">Directrice</p>
    </div>
  </div>

      </div>
...
      </div>
    </main>
        <footer class="pied">Page [/equipe] - © 2026 Mon site</footer>
  </div>
</body>
</html>

Le navigateur a reçu une page complète : l'assemblage du gabarit, du fragment, de la page et des macros a été fait sur le serveur. Les URL des feuilles de style ont été calculées par [path] et [asset] : ce sont des URL relatives au site. On peut vérifier que les feuilles de style sont bien servies (l'option [-I] de [curl] n'affiche que les entêtes de la réponse) :

curl -I http://localhost:8000/bootstrap/css/bootstrap.min.css
curl -I http://localhost:8000/css/styles.css
HTTP/1.1 200 OK
Cache-Control: public
Last-Modified: Sun, 27 Sep 2026 09:32:39 GMT
Content-Type: text/css; charset=UTF-8
Content-Length: 232111
Accept-Ranges: bytes
...
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Length: 422

Le premier fichier a été envoyé par notre contrôleur ([BinaryFileResponse] ajoute la date de modification du fichier, et autorise sa mise en cache : [Cache-Control: public]) ; le second, directement par le serveur de développement, sans passer par [Symfony]. Une tentative de sortir du dossier, enfin :

curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8000/bootstrap/..%2F..%2F15-gabarit%2F.env"
404

(les options [-s -o /dev/null -w "%{http_code}\n"] n'affichent que le code de la réponse ; sous [Windows], on écrit [-o NUL]).

4.4. Exemple 16 : les extensions de Twig

Une vue a souvent besoin de mettre en forme des valeurs : un montant avec sa devise, une date en toutes lettres, un texte en majuscules. Ce code n'a pas sa place dans l'action (qui ne s'occupe pas de présentation), et [Twig] ne permet pas de l'écrire dans la vue (qui ne peut pas exécuter de [PHP], cf. introduction du chapitre). On l'écrit en [PHP], dans une extension de [Twig] : une classe qui ajoute au langage des vues ses propres filtres, fonctions ou tests. Des bibliothèques en fournissent de toutes prêtes, comme [twig/intl-extra], installée avec les exemples, qui met en forme dates et montants selon la langue. Les macros, enfin, fabriquent des morceaux de HTML (exemple 15).

16-extensions/
├── config/
│   └── services.yaml
├── src/
│   ├── Controller/
│   │   └── PagesController.php
│   ├── EventListener/
│   │   └── FixerLangue.php
│   ├── Modele/
│   │   └── Produit.php
│   ├── Twig/
│   │   └── FormatsExtension.php
│   └── Kernel.php
├── templates/
│   ├── fragments/
│   │   └── navbar.html.twig
│   ├── layout.html.twig
│   ├── macros.html.twig
│   └── pages/
│       ├── accueil.html.twig
│       └── produits.html.twig
└── ...

4.4.1. L'extension : src/Twig/FormatsExtension.php

<?php
// src/Twig/FormatsExtension.php : une EXTENSION de Twig, qui ajoute deux filtres
// au langage des vues : monnaie et date_longue

namespace App\Twig;

use DateTimeInterface;
use IntlDateFormatter;
use NumberFormatter;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class FormatsExtension extends AbstractExtension
{
    // la pile des requêtes, pour connaître la langue de la requête en cours
    public function __construct(private RequestStack $requetes)
    {
    }

    // les filtres de l'extension : leur nom dans les vues, et la méthode qui les réalise
    public function getFilters(): array
    {
        return [
            new TwigFilter('monnaie', $this->monnaie(...)),
            new TwigFilter('date_longue', $this->dateLongue(...)),
        ];
    }

    // un montant en centimes, dans une devise, au format de la langue de la
    // requête : 114990 -> "1 149,90 €" en français, "€1,149.90" en anglais.
    // L'extension intl de PHP connaît les conventions de chaque pays
    public function monnaie(int $centimes, string $devise = 'EUR'): string
    {
        $formateur = new NumberFormatter($this->langue(), NumberFormatter::CURRENCY);
        return $formateur->formatCurrency($centimes / 100, $devise);
    }

    // une date en toutes lettres, dans la langue de la requête : "dimanche 27 septembre 2026"
    public function dateLongue(DateTimeInterface $jour): string
    {
        $formateur = new IntlDateFormatter($this->langue(), IntlDateFormatter::FULL, IntlDateFormatter::NONE);
        return $formateur->format($jour);
    }

    private function langue(): string
    {
        return $this->requetes->getCurrentRequest()?->getLocale() ?? 'fr';
    }
}
  • ligne 14 : une extension de [Twig] hérite de la classe [AbstractExtension]. C'est un service, comme toute classe du dossier [src] ; [Symfony] reconnaît en elle une extension ([autoconfigure]), et la donne à [Twig] ;
  • lignes 17-19 : l'extension a besoin de la langue de la requête en cours. Elle reçoit la pile des requêtes ([RequestStack]), un service de [Symfony] qui connaît la requête en cours (il y a une pile, parce qu'une requête peut en déclencher une autre, en interne) ;
  • lignes 22-28 : la méthode [getFilters] rend les filtres de l'extension : pour chacun, son nom dans les vues, et la fonction qui le réalise. [$this->monnaie(...)] (avec les trois points) n'appelle pas la méthode : il en fait une valeur, une fonction qu'on pourra appeler plus tard (une first-class callable). Une extension peut de même fournir des fonctions ([getFunctions]) et des tests ([getTests]) ;
  • lignes 33-37 : la mise en forme d'un montant, reçu en centimes (cf. exemple 06). Elle dépend de la langue : en français, [1 149,90 €] (espace pour séparer les milliers, virgule décimale, symbole après le montant) ; en anglais, [€1,149.90]. La classe [NumberFormatter] de l'extension [intl] de [PHP] connaît les conventions de tous les pays (elle utilise les données du projet CLDR d'Unicode) : on la crée pour une langue et un style (ici [CURRENCY], les montants), et [formatCurrency] met en forme un montant, la devise étant désignée par son code international ([EUR], [USD]). Le premier paramètre du filtre est la valeur filtrée ; les suivants sont ses paramètres dans la vue ([prix|monnaie('USD')]) ;
  • lignes 40-44 : la mise en forme d'une date, par la classe [IntlDateFormatter] de l'extension [intl] : le style [FULL] écrit la date en toutes lettres, avec le jour de la semaine ([dimanche 27 septembre 2026]) ; le style [NONE], pour l'heure, ne l'écrit pas. Le type [DateTimeInterface] accepte toutes les dates de [PHP] ;
  • lignes 46-49 : la langue ([fr], [en]) de la requête en cours, ou le français s'il n'y a pas de requête (dans une commande de la console, par exemple). Nous allons voir qui la fixe.

4.4.2. La langue de chaque requête : src/EventListener/FixerLangue.php

<?php
// src/EventListener/FixerLangue.php : la langue de chaque requête

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

// priority : l'ordre des écouteurs d'un même événement (le plus grand d'abord) ;
// 20 : après le routage (32), avant les écouteurs de Symfony qui utilisent la langue (16, 15)
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
class FixerLangue
{
    // le paramètre ?lang=en de l'URL, le français sinon
    public function __invoke(RequestEvent $evenement): void
    {
        $requete = $evenement->getRequest();
        $requete->setLocale($requete->query->get('lang') === 'en' ? 'en' : 'fr');
    }
}

Chaque requête a une langue (locale), qui vaut, par défaut, la langue [default_locale] de la configuration ([en] si rien n'est configuré). Ici, le paramètre [?lang=en] de l'URL choisit l'anglais, le français est la langue par défaut. Un écouteur de l'événement [kernel.request] (exemple 12) la fixe avant chaque action, par la méthode [setLocale] de la requête. Le paramètre [priority] de l'attribut règle l'ordre des écouteurs d'un même événement : les plus grands passent d'abord (0 par défaut). [Symfony] a lui-même plusieurs écouteurs de [kernel.request] : le routage (priorité 32), puis ceux qui préparent les services qui dépendent de la langue (16 et 15). Le nôtre doit s'exécuter entre les deux : 20. La commande [php bin/console debug:event-dispatcher kernel.request] affiche tous les écouteurs de l'événement, dans l'ordre. La langue ainsi fixée ne vaut que pour la requête en cours : la suivante repartira de la langue par défaut. Le chapitre sur l'internationalisation se servira de cette même langue pour traduire les textes.

4.4.3. La configuration : config/services.yaml

# config/services.yaml : les services de l'application (cf. exemple 06)
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # le contrôleur qui sert Bootstrap, commun aux exemples 15 à 28 (cf. exemple 15)
    Commun\BootstrapController: ~

    # l'extension Intl de Twig (bibliothèque twig/intl-extra) : les filtres
    # format_currency, format_date, format_number...
    Twig\Extra\Intl\IntlExtension: ~
  • ligne 15 : les filtres de la bibliothèque [twig/intl-extra] sont fournis par l'extension [IntlExtension]. Sa classe n'est pas dans le dossier [src] : on la déclare comme service, et [Symfony] la reconnaît comme extension de [Twig] ([autoconfigure]). (Dans un projet ordinaire, le paquet [twig/extra-bundle], installé par [Symfony Flex], le fait lui-même.)

4.4.4. Le contrôleur : src/Controller/PagesController.php

<?php
// src/Controller/PagesController.php : deux pages

namespace App\Controller;

use App\Modele\Produit;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route(methods: ['GET'])]
class PagesController extends AbstractController
{
    #[Route('/', name: 'accueil')]
    public function accueil(): Response
    {
        return $this->render('pages/accueil.html.twig', ['aujourdhui' => new DateTimeImmutable('today')]);
    }

    #[Route('/produits', name: 'produits')]
    public function produits(): Response
    {
        return $this->render('pages/produits.html.twig', ['produits' => [
            new Produit('clavier', 2500, 12),
            new Produit('souris', 1250, 0),
            new Produit('écran', 114990, 3),
        ]]);
    }
}

Les actions transmettent aux vues des valeurs brutes : une date ([new DateTimeImmutable('today')], la date du jour à minuit), des montants en centimes. La mise en forme est l'affaire des vues. La classe [Produit] réunit un nom, un prix (en centimes, cf. exemple 06) et un stock.

4.4.5. Les vues

{# un lien de la barre de navigation, qui reçoit la classe [active] si la page
   courante est sous le chemin [chemin]. app.request.pathInfo : le chemin de la
   requête en cours ; starts with : un test de Twig #}
{% macro lien(href, chemin, texte) %}
  <a class="nav-link{{ app.request.pathInfo starts with chemin ? ' active' }}" href="{{ href }}">{{ texte }}</a>
{% endmacro %}

{# un badge Bootstrap : vert si le produit est en stock, rouge sinon #}
{% macro badge(stock) %}
  <span class="badge {{ stock > 0 ? 'text-bg-success' : 'text-bg-danger' }}">{{ stock > 0 ? stock ~ ' en stock' : 'épuisé' }}</span>
{% endmacro %}
  • lignes 4-6 : une macro qui fabrique un lien de la barre de navigation. Le lien de la page courante reçoit la classe [active] de [Bootstrap], qui le met en évidence. Une macro voit la variable globale [app] (exemple 15) : [app.request.pathInfo] est le chemin de la requête en cours ; [starts with] est un opérateur de [Twig], qui teste le début d'une chaîne. L'opérateur ternaire sans « sinon » ([condition ? ' active']) rend une chaîne vide si la condition est fausse (exemple 14) ;
  • lignes 9-11 : un badge de [Bootstrap], vert ([text-bg-success]) ou rouge ([text-bg-danger]) selon le stock. L'opérateur [~] de [Twig] concatène deux chaînes (le [.] de [PHP]).
{% import 'macros.html.twig' as m %}
<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <span class="navbar-brand">Extensions</span>
  <div class="navbar-nav">
    {# 1. une condition écrite dans la vue ; app.current_route : le nom de la route en cours #}
    <a class="nav-link{{ app.current_route == 'accueil' ? ' active' }}" href="{{ path('accueil') }}">Accueil</a>
    {# 2. la macro [lien] #}
    {{ m.lien(path('produits'), '/produits', 'Produits') }}
    {# 3. path() fabrique l'URL d'une route nommée ; un paramètre qui n'est pas un
         paramètre de la route s'ajoute à la fin de l'URL : /produits?lang=en #}
    <a class="nav-link" href="{{ path('produits', {lang: 'en'}) }}">Products (en)</a>
  </div>
</nav>
  • ligne 1 : un fragment inclus (exemple 15) n'hérite pas des importations de la vue qui l'inclut : il importe lui-même les macros dont il a besoin ;
  • ligne 6 : la même chose qu'avec la macro, écrite directement dans la vue, avec l'opérateur ternaire. [app.current_route] est le nom de la route de la requête en cours. La macro évite de répéter la condition pour chaque lien ;
  • ligne 8 : l'appel de la macro ;
  • ligne 11 : un paramètre de [path] qui n'est pas un paramètre de la route est ajouté à l'URL, comme paramètre : [/produits?lang=en].
{% extends 'layout.html.twig' %}
{% block titre %}Accueil{% endblock %}

{% block contenu %}
  <h1>Accueil</h1>
  {# notre filtre date_longue #}
  <p>Nous sommes le {{ aujourdhui|date_longue }}.</p>
  {# app.request.locale : la langue de la requête, fixée par l'écouteur FixerLangue #}
  <p>Langue de formatage : {{ app.request.locale }}
    (<a href="{{ path('accueil', {lang: 'en'}) }}">en anglais</a>, <a href="{{ path('accueil') }}">en français</a>)</p>
{% endblock %}
  • ligne 7 : notre filtre [date_longue] ;
  • ligne 9 : [app.request.locale], la langue de la requête, fixée par l'écouteur [FixerLangue].
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}
{% block titre %}Produits{% endblock %}

{% block contenu %}
  <h1>Produits</h1>
  <table class="table table-sm w-auto">
    <thead><tr><th>Produit</th><th>Prix</th><th>Prix en dollars</th><th>Stock</th></tr></thead>
    <tbody>
      {% for produit in produits %}
        <tr>
          {# un filtre de Twig (upper), puis notre filtre monnaie, sans puis avec paramètre #}
          <td>{{ produit.nom|upper }}</td>
          <td>{{ produit.prixCentimes|monnaie }}</td>
          <td>{{ produit.prixCentimes|monnaie('USD') }}</td>
          {# la macro [badge] fabrique le HTML du badge #}
          <td>{{ m.badge(produit.stock) }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
  {# le filtre format_currency de l'extension Intl : un montant (ici en unités), une
     devise, et la langue de la requête #}
  <p>Total du stock : {{ (produits|reduce((s, p) => s + p.prixCentimes * p.stock, 0) / 100)|format_currency('EUR', locale: app.request.locale) }}</p>
{% endblock %}
  • ligne 13 : un filtre de [Twig] ;
  • lignes 14-15 : notre filtre [monnaie], avec sa devise par défaut, puis avec un paramètre ;
  • ligne 17 : l'appel de la macro [badge] ;
  • ligne 24 : le filtre [format_currency] de l'extension Intl : un montant (ici en euros : le total des stocks, calculé par [reduce], divisé par 100), une devise, et la langue de la requête, passée par un paramètre nommé de [Twig] ([locale: ...]). Cette extension fournit aussi [format_date], [format_datetime], [format_number], [country_name], [language_name]... Notre filtre [monnaie] fait donc double emploi : nous l'avons écrit pour montrer comment on écrit une extension.

Le gabarit [layout.html.twig] est une version simplifiée de celui de l'exemple 15 ; son attribut [lang] reçoit la langue de la requête ([<html lang="{{ app.request.locale }}">]).

4.4.6. Exécution

Image

Image

La même page, avec le paramètre [?lang=en]. Seule la mise en forme des nombres change : les textes, eux, sont écrits en dur dans les vues. Le chapitre sur l'internationalisation traduira aussi les textes.

Image

Un extrait du HTML produit :

curl "http://localhost:8000/produits?lang=en"
...
              <tr>
                    <td>CLAVIER</td>
          <td>€25.00</td>
          <td>$25.00</td>
                    <td>  <span class="badge text-bg-success">12 en stock</span>
</td>
        </tr>
...
    <p>Total du stock : €3,749.70</p>
...

4.5. Exemple 17 : un formulaire GET

Un formulaire HTML envoie ses champs au serveur, de l'une de deux façons, selon son attribut [method] :

  • [method="get"] : les champs sont ajoutés à l'URL, comme paramètres ([/recherche?motCle=le&genre=roman]). C'est la méthode d'un formulaire qui consulte : une recherche, un filtre. L'URL des résultats peut être mise en favori, partagée, rechargée sans risque ;
  • [method="post"] : les champs voyagent dans le corps de la requête (cf. exemple 11). C'est la méthode d'un formulaire qui modifie des données : une inscription, un ajout, une suppression.

Ce premier formulaire est une recherche de livres. Il est écrit à la main, en HTML, dans la vue : les exemples suivants confieront les formulaires au composant Form de [Symfony].

4.5.1. Le modèle : src/Modele/Livre.php et RechercheModele.php

<?php
// src/Modele/Livre.php : un livre

namespace App\Modele;

readonly class Livre
{
    public function __construct(
        public string $titre,
        public string $auteur,
        public string $genre,
    ) {
    }
}
<?php
// src/Modele/RechercheModele.php : le modèle de la vue : la saisie (pour la
// réafficher), les genres, les résultats

namespace App\Modele;

readonly class RechercheModele
{
    /**
     * @param list<string> $genres
     * @param list<Livre> $resultats
     */
    public function __construct(
        public string $motCle,
        public string $genre,
        public array $genres,
        public array $resultats,
    ) {
    }
}
  • lignes 14-19 : le modèle de la vue contient la saisie de l'utilisateur, pour que le formulaire la réaffiche, la liste des genres proposés, et les résultats.

4.5.2. Le contrôleur : src/Controller/LivresController.php

<?php
// src/Controller/LivresController.php : un formulaire de recherche, envoyé par GET

namespace App\Controller;

use App\Modele\Livre;
use App\Modele\RechercheModele;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class LivresController extends AbstractController
{
    // des CONSTANTES de classe : les données, écrites dans le code pour l'exemple
    private const GENRES = ['roman', 'poésie', 'théâtre'];

    /** @return list<Livre> */
    private static function livres(): array
    {
        return [
            new Livre('Le Rouge et le Noir', 'Stendhal', 'roman'),
            new Livre('Les Fleurs du mal', 'Baudelaire', 'poésie'),
            new Livre('Madame Bovary', 'Flaubert', 'roman'),
            new Livre('Alcools', 'Apollinaire', 'poésie'),
            new Livre('Le Cid', 'Corneille', 'théâtre'),
        ];
    }

    // GET / et GET /recherche?motCle=...&genre=... : le formulaire ET ses résultats ;
    // deux routes pour la même action : l'attribut #[Route] peut être répété
    #[Route('/', name: 'livres_accueil', methods: ['GET'])]
    #[Route('/recherche', name: 'livres_recherche', methods: ['GET'])]
    public function recherche(Request $request): Response
    {
        // getString : le paramètre, sous forme de chaîne ('' s'il est absent)
        $motCle = $request->query->getString('motCle');
        $genre = $request->query->getString('genre');
        $mot = mb_strtolower($motCle);
        $resultats = array_values(array_filter(self::livres(), fn (Livre $livre) =>
            ($mot === '' || str_contains(mb_strtolower($livre->titre), $mot)
                || str_contains(mb_strtolower($livre->auteur), $mot))
            && ($genre === '' || $livre->genre === $genre)));
        return $this->render('livres/recherche.html.twig', [
            'modele' => new RechercheModele($motCle, $genre, self::GENRES, $resultats),
        ]);
    }
}
  • ligne 16 : une constante de classe, privée, désignée par [self::GENRES] ([self] désigne la classe courante). Une constante ne peut pas contenir d'objets : les livres sont donc fournis par une méthode statique (lignes 19-28). Dans une vraie application, ils viendraient d'une base de données ;
  • lignes 32-33 : deux routes pour la même action : l'attribut [#[Route]] peut être répété. À la première visite ([/]), il n'y a pas de paramètres, et tous les livres sont affichés ;
  • lignes 37-38 : les champs du formulaire sont lus comme des paramètres de l'URL (exemple 07). La méthode [getString] rend le paramètre sous forme de chaîne, vide s'il est absent ;
  • lignes 40-43 : le filtrage. La fonction [array_filter] garde les éléments d'un tableau pour lesquels la fonction donnée rend [true] : un livre est retenu si le mot-clé est vide ou figure dans son titre ou son auteur (en minuscules des deux côtés, [mb_strtolower] traitant correctement les caractères accentués), et si le genre est vide ou est le sien. [array_filter] conserve les clés des éléments retenus (0, 2...) : [array_values] les renumérote à partir de 0 ;
  • lignes 44-46 : le modèle de la vue.

4.5.3. La vue : templates/livres/recherche.html.twig

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

{% block contenu %}
  <h1 class="h3">Recherche de livres</h1>
  {# method="get" : les champs sont envoyés dans l'URL (?motCle=...&genre=...) #}
  <form method="get" action="{{ path('livres_recherche') }}" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="motCle">Titre ou auteur</label>
      {# value : le champ réaffiche ce qui a été saisi (échappé par {{ }}) #}
      <input class="form-control" id="motCle" name="motCle" value="{{ modele.motCle }}">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(tous)</option>
        {% for genre in modele.genres %}
          {# l'attribut booléen selected, écrit si la condition est vraie, omis sinon #}
          <option value="{{ genre }}"{{ genre == modele.genre ? ' selected' }}>{{ genre }}</option>
        {% endfor %}
      </select>
    </div>
    <div class="col-auto">
      <button class="btn btn-primary" type="submit">Rechercher</button>
    </div>
  </form>
  <p>{{ modele.resultats|length }} livre(s) trouvé(s).</p>
  <ul>
    {% for livre in modele.resultats %}
      <li><i>{{ livre.titre }}</i>, {{ livre.auteur }} ({{ livre.genre }})</li>
    {% endfor %}
  </ul>
{% endblock %}
  • ligne 6 : la balise [<form>]. L'attribut [action] est l'URL à laquelle le formulaire est envoyé ; [method="get"] place les champs dans l'URL. Les classes de [Bootstrap] disposent les champs sur une ligne ;
  • ligne 8 : un libellé. Son attribut [for] désigne l'identifiant ([id]) du champ qu'il décrit : un clic sur le libellé place le curseur dans le champ, et les lecteurs d'écran lisent le libellé avec le champ ;
  • ligne 10 : une zone de saisie. Son attribut [name] est le nom du paramètre envoyé au serveur : c'est le lien entre le formulaire et l'action ([$request->query->getString('motCle')]). L'attribut [value] est la valeur affichée : la saisie précédente, réaffichée (et échappée par [{{ }}], ce qui est indispensable dans un attribut : une saisie contenant un guillemet ["] fermerait sinon l'attribut) ;
  • lignes 14-20 : une liste déroulante. Chaque [<option>] a une valeur ([value]), envoyée au serveur, et un texte, affiché. La première option, de valeur vide, signifie « tous les genres » ;
  • ligne 18 : l'option choisie à la requête précédente reçoit l'attribut [selected]. C'est un attribut booléen : sa présence suffit, il n'a pas de valeur. L'opérateur ternaire sans « sinon » l'écrit si la condition est vraie, et n'écrit rien sinon. [==] est l'égalité de [Twig] ;
  • ligne 23 : le bouton d'envoi ;
  • ligne 26 : le nombre de résultats.

4.5.4. Exécution

Image

Saisissons [le] et choisissons le genre [roman] :

Image

L'URL de la page des résultats contient les champs du formulaire : [/recherche?motCle=le&genre=roman]. La réponse, à partir du formulaire :

curl "http://localhost:8000/recherche?motCle=le&genre=roman"
...
    <form method="get" action="/recherche" class="row g-2 align-items-end mb-3">
    <div class="col-auto">
      <label class="form-label" for="motCle">Titre ou auteur</label>
            <input class="form-control" id="motCle" name="motCle" value="le">
    </div>
    <div class="col-auto">
      <label class="form-label" for="genre">Genre</label>
      <select class="form-select" id="genre" name="genre">
        <option value="">(tous)</option>
                            <option value="roman" selected>roman</option>
                            <option value="poésie">poésie</option>
                            <option value="théâtre">théâtre</option>
              </select>
    </div>
...
  </form>
  <p>1 livre(s) trouvé(s).</p>
  <ul>
          <li><i>Le Rouge et le Noir</i>, Stendhal (roman)</li>
      </ul>
...

« Madame Bovary » n'est pas retenu : « le » ne figure ni dans son titre ni dans le nom de son auteur.

4.6. Exemple 18 : les champs d'un formulaire

Cet exemple présente tous les types de champs d'un formulaire HTML, envoyé cette fois par POST. Il montre comment réafficher chaque type de champ avec sa valeur courante, comment le navigateur envoie les valeurs de chacun, et comment le serveur vérifie qu'une valeur reçue fait bien partie des choix proposés. Écrire tout cela à la main, comme à l'exemple 17, serait long et répétitif. [Symfony] le confie à son composant Form : un formulaire est décrit par une classe, son type ; [Symfony] en fabrique le HTML, lit les champs envoyés, les convertit, les valide, et réaffiche le formulaire avec la saisie et les erreurs. C'est le pendant de [WTForms] en [Python].

18-champs/
├── config/
│   └── packages/
│       ├── framework.yaml
│       └── twig.yaml
├── src/
│   ├── Controller/
│   │   └── FormulaireController.php
│   ├── Form/
│   │   └── FormulaireType.php
│   ├── Service/
│   │   └── ListesService.php
│   └── Kernel.php
├── templates/
│   ├── formulaire/
│   │   └── formulaire.html.twig
│   └── layout.html.twig
└── ...

4.6.1. Le service des listes : src/Service/ListesService.php

<?php
// src/Service/ListesService.php : les listes proposées par le formulaire. Dans une
// vraie application, elles viendraient d'une base de données.

namespace App\Service;

class ListesService
{
    // chaque liste : un tableau associatif texte affiché => valeur envoyée
    public function titres(): array
    {
        return ['Mr' => 'Mr', 'Mme' => 'Mme', 'Melle' => 'Melle'];
    }

    public function pays(): array
    {
        return ['France' => 'fr', 'Belgique' => 'be', 'Suisse' => 'ch', 'Canada' => 'ca'];
    }

    public function loisirs(): array
    {
        return ['lecture' => 'lecture', 'musique' => 'musique', 'sport' => 'sport', 'cinéma' => 'cinéma'];
    }

    public function langues(): array
    {
        return ['français' => 'français', 'anglais' => 'anglais', 'espagnol' => 'espagnol',
            'allemand' => 'allemand'];
    }
}

Les listes de choix du formulaire (titres, pays, loisirs, langues) sont fournies par un service, sur le modèle de l'exemple 06 : dans une vraie application, elles viendraient d'une base de données. Chaque liste est un tableau associatif : la clé est le texte affiché, la valeur est celle qui est envoyée au serveur (c'est l'ordre qu'attend le composant Form).

4.6.2. La configuration

# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # les messages (erreurs des formulaires) en français (cf. exemple 09)
    default_locale: fr
    translator:
        default_path: '%kernel.project_dir%/translations'
    # la SESSION : le jeton anti-CSRF des formulaires y est conservé (cf. exemple 20)
    session: true
    # la protection anti-CSRF des formulaires (cf. plus loin)
    csrf_protection: true
  • lignes 5-7 : la langue et le traducteur (exemple 09) : les messages d'erreur du composant Form, comme ceux du validateur, seront en français ;
  • ligne 9 : la session de l'utilisateur, dont nous reparlerons à l'exemple 20 : le composant Form y range le jeton de la protection anti-CSRF (voir plus loin) ;
  • ligne 11 : la protection anti-CSRF des formulaires.
1
2
3
4
5
# config/packages/twig.yaml : la configuration du TwigBundle
twig:
    default_path: '%kernel.project_dir%/templates'
    # le THÈME des formulaires : les champs sont dessinés avec les classes de Bootstrap 5
    form_themes: ['bootstrap_5_layout.html.twig']
  • ligne 5 : le thème des formulaires. [Symfony] dessine chaque champ à l'aide de blocs [Twig] ; un thème en fournit la version qui convient à un framework CSS. Le thème [bootstrap_5_layout.html.twig], livré avec [Symfony], utilise les classes de [Bootstrap] 5 ([form-control], [form-label], [is-invalid]...). L'application peut écrire ses propres thèmes, ou redéfinir quelques blocs d'un thème existant.

4.6.3. Le type du formulaire : src/Form/FormulaireType.php

<?php
// src/Form/FormulaireType.php : la DESCRIPTION du formulaire : ses champs, leur type, leurs options

namespace App\Form;

use App\Service\ListesService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;

class FormulaireType extends AbstractType
{
    // un type de formulaire est un service : il reçoit le service des listes
    public function __construct(private ListesService $listes)
    {
    }

    // les champs du formulaire, dans l'ordre de leur affichage
    public function buildForm(FormBuilderInterface $constructeur, array $options): void
    {
        $constructeur
            // boutons radio : une liste de choix (ChoiceType), "développée" (expanded) ;
            // constraints : les contraintes de validation du champ (cf. exemple 08)
            ->add('titre', ChoiceType::class, [
                'choices' => $this->listes->titres(),
                'expanded' => true,
                'label_attr' => ['class' => 'radio-inline'],
                'constraints' => [new NotBlank(message: 'Le titre est obligatoire.')],
            ])
            // une zone de saisie ; required => false : le champ n'est pas obligatoire
            ->add('nom', TextType::class, ['required' => false])
            // un mot de passe : jamais réaffiché dans la page
            ->add('mot_de_passe', PasswordType::class, ['label' => 'Mot de passe', 'required' => false])
            // une date : un champ <input type="date"> ; la valeur est une chaîne aaaa-mm-jj
            ->add('naissance', DateType::class, [
                'label' => 'Date de naissance',
                'widget' => 'single_text',
                'input' => 'string',
                'required' => false,
            ])
            // cases à cocher : une liste de choix développée, à choix MULTIPLES
            ->add('loisirs', ChoiceType::class, [
                'choices' => $this->listes->loisirs(),
                'expanded' => true,
                'multiple' => true,
                'label_attr' => ['class' => 'checkbox-inline'],
                'required' => false,
            ])
            // liste déroulante : une liste de choix non développée
            ->add('pays', ChoiceType::class, ['choices' => $this->listes->pays()])
            // liste à sélection multiple, affichée sur quatre lignes
            ->add('langues', ChoiceType::class, [
                'label' => 'Langues parlées',
                'choices' => $this->listes->langues(),
                'multiple' => true,
                'attr' => ['size' => 4],
                'required' => false,
            ])
            ->add('commentaire', TextareaType::class, ['required' => false, 'attr' => ['rows' => 2]])
            // un champ caché
            ->add('jeton', HiddenType::class);
    }
}
  • ligne 17 : un type de formulaire hérite de la classe [AbstractType]. C'est un service (il est dans le dossier [src]) : il peut recevoir d'autres services, ici celui des listes (ligne 20) ;
  • ligne 25 : la méthode [buildForm] décrit les champs, en les ajoutant ([add]) à un constructeur de formulaire (form builder) : pour chacun, son nom, son type, et ses options. Le nom du champ est aussi celui de la donnée qu'il édite ; son libellé, par défaut, est son nom, mis en forme ([nom] devient « Nom ») ;
  • lignes 30-35 : un champ [ChoiceType] est une liste de choix, décrite par l'option [choices]. Avec l'option [expanded], il devient un groupe de boutons radio (un choix unique). L'option [label_attr] donne une classe au libellé du groupe : [radio-inline] demande au thème des boutons côte à côte. L'option [constraints] donne au champ ses contraintes de validation (exemple 08) : un titre est obligatoire ;
  • ligne 37 : un champ [TextType] est une zone de saisie. L'option [required] vaut [true] par défaut : [Symfony] écrit alors l'attribut HTML [required], que le navigateur vérifie (exemple 19). Elle ne dit rien au serveur : c'est une contrainte ([NotBlank]) qui rend un champ obligatoire pour lui ;
  • ligne 39 : un mot de passe ([PasswordType]), qui n'est jamais renvoyé dans la page : son champ est toujours vide à l'affichage ;
  • lignes 41-46 : une date ([DateType]), affichée par un seul champ [<input type="date">] (option [widget]). Un tel champ échange sa valeur avec le serveur au format [aaaa-mm-jj], quel que soit le format dans lequel le navigateur l'affiche ; l'option [input] précise que la donnée est une chaîne (plutôt qu'un objet [DateTime], par défaut) ;
  • lignes 48-54 : une liste de choix développée, à choix multiples ([multiple]) : des cases à cocher ;
  • ligne 56 : une liste de choix non développée : une liste déroulante ;
  • lignes 58-64 : une liste non développée, à choix multiples : une liste à sélection multiple, affichée sur quatre lignes (l'attribut HTML [size], donné par l'option [attr]) ;
  • ligne 65 : une zone de texte ([TextareaType]), sur deux lignes ;
  • ligne 67 : un champ caché ([HiddenType]).

[Symfony] fournit une trentaine de types de champs : [EmailType], [IntegerType], [MoneyType], [UrlType], [CheckboxType], [FileType], [CountryType], [RepeatedType] (exemple 19), [CollectionType] (une liste de sous-formulaires)...

4.6.4. Le contrôleur : src/Controller/FormulaireController.php

<?php
// src/Controller/FormulaireController.php : un formulaire qui utilise tous les types de champs

namespace App\Controller;

use App\Form\FormulaireType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class FormulaireController extends AbstractController
{
    // GET : le formulaire, avec des valeurs initiales ; POST : les champs envoyés
    // sont lus, validés, puis réaffichés par le même formulaire
    #[Route('/formulaire', name: 'formulaire', methods: ['GET', 'POST'])]
    public function formulaire(Request $request): Response
    {
        // les valeurs initiales (une par champ, un tableau pour un champ multiple)
        $initiales = ['titre' => 'Mme', 'pays' => 'fr', 'langues' => ['français'], 'jeton' => 'A7X2'];
        // createForm : le formulaire, construit d'après sa description
        $formulaire = $this->createForm(FormulaireType::class, $initiales);
        // handleRequest : si la requête est un POST, les champs reçus sont recopiés
        // dans le formulaire, puis validés
        $formulaire->handleRequest($request);
        $modele = ['formulaire' => $formulaire];
        if ($formulaire->isSubmitted()) {
            // ce que le navigateur a réellement envoyé, champ par champ
            $recu = [];
            foreach ($request->request->all() as $nom => $valeur) {
                foreach ((array) $valeur as $cle => $v) {
                    $recu[] = "{$nom}[$cle] = [".implode(', ', (array) $v).']';
                }
            }
            $modele += [
                'recu' => implode("\n", $recu),
                'valide' => $formulaire->isValid(),
                // getErrors(true) : les erreurs de tous les champs
                'erreurs' => $formulaire->getErrors(true),
                // getData() : les valeurs du formulaire, converties par chaque champ
                'donnees' => $formulaire->getData(),
            ];
        }
        return $this->render('formulaire/formulaire.html.twig', $modele);
    }
}
  • ligne 16 : une seule action, pour les deux méthodes : GET affiche le formulaire, POST le traite. C'est la façon habituelle d'écrire une action de formulaire avec [Symfony] ;
  • ligne 20 : les valeurs initiales du formulaire : un tableau associatif, une valeur par champ (un tableau pour un champ à valeurs multiples) ;
  • ligne 22 : [createForm] fabrique le formulaire, un objet [Form], d'après son type et ses données initiales ;
  • ligne 25 : [handleRequest] examine la requête. Pour un GET, il ne fait rien. Pour un POST qui contient les champs du formulaire, il les recopie dans le formulaire : chaque champ convertit sa valeur (la date, les listes...), vérifie qu'un choix fait partie de ceux qui étaient proposés, puis le formulaire valide les contraintes et le jeton anti-CSRF ;
  • ligne 27 : [isSubmitted] : le formulaire a-t-il été envoyé ? ;
  • lignes 29-34 : pour l'exemple, la liste de ce que le navigateur a réellement envoyé. Les champs d'un formulaire [Symfony] sont nommés d'après le formulaire : [formulaire[nom]], [formulaire[loisirs][]]. [PHP] les range donc dans un tableau [formulaire] (exemple 10), que l'on parcourt. [(array) $valeur] convertit une valeur simple en tableau d'un élément (un tableau reste un tableau) ; [implode] réunit les éléments d'un tableau en une chaîne, séparés par le premier paramètre ;
  • lignes 35-42 : le modèle de la vue reçoit ce qu'a reçu le serveur, le résultat de la validation ([isValid]), les erreurs ([getErrors(true)] : celles de tous les champs), et les données du formulaire ([getData]), converties par chaque champ. L'opérateur [+=] ajoute ces clés au tableau [$modele] ;
  • ligne 44 : le formulaire est passé à la vue, qui l'affichera, avec sa saisie et ses erreurs.

4.6.5. La vue : templates/formulaire/formulaire.html.twig

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

{% block contenu %}
  <h1 class="h3">Un formulaire complet</h1>
  {# form_start : la balise <form>, ici avec la méthode POST et l'URL de la page #}
  {{ form_start(formulaire, {attr: {style: 'max-width: 40rem'}}) }}
    {# form_row : un champ complet (libellé, zone de saisie, erreurs) #}
    {{ form_row(formulaire.titre) }}
    <div class="row g-2">
      <div class="col">{{ form_row(formulaire.nom) }}</div>
      <div class="col">{{ form_row(formulaire.mot_de_passe) }}</div>
      <div class="col">{{ form_row(formulaire.naissance) }}</div>
    </div>
    {{ form_row(formulaire.loisirs) }}
    <div class="row g-2">
      <div class="col">{{ form_row(formulaire.pays) }}</div>
      <div class="col">{{ form_row(formulaire.langues) }}</div>
    </div>
    {{ form_row(formulaire.commentaire) }}
    <button class="btn btn-primary" type="submit">Envoyer</button>
  {# form_end : les champs qui restent (le champ caché, le jeton anti-CSRF), puis </form> #}
  {{ form_end(formulaire) }}

  {# is defined : vrai si la variable recu a été passée à la vue (formulaire envoyé) #}
  {% if recu is defined %}
    <h2 class="h5 mt-3">Ce que le serveur a reçu</h2>
    <pre class="bg-light p-2 border">{{ recu }}</pre>
    <p>Formulaire valide : {{ valide ? 'oui' : 'non' }}</p>
    <ul>
      {% for erreur in erreurs %}
        {# erreur.origin : le champ en erreur ; erreur.message : le message #}
        <li>{{ erreur.origin.name }} : {{ erreur.message }}</li>
      {% endfor %}
    </ul>
    {% if valide %}
      <p>Les données du formulaire :</p>
      <ul>
        {# une boucle sur un tableau associatif : la clé et la valeur #}
        {% for cle, valeur in donnees %}
          <li>{{ cle }} : {{ valeur is iterable ? valeur|join(', ') : valeur }}</li>
        {% endfor %}
      </ul>
    {% endif %}
  {% endif %}
{% endblock %}
  • ligne 6 : la fonction [form_start] écrit la balise [<form>] : la méthode POST et l'URL de la page en cours, par défaut ; le second paramètre ajoute des attributs HTML ;
  • ligne 8 : la fonction [form_row] écrit un champ complet : son libellé, sa zone de saisie (avec sa valeur courante), et ses erreurs, avec les classes du thème. [formulaire.titre] désigne le champ [titre] du formulaire. On peut aussi écrire séparément les morceaux : [form_label], [form_widget], [form_errors] ;
  • lignes 9-19 : les autres champs, disposés par les classes de grille de [Bootstrap] ;
  • ligne 22 : [form_end] écrit les champs qui n'ont pas encore été écrits (ici le champ caché [jeton] et le champ caché [_token], le jeton anti-CSRF), puis ferme la balise [</form>] ;
  • ligne 25 : le test [is defined] : la variable [recu] n'existe qu'après un envoi ;
  • lignes 30-33 : les erreurs. Chacune est un objet [FormError], dont [origin] est le champ en erreur ;
  • lignes 39-41 : une boucle [for] sur un tableau associatif, qui parcourt à la fois les clés et les valeurs. Le test [is iterable] distingue les tableaux (réunis par le filtre [join]) des valeurs simples.

On aurait pu écrire tout le formulaire d'une ligne : [{{ form(formulaire) }}]. Les fonctions [form_start], [form_row] et [form_end] permettent de disposer les champs à sa guise.

4.6.6. Exécution

Image

Les valeurs initiales sont visibles : le bouton radio « Mme », le pays « France », la langue « français ». Le HTML produit (extraits, présentés sur plusieurs lignes) :

<form name="formulaire" method="post" style="max-width: 40rem">
  <fieldset class="mb-3"><legend class="col-form-label required">Titre</legend>
    <div id="formulaire_titre">
      <div class="form-check form-check-inline">
        <input type="radio" id="formulaire_titre_0" name="formulaire[titre]" required="required" class="form-check-input" value="Mr" />
        <label class="form-check-label required" for="formulaire_titre_0">Mr</label></div>
      <div class="form-check form-check-inline">
        <input type="radio" id="formulaire_titre_1" name="formulaire[titre]" required="required" class="form-check-input" value="Mme" checked="checked" />
...
  <div class="mb-3"><label for="formulaire_nom" class="form-label">Nom</label>
    <input type="text" id="formulaire_nom" name="formulaire[nom]" class="form-control" /></div>
...
    <input type="checkbox" id="formulaire_loisirs_0" name="formulaire[loisirs][]" class="form-check-input" value="lecture" />
...
  <select id="formulaire_pays" name="formulaire[pays]" class="form-select">
    <option value="fr" selected="selected">France</option><option value="be">Belgique</option>...
  <select id="formulaire_langues" name="formulaire[langues][]" size="4" class="form-select" multiple="multiple">
    <option value="français" selected="selected">français</option>...
...
  <input type="hidden" id="formulaire_jeton" name="formulaire[jeton]" value="A7X2" />
  <input type="hidden" id="formulaire__token" name="formulaire[_token]" data-controller="csrf-protection" value="985f85bacf81.q7yYwKlojJdFW9i4_GTnBTStHVUOPZTNde7IbD2x39U.0fvbo_kO39kpNOzdij6AdlHoVQBMRM6mP9ysBET4nL-f1NH4nATZ7XUi4A" />
</form>

[Symfony] a tout écrit : les identifiants ([formulaire_titre_1]), les libellés reliés à leurs champs, les noms terminés par [[]] des champs multiples (exemple 10), les attributs [checked] et [selected] des valeurs courantes, les classes de [Bootstrap]. Le dernier champ caché, [formulaire[_token]], porte le jeton anti-CSRF. Une attaque CSRF (Cross-Site Request Forgery, falsification de requête inter-sites) consiste, pour un site malveillant, à faire envoyer par le navigateur de sa victime un formulaire vers notre application. Le navigateur y joint les cookies de notre application, et donc la session de l'utilisateur connecté : la requête semble légitime. La parade : chaque formulaire de notre application contient un jeton, aléatoire, lié à la session de l'utilisateur, que le site malveillant ne peut pas connaître. Le composant Form ajoute ce jeton à chaque formulaire, et [handleRequest] refuse un formulaire dont le jeton est absent ou faux : aucun formulaire ne peut l'oublier. (L'attribut [data-controller] sert à un script facultatif de [Symfony], qui renforce la protection ; il est sans effet ici.) Remplissons le formulaire et envoyons-le :

Image

La page montre ce qu'a reçu le serveur : un paramètre par champ, avec plusieurs valeurs pour les cases à cocher et la liste multiple ; le mot de passe a été envoyé, mais n'est pas réaffiché dans son champ. Le champ caché [jeton] a été renvoyé tel quel : c'est le moyen de faire faire un aller-retour à une information que l'utilisateur ne voit pas (l'étude de cas s'en sert pour le numéro de version d'un enregistrement). Mais un champ caché n'est pas un champ protégé : l'utilisateur peut le modifier avec les outils de développement de son navigateur, et le serveur doit le vérifier comme tout autre champ. Le jeton anti-CSRF fait lui aussi partie des champs reçus ; il ne fait pas partie des données du formulaire.

Envoyons maintenant, avec [curl], un formulaire sans jeton, et avec un pays qui n'était pas proposé :

curl -g -d "formulaire[nom]=Martin&formulaire[pays]=xx" http://localhost:8000/formulaire
...
    <h2 class="h5 mt-3">Ce que le serveur a reçu</h2>
    <pre class="bg-light p-2 border">formulaire[nom] = [Martin]
formulaire[pays] = [xx]</pre>
    <p>Formulaire valide : non</p>
    <ul>
                      <li>formulaire : Le jeton CSRF est invalide. Veuillez renvoyer le formulaire.</li>
                      <li>titre : Le titre est obligatoire.</li>
                      <li>pays : Le choix sélectionné est invalide.</li>
          </ul>
...

Le formulaire est invalide, pour trois raisons : le jeton anti-CSRF manque (l'erreur est attachée au formulaire lui-même), aucun titre n'a été envoyé, et le pays [xx] ne fait pas partie des choix. Ce dernier contrôle est fait par le champ [ChoiceType] lui-même, sans que nous l'ayons demandé : un utilisateur malveillant peut envoyer n'importe quoi, pas seulement ce que proposait la page. Les messages sont en français : ceux du composant Form sont traduits par [Symfony], comme ceux du validateur (exemple 09). La réponse a le code 422 (Unprocessable Content) : [render] le choisit de lui-même quand on lui passe un formulaire envoyé et invalide. Remarquons la différence avec d'autres frameworks : [Symfony] ne refuse pas la requête avant l'action ; c'est le formulaire qui la déclare invalide, et l'action qui décide de la suite.

Pour que le jeton soit accepté, [curl] doit se comporter comme un navigateur : demander d'abord le formulaire, en conservant le cookie de session (option [-c], qui enregistre les cookies reçus dans un fichier), puis renvoyer le cookie (option [-b]) et le jeton lu dans la page. L'option [--data-urlencode] encode elle-même la valeur du jeton :

curl -c cookies.txt http://localhost:8000/formulaire
curl -b cookies.txt -g --data-urlencode "formulaire[_token]=fd1f4224ba.KXZM..." -d "formulaire[nom]=Martin&formulaire[pays]=xx" http://localhost:8000/formulaire
1
2
3
4
5
6
...
    <ul>
                      <li>titre : Le titre est obligatoire.</li>
                      <li>pays : Le choix sélectionné est invalide.</li>
          </ul>
...

(le jeton est celui de la page reçue par la première commande). Il ne reste que les deux erreurs de saisie.

4.7. Exemple 19 : la validation d'un formulaire

Un formulaire d'inscription, dont chaque champ a ses contraintes. En cas d'erreur, le formulaire est réaffiché avec la saisie de l'utilisateur et un message sous chaque champ erroné. Cet exemple utilise la démarche standard de [Symfony] : les données du formulaire sont un objet, dont la classe porte les contraintes (exemple 08) ; le formulaire remplit l'objet, puis le valide.

19-validation-formulaire/
├── src/
│   ├── Controller/
│   │   └── InscriptionController.php
│   ├── Form/
│   │   └── InscriptionType.php
│   ├── Modele/
│   │   └── Inscription.php
│   └── Kernel.php
├── templates/
│   ├── inscription/
│   │   ├── bienvenue.html.twig
│   │   └── inscription.html.twig
│   └── layout.html.twig
└── ...

Les fichiers [config/packages/framework.yaml] et [config/packages/twig.yaml] sont ceux de l'exemple 18.

4.7.1. Les données et leurs contraintes : src/Modele/Inscription.php

<?php
// src/Modele/Inscription.php : les données du formulaire d'inscription, et leurs contraintes

namespace App\Modele;

use Symfony\Component\Validator\Constraints as Assert;

class Inscription
{
    #[Assert\NotBlank(message: 'Le nom est obligatoire.')]
    #[Assert\Length(max: 20, maxMessage: 'Le nom ne doit pas dépasser 20 caractères.')]
    public ?string $nom = null;

    // Email : une adresse valide, avec un nom de domaine complet (exemple.fr, et non exemple)
    #[Assert\NotBlank(message: "L'adresse électronique est obligatoire.")]
    #[Assert\Email(message: 'Adresse électronique invalide.')]
    public ?string $email = null;

    #[Assert\NotBlank(message: "L'âge est obligatoire.")]
    #[Assert\Range(notInRangeMessage: "L'âge doit être compris entre 18 et 120 ans.", min: 18, max: 120)]
    public ?int $age = null;

    #[Assert\NotBlank(message: 'Le mot de passe est obligatoire.')]
    #[Assert\Length(min: 6, minMessage: 'Le mot de passe doit comporter au moins 6 caractères.')]
    public ?string $motDePasse = null;
}
  • ligne 8 : une classe ordinaire, dont les propriétés sont publiques et modifiables : le formulaire les remplira une à une. Toutes sont initialisées à [null] : l'objet est vide au premier affichage ;
  • lignes 10-12 : le nom est obligatoire, et ne dépasse pas 20 caractères. Chaque contrainte reçoit ici son message, en français (exemple 09) : [message] pour [NotBlank], [maxMessage] pour le dépassement de [Length] ;
  • lignes 15-17 : la contrainte [Email] vérifie qu'une adresse est valide, et exige un nom de domaine complet ([exemple.fr], et non [exemple]) ;
  • lignes 19-21 : l'âge est un entier, compris entre 18 et 120 ([notInRangeMessage] : le message de [Range] quand il y a un minimum et un maximum). Le formulaire aura converti la saisie en entier ;
  • lignes 23-25 : le mot de passe comporte au moins 6 caractères.

4.7.2. Le formulaire : src/Form/InscriptionType.php

<?php
// src/Form/InscriptionType.php : les champs du formulaire d'inscription

namespace App\Form;

use App\Modele\Inscription;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class InscriptionType extends AbstractType
{
    public function buildForm(FormBuilderInterface $constructeur, array $options): void
    {
        $constructeur
            // attr : des attributs HTML en plus sur la zone de saisie (pour la
            // validation par le navigateur, cf. plus loin)
            ->add('nom', TextType::class, ['attr' => ['maxlength' => 20]])
            ->add('email', EmailType::class, ['label' => 'Adresse électronique'])
            // IntegerType : un nombre entier ; une saisie qui n'en est pas un produit
            // l'erreur invalid_message
            ->add('age', IntegerType::class, [
                'label' => 'Âge',
                'attr' => ['min' => 18, 'max' => 120],
                'invalid_message' => 'Un nombre entier est attendu.',
            ])
            // RepeatedType : DEUX champs qui doivent recevoir la même valeur (le mot
            // de passe et sa confirmation) ; une seule valeur dans les données
            ->add('motDePasse', RepeatedType::class, [
                'type' => PasswordType::class,
                'first_options' => ['label' => 'Mot de passe', 'attr' => ['minlength' => 6]],
                'second_options' => ['label' => 'Confirmation'],
                'invalid_message' => 'La confirmation ne correspond pas au mot de passe.',
            ]);
    }

    // les options du formulaire : ses données sont un objet Inscription
    public function configureOptions(OptionsResolver $options): void
    {
        $options->setDefaults(['data_class' => Inscription::class]);
    }
}
  • ligne 23 : l'option [attr] ajoute des attributs HTML à la zone de saisie : [maxlength] limite la saisie dans le navigateur ;
  • ligne 24 : un champ [EmailType] produit un [<input type="email">], que le navigateur sait vérifier ;
  • lignes 27-31 : un champ [IntegerType] produit un [<input type="number">], et convertit la saisie en entier. Une saisie qui n'est pas un nombre entier ne peut pas être convertie : c'est une erreur de conversion (et non de validation, cf. exemple 08), dont le message est donné par l'option [invalid_message] ;
  • lignes 34-39 : un champ [RepeatedType] est fait de deux champs (ici deux [PasswordType]), qui doivent recevoir la même valeur : le mot de passe et sa confirmation. Chacun a ses options ([first_options], [second_options]) ; si les deux saisies diffèrent, c'est l'erreur [invalid_message]. Les données n'ont qu'une valeur, le mot de passe ;
  • lignes 43-46 : la méthode [configureOptions] règle les options du formulaire lui-même : [data_class] indique que ses données sont un objet [Inscription]. Chaque champ lit et écrit la propriété de même nom.

[Symfony] sait aussi deviner le type d'un champ à partir des contraintes et du type de la propriété : [->add('age')], sans type, produirait un [IntegerType], avec les attributs [min] et [max] déduits de [Range]. Nous l'avons écrit explicitement, pour la clarté.

4.7.3. Le contrôleur : src/Controller/InscriptionController.php

<?php
// src/Controller/InscriptionController.php : un formulaire validé par le serveur

namespace App\Controller;

use App\Form\InscriptionType;
use App\Modele\Inscription;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;

class InscriptionController extends AbstractController
{
    // GET /inscription : le formulaire ; ?html5=true : on laisse le navigateur le
    // valider avant l'envoi. POST /inscription : le formulaire envoyé
    #[Route('/inscription', name: 'inscription', methods: ['GET', 'POST'])]
    public function inscription(Request $request, #[MapQueryParameter] bool $html5 = false): Response
    {
        $inscription = new Inscription();
        $formulaire = $this->createForm(InscriptionType::class, $inscription);
        $formulaire->handleRequest($request);
        // isValid : les champs ont pu être convertis, et l'objet Inscription
        // respecte ses contraintes
        if ($formulaire->isSubmitted() && $formulaire->isValid()) {
            return $this->render('inscription/bienvenue.html.twig', ['inscription' => $inscription]);
        }
        // le premier affichage, ou un formulaire invalide, réaffiché avec la saisie
        // et les erreurs (render le renvoie alors avec le code 422)
        return $this->render('inscription/inscription.html.twig',
            ['formulaire' => $formulaire, 'html5' => $html5]);
    }
}
  • ligne 19 : le paramètre [?html5=true] active la validation par le navigateur (voir plus loin). [#[MapQueryParameter]] le convertit en booléen (exemple 07) ;
  • lignes 21-23 : l'objet [Inscription], vide, est confié au formulaire ; [handleRequest] le remplira à partir des champs envoyés ;
  • lignes 26-28 : [isValid] est vrai si chaque champ a pu être converti, et si l'objet [Inscription] respecte ses contraintes. La page de bienvenue reçoit alors l'objet, rempli (l'exemple suivant montrera qu'il vaut mieux, après un POST réussi, rediriger) ;
  • lignes 31-32 : sinon, le formulaire est affiché : au premier affichage, vide ; après un envoi invalide, avec la saisie et les erreurs, et le code 422 (exemple 18).

Le formulaire invalide est donc réaffiché par l'action POST elle-même, qui dispose de la saisie et des erreurs : c'est la démarche de [Symfony]. D'autres frameworks, comme [Laravel], répondent plutôt par une redirection vers la page du formulaire, en conservant saisie et erreurs dans la session pour la requête suivante.

4.7.4. Les vues

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

{% block contenu %}
  <h1 class="h3">Inscription</h1>
  {% if html5 %}
    <p>Validation par le navigateur <b>activée</b> - <a href="{{ path('inscription') }}">la désactiver</a></p>
  {% else %}
    <p>Validation par le navigateur <b>désactivée</b> - <a href="{{ path('inscription', {html5: 'true'}) }}">l'activer</a></p>
  {% endif %}
  {# novalidate : le navigateur ignore les attributs required, min... et envoie
     le formulaire tel quel ; seul le serveur valide #}
  {{ form_start(formulaire, {attr: html5 ? {} : {novalidate: 'novalidate'}}) }}
    {# form_widget : tous les champs du formulaire, chacun avec son libellé et ses erreurs #}
    {{ form_widget(formulaire) }}
    <button class="btn btn-primary" type="submit">S'inscrire</button>
  {{ form_end(formulaire) }}
{% endblock %}

{% block bas_de_page %}
  {# l'erreur globale, en bas de page, sur fond rose léger. vars : les
     informations du formulaire (envoyé ? valide ?) #}
  {% if formulaire.vars.submitted and not formulaire.vars.valid %}
    <div class="zone-erreur" role="alert">Le formulaire contient des erreurs.</div>
  {% endif %}
{% endblock %}
  • ligne 12 : l'attribut [novalidate] désactive la validation par le navigateur : le formulaire est envoyé tel quel, et seul le serveur le valide. C'est ce que nous voulons observer d'abord. L'opérateur ternaire choisit entre deux tableaux d'attributs ;
  • ligne 14 : [form_widget(formulaire)], appliqué au formulaire entier, écrit tous ses champs, chacun avec son libellé, sa saisie et ses erreurs : c'est le thème de [Bootstrap] qui encadre en rouge un champ erroné (classe [is-invalid]) et affiche son message dessous (classe [invalid-feedback]). Nous n'avons eu à écrire aucun composant pour cela ;
  • lignes 19-25 : le bloc [bas_de_page], défini par le gabarit (voir plus loin). [formulaire.vars] contient les informations du formulaire, calculées pour la vue : [submitted] (envoyé ?), [valid] (valide ?), [value], [errors]... Si le formulaire a été envoyé et qu'il est invalide, un message général, dans une zone dont le style est défini par [public/css/styles.css] (exemple 15). L'attribut [role="alert"] le signale aux lecteurs d'écran.
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inscription</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {fichier: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    <main>
      {% block contenu %}{% endblock %}
    </main>
    {# un bloc que les pages peuvent remplir, en bas de page #}
    {% block bas_de_page %}{% endblock %}
  </div>
</body>
</html>
  • ligne 15 : un second bloc, [bas_de_page], vide par défaut : un gabarit peut avoir autant de blocs qu'il en faut.
1
2
3
4
5
6
7
8
{% extends 'layout.html.twig' %}

{% block contenu %}
  {# inscription : l'objet Inscription, rempli par le formulaire #}
  <h1 class="h3">Bienvenue {{ inscription.nom }}</h1>
  <p>Votre inscription est enregistrée : adresse <b>{{ inscription.email }}</b>,
    âge <b>{{ inscription.age }}</b> ans.</p>
{% endblock %}

[inscription] est l'objet [Inscription], rempli et validé : le nom a été débarrassé de ses espaces (voir plus loin), l'âge est un entier.

4.7.5. Exécution

Envoyons un formulaire plein d'erreurs : un nom fait d'espaces, une adresse sans domaine complet, un âge de 12 ans, un mot de passe trop court, et une confirmation différente :

Image

La saisie a été conservée (sauf les mots de passe), et chaque erreur est signalée sous son champ. Le nom fait d'espaces est signalé comme absent : le composant Form retire les espaces qui entourent chaque saisie (option [trim] des champs, vraie par défaut). Le mot de passe, lui, n'a qu'une erreur, celle de la confirmation : quand les deux saisies diffèrent, le champ [RepeatedType] ne fournit aucune valeur, et la longueur n'est pas vérifiée. L'URL est celle du formulaire, [/inscription] : la page a été affichée par l'action, en réponse au POST. Avec [curl], en fournissant le jeton anti-CSRF comme à l'exemple 18, on peut aussi envoyer ce qu'un navigateur n'enverrait pas, un âge non numérique :

curl -i -b cookies.txt -g --data-urlencode "inscription[_token]=..." -d "inscription[nom]=Martin&inscription[email]=martin@exemple.fr&inscription[age]=abc&inscription[motDePasse][first]=abcdef&inscription[motDePasse][second]=abcdef" http://localhost:8000/inscription
1
2
3
4
5
HTTP/1.1 422 Unprocessable Content
...
<input type="number" id="inscription_age" name="inscription[age]" required="required" min="18" max="120" aria-describedby="inscription_age_error1" aria-invalid="true" class="form-control is-invalid" value="abc" />
<div class="invalid-feedback d-block" id="inscription_age_error1">Un nombre entier est attendu.</div>
...

On voit la saisie réaffichée, les attributs HTML5 ([required], [min], [max]), la classe [is-invalid], le message de conversion, et les attributs [aria-...], qui relient le champ à son message pour les lecteurs d'écran.

4.7.6. La validation par le navigateur

Supprimons l'attribut [novalidate], en suivant le lien « l'activer » (paramètre [?html5=true]). Le navigateur vérifie alors lui-même les attributs [required], [type="email"], [min], [maxlength]... avant d'envoyer le formulaire, et affiche ses propres messages, dans une bulle placée sous le premier champ erroné (« Veuillez renseigner ce champ », « La valeur doit être supérieure ou égale à 18 »...) : le formulaire n'est pas envoyé tant qu'il reste une erreur. [Symfony] a écrit ces attributs de lui-même : [required] pour tout champ (option [required], exemple 18), [type="email"] et [type="number"] d'après le type des champs ; [maxlength], [min], [max] et [minlength] viennent de nos options [attr]. Ils doivent rester cohérents avec les contraintes.

Cette validation est confortable pour l'utilisateur : immédiate, sans aller-retour avec le serveur. Mais elle ne remplace pas celle du serveur : un utilisateur peut la désactiver, et un programme comme [curl] l'ignore complètement. La règle est absolue : le serveur valide toujours, la validation par le navigateur n'est qu'un confort en plus.

Une saisie correcte, enfin. Le nom, saisi avec des espaces autour, a été nettoyé par le formulaire :

Image

4.8. Exemple 20 : le schéma Post / Redirect / Get

Quand une action POST répond par une page, cette page est le résultat d'un POST. Si l'utilisateur la recharge (touche [F5]), le navigateur renvoie le POST, après un avertissement que peu de gens lisent : la commande est passée deux fois, le message publié deux fois. Le schéma Post / Redirect / Get (PRG) évite ce problème : une action POST qui a réussi ne rend pas de page ; elle répond par une redirection (exemple 04) vers une page GET, que le navigateur demande aussitôt. Recharger cette page ne fait que refaire le GET.

Il reste un problème : la page GET voudrait afficher un message (« la tâche a été ajoutée »), mais c'est l'action POST qui le connaît, et elle ne peut pas transmettre de variable à une requête qui n'a pas encore eu lieu. La solution : le message flash, déposé dans la session par l'action POST, conservé jusqu'à ce qu'il soit lu, affiché, puis oublié.

4.8.1. La configuration : config/packages/framework.yaml

# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # la SESSION : ses données sont rangées sur le serveur, un fichier par session,
    # dans le dossier var/sessions/dev (handler_id: null : les fichiers de PHP lui-même)
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    # les VERROUS (composant Lock) : des fichiers verrouillés (flock) du dossier var/lock
    lock: 'flock://%kernel.project_dir%/var/lock'
  • lignes 6-8 : la session de l'utilisateur (chapitre sur les portées) : des données propres à un navigateur, conservées d'une requête à la suivante. Elles sont rangées sur le serveur ; le navigateur n'en détient qu'un identifiant, dans un cookie. [handler_id: null] confie leur enregistrement à [PHP] lui-même, qui range chaque session dans un fichier ; [save_path] choisit le dossier : [var/sessions/dev], pour que nous puissions les examiner. [%kernel.environment%] est l'environnement ([dev]) ;
  • ligne 10 : les verrous (le composant Lock, présenté plus bas), rangés dans le dossier [var/lock].

4.8.2. Le service : src/Service/TachesService.php

<?php
// src/Service/TachesService.php : la liste des tâches, partagée par tous les
// utilisateurs. En PHP, les objets ne survivent pas à la requête : la liste est
// rangée dans le CACHE de l'application (ici, un fichier du dossier var/cache/dev/pools)

namespace App\Service;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

class TachesService
{
    private const CLE = 'taches';

    // le cache de l'application (le service cache.app) et la fabrique de verrous
    public function __construct(
        private CacheItemPoolInterface $cache,
        private LockFactory $verrous,
    ) {
    }

    /** @return list<string> */
    public function liste(): array
    {
        // l'élément de cache rangé sous la clé [taches] ; isHit : il existe
        $element = $this->cache->getItem(self::CLE);
        return $element->isHit() ? $element->get() : ['Lire le chapitre 3'];
    }

    public function ajouter(string $libelle): void
    {
        // plusieurs requêtes peuvent s'exécuter EN MÊME TEMPS (le serveur les
        // traite dans plusieurs processus) : un verrou garantit qu'une seule à la
        // fois lit, modifie et réécrit la liste
        $verrou = $this->verrous->createLock('taches');
        // acquire(true) : attendre que le verrou soit libre, puis le prendre
        $verrou->acquire(true);
        try {
            $taches = $this->liste();
            $taches[] = $libelle;
            // l'élément de cache reçoit sa nouvelle valeur, sans limite de durée
            $element = $this->cache->getItem(self::CLE);
            $this->cache->save($element->set($taches));
        } finally {
            // finally : le verrou est rendu dans tous les cas, même après une exception
            $verrou->release();
        }
    }
}

La liste des tâches est partagée par tous les utilisateurs, et doit survivre d'une requête à la suivante. En [Java], en [C#] ou en [Python], on la garderait en mémoire, dans un service unique. En [PHP], c'est impossible : nous l'avons vu aux exemples 01 et 06, rien ne survit à la requête. La liste doit donc être rangée hors de [PHP] : dans une base de données, un fichier... ou dans le cache de l'application, que nous utilisons ici.

  • lignes 16-20 : le service reçoit deux services de [Symfony]. Le premier est le cache de l'application (le service [cache.app], du composant Cache), un magasin de couples clé → valeur, présenté par l'interface normalisée [CacheItemPoolInterface] (la norme PSR-6). Par défaut, il range ses valeurs dans des fichiers, sous le dossier [var/cache/dev/pools] ; en production, on préfère un serveur spécialisé, comme [Redis] (une ligne de configuration). Le second est la fabrique de verrous du composant Lock ;
  • lignes 23-28 : [getItem] rend l'élément du cache rangé sous une clé ; [isHit] indique s'il existe, [get] rend sa valeur. Une valeur quelconque (ici un tableau de chaînes) peut être rangée : [Symfony] la sérialise, c'est-à-dire qu'il la transforme en texte, à l'aide de la fonction [serialize] de [PHP] ;
  • lignes 35-47 : le serveur de développement, comme un serveur de production, peut traiter plusieurs requêtes en même temps, dans plusieurs processus (la variable d'environnement [PHP_CLI_SERVER_WORKERS] en fixe le nombre pour [php -S]). Deux ajouts simultanés pourraient lire la même liste, y ajouter chacun sa tâche, et la réécrire : la seconde écriture effacerait la première tâche. Un verrou garantit qu'une seule requête à la fois exécute le code qu'il protège : [createLock('taches')] crée un verrou nommé ; [acquire(true)] attend qu'il soit libre, puis le prend ; [release()] le rend. Ici, le verrou est un fichier du dossier [var/lock], verrouillé par le système d'exploitation (la fonction [flock]) ; plusieurs serveurs partageraient plutôt un verrou rangé dans [Redis] ou dans une base de données ;
  • lignes 38-47 : le bloc [try] ... [finally] garantit que le verrou est rendu dans tous les cas, même si une exception interrompt le code qu'il protège ;
  • lignes 42-43 : l'élément de cache reçoit sa nouvelle valeur ([set]), puis il est enregistré ([save]). Sans durée de vie précisée, il est conservé sans limite ([expiresAfter(3600)] le garderait une heure).

4.8.3. Le contrôleur : src/Controller/TachesController.php

<?php
// src/Controller/TachesController.php : le schéma Post / Redirect / Get

namespace App\Controller;

use App\Service\TachesService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class TachesController extends AbstractController
{
    public function __construct(private TachesService $taches)
    {
    }

    // GET /taches : la liste et le formulaire d'ajout
    #[Route('/taches', name: 'taches', methods: ['GET'])]
    public function afficher(): Response
    {
        return $this->render('taches/taches.html.twig', ['taches' => $this->taches->liste()]);
    }

    // POST /taches : le schéma Post / Redirect / Get
    #[Route('/taches', name: 'taches_ajouter', methods: ['POST'])]
    public function ajouter(Request $request): Response
    {
        $libelle = trim($request->request->getString('libelle'));
        if ($libelle === '' || mb_strlen($libelle) > 40) {
            // saisie invalide : on réaffiche la page, sans redirection
            return $this->render('taches/taches.html.twig', ['taches' => $this->taches->liste(),
                'libelle' => $libelle,
                'erreur' => 'Le libellé doit comporter de 1 à 40 caractères.']);
        }
        $this->taches->ajouter($libelle);
        // addFlash dépose un message "flash" dans la session : il survivra à la
        // redirection, pour la requête SUIVANTE
        $this->addFlash('succes', "La tâche [$libelle] a été ajoutée.");
        // pas de page : une redirection vers la page GET
        return $this->redirectToRoute('taches');
    }

    // POST /taches/sans-prg : le contre-exemple (une page en réponse au POST)
    #[Route('/taches/sans-prg', name: 'taches_sans_prg', methods: ['POST'])]
    public function ajouterSansPrg(Request $request): Response
    {
        $this->taches->ajouter(trim($request->request->getString('libelle')) ?: '(sans titre)');
        return $this->render('taches/taches.html.twig', ['taches' => $this->taches->liste(), 'sansPrg' => true]);
    }
}
  • lignes 29-35 : la saisie est validée à la main, pour ne pas encombrer l'exemple ([trim] retire les espaces qui l'entourent). Une saisie invalide est réaffichée, sans redirection : il n'y a rien à protéger d'un double envoi, puisque rien n'a été modifié ;
  • ligne 39 : la méthode [addFlash] d'[AbstractController] dépose dans la session un message flash, de type [succes] (le type est libre : il permet de distinguer les messages de succès, d'erreur...) ;
  • ligne 41 : la redirection vers la page GET, nommée [taches] (exemple 04) ;
  • lignes 45-50 : le contre-exemple : une page en réponse au POST. L'opérateur [?:] rend son opérande de gauche s'il est « vrai » (ici, une chaîne non vide), celui de droite sinon.

4.8.4. Les vues

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Tâches</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {fichier: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 36rem">
    {# les messages flash [succes], en haut de page : déposés AVANT la redirection ;
       app.flashes les RETIRE de la session en les lisant #}
    {% for message in app.flashes('succes') %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
    {% if erreur is defined %}
      <div class="zone-erreur" role="alert">{{ erreur }}</div>
    {% endif %}
  </div>
</body>
</html>
  • lignes 13-15 : [app.flashes('succes')] rend la liste des messages flash de type [succes], et les retire de la session : un message flash n'est affiché qu'une fois. La classe [alert alert-success] de [Bootstrap] affiche le message dans un cadre vert ;
  • lignes 19-21 : le message d'erreur, en bas de page, quand l'action en a passé un.
{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Mes tâches</h1>
  <ol>
    {% for tache in taches %}
      <li>{{ tache }}</li>
    {% endfor %}
  </ol>
  <form method="post" action="{{ path('taches_ajouter') }}" class="d-flex gap-2">
    <input class="form-control" name="libelle" value="{{ libelle ?? '' }}" placeholder="Nouvelle tâche">
    <button class="btn btn-primary text-nowrap" type="submit">Ajouter</button>
    {# formaction : ce bouton envoie le MÊME formulaire à une autre adresse #}
    <button class="btn btn-outline-secondary text-nowrap" type="submit" formaction="{{ path('taches_sans_prg') }}">Ajouter sans PRG</button>
  </form>
  {% if sansPrg is defined %}
    <p class="text-danger mt-2">Page affichée en réponse à un POST : appuyez sur F5 et la tâche sera ajoutée une seconde fois.</p>
  {% endif %}
{% endblock %}
  • ligne 11 : l'opérateur [??] de [Twig] rend son opérande de droite si celui de gauche n'existe pas (ou vaut [null]) : la variable [libelle] n'existe qu'après une saisie invalide ;
  • ligne 14 : l'attribut [formaction] d'un bouton envoie le formulaire à une autre adresse que celle de son attribut [action] : un même formulaire peut ainsi déclencher deux actions différentes.

Ce formulaire, écrit à la main, n'a pas de jeton anti-CSRF : pour garder l'exemple simple, nous l'ajouterons à l'exemple suivant.

4.8.5. Où sont conservés les messages flash ?

Entre la requête POST et la requête GET qui suit, le message doit être conservé quelque part : dans la session de l'utilisateur, sur le serveur. Observons-le avec [curl], en conservant les cookies dans un fichier (options [-c] et [-b], exemple 18) :

curl -i -b cookies.txt -c cookies.txt -d "libelle=Faire%20les%20exercices" http://localhost:8000/taches
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Cache-Control: max-age=0, must-revalidate, private
Location: /taches
Content-Type: text/html; charset=utf-8
Expires: Sun, 27 Sep 2026 09:36:41 GMT
Set-Cookie: PHPSESSID=1e1f574eccd93268b1eb829dd714a029; path=/; httponly; samesite=lax
...

La réponse au POST est une redirection (code 302, entête [Location]), accompagnée du cookie de session, [PHPSESSID] (le nom que [PHP] donne à ce cookie), qui contient l'identifiant de la session : 32 caractères aléatoires, impossibles à deviner. Ce cookie est [httponly] (le [JavaScript] de la page ne peut pas le lire) et [samesite=lax] (le navigateur ne l'envoie pas avec une requête POST venue d'un autre site). Les entêtes [Cache-Control] et [Expires] interdisent la mise en cache d'une réponse qui dépend d'une session. L'identifiant est aussi le nom du fichier de la session sur le serveur : [var/sessions/dev/sess_1e1f574eccd93268b1eb829dd714a029]. Voici ce fichier, après la requête POST :

_symfony_flashes|a:1:{s:6:"succes";a:1:{i:0;s:49:"La tâche [Faire les exercices] a été ajoutée.";}}_sf2_meta|a:3:{s:1:"u";i:1790501801;s:1:"c";i:1790501801;s:1:"l";i:0;}

C'est le format de sérialisation des sessions de [PHP] : chaque donnée de la session est écrite sous la forme [nom|valeur sérialisée]. [a:1:{...}] annonce un tableau de 1 élément, [s:6:"succes"] une chaîne de 6 octets... On y trouve les messages flash ([_symfony_flashes]), rangés par type, et des métadonnées de [Symfony] ([_sf2_meta] : les dates de création et de dernière utilisation de la session). Suivons la redirection, en renvoyant le cookie :

curl -i -b cookies.txt -c cookies.txt http://localhost:8000/taches
1
2
3
4
5
6
7
HTTP/1.1 200 OK
...
              <div class="alert alert-success" role="status">La tâche [Faire les exercices] a été ajoutée.</div>
...
          <li>Lire le chapitre 3</li>
          <li>Faire les exercices</li>
...

Le message est affiché, et retiré de la session par [app.flashes]. La session, désormais vide, a même disparu : [Symfony] n'enregistre pas une session vide, et le fichier a été supprimé. Une nouvelle requête GET n'afficherait plus le message. La liste des tâches, elle, est dans un fichier du cache, dont le nom est calculé à partir de la clé ([var/cache/dev/pools/app/VT2eLxVxyZ/T/U/kAplxOBLatpb5XP0GkrA]) :

1
2
3
0
taches
a:2:{i:0;s:18:"Lire le chapitre 3";i:1;s:19:"Faire les exercices";}

La première ligne est la date d'expiration de la valeur (0 : jamais), la deuxième la clé, la troisième la liste sérialisée. Quant au verrou, c'est un fichier vide, [var/lock/sf.taches.hngbec7.lock], que le système verrouille le temps d'une modification.

4.8.6. Exécution

Ajout d'une tâche : le navigateur a suivi la redirection ; l'URL est celle de la page GET, que l'on peut recharger sans risque.

Image

Une saisie invalide, réaffichée avec son message d'erreur :

Image

Le contre-exemple : la page est la réponse au POST, et son URL est [/taches/sans-prg]. Si l'on appuie sur [F5], le navigateur demande s'il faut renvoyer le formulaire ; si l'on accepte, la tâche est ajoutée une seconde fois.

Image

4.9. Exemple 21 : liens, boutons et confirmation

Un principe du protocole HTTP : une requête GET ne modifie rien. Les navigateurs, les moteurs de recherche, les outils qui préchargent les pages suivent librement les liens, sans demander l'avis de l'utilisateur. Un lien « supprimer » ([<a href="/contacts/2/supprimer">]) serait donc dangereux : un robot qui parcourt le site supprimerait tout. Dans une application web :

  • consulter se fait par un lien ([<a href>]), qui émet une requête GET ;
  • modifier (enregistrer, dupliquer, supprimer) se fait par un bouton placé dans un formulaire, protégé par un jeton anti-CSRF.

Cet exemple gère une liste de contacts, et ajoute une fenêtre de confirmation avant chaque suppression.

4.9.1. La configuration : config/packages/framework.yaml

# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # un formulaire POST qui contient un champ _method (DELETE, PUT...) est traité
    # comme une requête de cette méthode
    http_method_override: true
    # la session (messages flash, jetons anti-CSRF), rangée dans var/sessions (cf. exemple 20)
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    csrf_protection: true
    lock: 'flock://%kernel.project_dir%/var/lock'
  • ligne 6 : un formulaire HTML ne sait envoyer que des requêtes GET et POST. Or le protocole HTTP prévoit d'autres méthodes : DELETE pour supprimer, PUT et PATCH pour modifier. [Symfony] contourne cette limite : avec l'option [http_method_override], un formulaire POST qui contient un champ caché [_method], de valeur [DELETE] (ou [PUT], [PATCH]), est traité comme une requête de cette méthode. C'est la simulation de méthode (method spoofing) ;
  • les autres lignes sont celles des exemples 18 et 20 : la session, la protection anti-CSRF, les verrous.

4.9.2. Le service : src/Service/ContactsService.php

<?php
// src/Service/ContactsService.php : les contacts, partagés par tous, rangés dans
// le cache de l'application (cf. exemple 20)

namespace App\Service;

use App\Modele\Contact;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;

class ContactsService
{
    private const CLE = 'contacts';

    public function __construct(
        private CacheItemPoolInterface $cache,
        private LockFactory $verrous,
    ) {
    }

    /** @return array{contacts: list<Contact>, dernierId: int} */
    private function donnees(): array
    {
        $element = $this->cache->getItem(self::CLE);
        return $element->isHit() ? $element->get() : [
            'contacts' => [
                new Contact(1, 'Marie Durand', '01 23 45 67 89'),
                new Contact(2, 'Paul Martin', '06 12 34 56 78'),
                new Contact(3, 'Philippe Jandot', '02 98 76 54 32'),
            ],
            'dernierId' => 3,
        ];
    }

    // une modification des données, sous verrou : la fonction reçoit les données,
    // les modifie (&), et rend le résultat de l'opération
    private function modifierDonnees(callable $operation): mixed
    {
        $verrou = $this->verrous->createLock('contacts');
        $verrou->acquire(true);
        try {
            $donnees = $this->donnees();
            $resultat = $operation($donnees);
            $this->cache->save($this->cache->getItem(self::CLE)->set($donnees));
            return $resultat;
        } finally {
            $verrou->release();
        }
    }

    /** @return list<Contact> */
    public function liste(): array
    {
        return $this->donnees()['contacts'];
    }

    // le contact, ou null s'il n'existe pas
    public function lire(int $id): ?Contact
    {
        return array_find($this->liste(), fn (Contact $c) => $c->id === $id);
    }

    public function modifier(int $id, string $nom, string $telephone): ?Contact
    {
        return $this->modifierDonnees(function (array &$donnees) use ($id, $nom, $telephone) {
            foreach ($donnees['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // un contact est immuable (readonly) : on en fabrique un nouveau,
                    // qui remplace l'ancien dans le tableau
                    return $donnees['contacts'][$i] = new Contact($id, $nom, $telephone);
                }
            }
            return null;
        });
    }

    public function ajouter(string $nom, string $telephone): Contact
    {
        return $this->modifierDonnees(function (array &$donnees) use ($nom, $telephone) {
            $contact = new Contact(++$donnees['dernierId'], $nom, $telephone);
            $donnees['contacts'][] = $contact;
            return $contact;
        });
    }

    public function supprimer(int $id): ?Contact
    {
        return $this->modifierDonnees(function (array &$donnees) use ($id) {
            foreach ($donnees['contacts'] as $i => $contact) {
                if ($contact->id === $id) {
                    // array_splice retire l'élément i du tableau, et renumérote les suivants
                    array_splice($donnees['contacts'], $i, 1);
                    return $contact;
                }
            }
            return null;
        });
    }
}

Les contacts sont rangés dans le cache, comme les tâches de l'exemple 20. Chaque contact est un objet immuable [Contact] (un identifiant, un nom, un numéro de téléphone), rangé dans un tableau, avec le dernier identifiant attribué. Quelques points de [PHP] :

  • lignes 37-49 : toutes les modifications suivent le même schéma : prendre le verrou, lire les données, les modifier, les réécrire, rendre le verrou. Cette méthode l'écrit une fois ; chaque modification lui passe une fonction (de type [callable], « appelable »), qui reçoit les données et les modifie. La fonction doit modifier le tableau lui-même, et non une copie : son paramètre est déclaré [array &$donnees], où le [&] demande un passage par référence (sans [&], un tableau est copié, cf. exemple 06). Le mot-clé [use] d'une fonction anonyme lui donne accès à des variables de la fonction qui la contient (ligne 65 : [$id], [$nom], [$telephone]) ;
  • ligne 44 : les appels s'enchaînent : [getItem] rend l'élément, [set] lui donne sa valeur et rend l'élément lui-même, [save] l'enregistre ;
  • lignes 63-75 : un contact est immuable : on en fabrique un nouveau, qui remplace l'ancien dans le tableau. Une affectation est une expression, dont la valeur est la valeur affectée : [return $donnees['contacts'][$i] = new Contact(...)] remplace le contact et le rend ;
  • ligne 80 : [++] placé avant une variable l'augmente de 1, et rend la nouvelle valeur ;
  • ligne 92 : [array_splice] retire un élément du tableau, et renumérote les suivants ;
  • les méthodes rendent [null] pour un contact inexistant : c'est au contrôleur de décider de la réponse.

4.9.3. Le formulaire de la fiche : src/Form/ContactType.php

<?php
// src/Form/ContactType.php : le formulaire de la fiche d'un contact, avec DEUX boutons d'envoi

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;

class ContactType extends AbstractType
{
    public function buildForm(FormBuilderInterface $constructeur, array $options): void
    {
        $constructeur
            ->add('nom', TextType::class, ['constraints' => [new NotBlank()]])
            ->add('telephone', TextType::class, ['label' => 'Téléphone', 'constraints' => [new NotBlank()]])
            // deux boutons d'envoi : le formulaire saura lequel a été cliqué
            ->add('enregistrer', SubmitType::class, ['attr' => ['class' => 'btn-primary']])
            ->add('dupliquer', SubmitType::class, ['attr' => ['class' => 'btn-outline-primary']]);
    }
}
  • lignes 17-18 : les deux champs, obligatoires ;
  • lignes 20-21 : deux boutons d'envoi ([SubmitType]) font partie du formulaire. Quand le formulaire est envoyé, le navigateur transmet le nom du bouton cliqué, et le formulaire sait lequel c'est. L'option [attr] leur donne leurs classes de [Bootstrap].

4.9.4. Le contrôleur : src/Controller/ContactsController.php

<?php
// src/Controller/ContactsController.php : liens (GET) et boutons (POST, DELETE)

namespace App\Controller;

use App\Form\ContactType;
use App\Modele\Contact;
use App\Service\ContactsService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/contacts', name: 'contacts')]
class ContactsController extends AbstractController
{
    public function __construct(private ContactsService $contacts)
    {
    }

    // le contact, ou une réponse 404 s'il n'existe pas
    private function contactOu404(?Contact $contact): Contact
    {
        return $contact ?? throw $this->createNotFoundException('contact inconnu');
    }

    // un LIEN (<a href>) : une requête GET, qui ne modifie rien
    #[Route('', name: '', methods: ['GET'])]
    public function liste(): Response
    {
        return $this->render('contacts/liste.html.twig', ['contacts' => $this->contacts->liste()]);
    }

    // GET /contacts/2 : la fiche ; POST /contacts/2 : l'un de ses deux boutons
    #[Route('/{id<\d+>}', name: '_fiche', methods: ['GET', 'POST'])]
    public function fiche(Request $request, int $id): Response
    {
        $contact = $this->contactOu404($this->contacts->lire($id));
        $formulaire = $this->createForm(ContactType::class,
            ['nom' => $contact->nom, 'telephone' => $contact->telephone]);
        $formulaire->handleRequest($request);
        if ($formulaire->isSubmitted() && $formulaire->isValid()) {
            ['nom' => $nom, 'telephone' => $telephone] = $formulaire->getData();
            $dupliquer = $formulaire->get('dupliquer');
            // isClicked : le bouton qui a envoyé le formulaire
            if ($dupliquer instanceof SubmitButton && $dupliquer->isClicked()) {
                $copie = $this->contacts->ajouter("$nom (copie)", $telephone);
                $this->addFlash('succes', "Le contact [$copie->nom] a été créé.");
            } else {
                $this->contacts->modifier($id, $nom, $telephone);
                $this->addFlash('succes', "Le contact [$nom] a été modifié.");
            }
            return $this->redirectToRoute('contacts');
        }
        return $this->render('contacts/fiche.html.twig', ['contact' => $contact, 'formulaire' => $formulaire]);
    }

    // une requête DELETE, simulée par un formulaire POST (champ caché _method)
    #[Route('/{id<\d+>}', name: '_supprimer', methods: ['DELETE'])]
    public function supprimer(Request $request, int $id): Response
    {
        // le jeton anti-CSRF du formulaire, propre à ce contact
        if (! $this->isCsrfTokenValid("supprimer-contact-$id", $request->request->getString('_token'))) {
            throw new AccessDeniedHttpException('jeton anti-CSRF invalide');
        }
        $contact = $this->contactOu404($this->contacts->supprimer($id));
        $this->addFlash('succes', "Le contact [$contact->nom] a été supprimé.");
        return $this->redirectToRoute('contacts');
    }
}
  • ligne 16 : le préfixe de nom [contacts] : la route de la liste, dont le nom est vide (ligne 30), s'appelle donc [contacts], celle de la fiche [contacts_fiche]... ;
  • lignes 24-27 : une fonction utilitaire : un contact inexistant arrête l'action par une réponse 404 ;
  • lignes 30-34 : l'action GET de la liste, qui ne modifie rien ;
  • lignes 37-58 : la fiche d'un contact. En GET, elle affiche le formulaire, rempli avec le contact ; en POST, elle le traite. La même URL [/contacts/2] sert ainsi à consulter et à enregistrer ;
  • ligne 45 : les données du formulaire, un tableau associatif, déstructuré : [['nom' => $nom, 'telephone' => $telephone] = ...] range chaque élément dans une variable ;
  • lignes 46-54 : [get('dupliquer')] rend le bouton [dupliquer] du formulaire ; [isClicked()] dit si c'est lui qui a envoyé le formulaire (le test [instanceof SubmitButton] rassure l'éditeur sur le type de l'objet). Selon le bouton, l'action duplique ou enregistre le contact ;
  • ligne 55 : dans les deux cas, le schéma PRG (exemple 20), avec un message flash ;
  • lignes 61-71 : la suppression, traitée par une requête DELETE (la même URL, une autre méthode). Le formulaire de suppression est écrit à la main dans la vue : l'action vérifie elle-même son jeton anti-CSRF, par la méthode [isCsrfTokenValid] d'[AbstractController]. Un jeton est lié à un identifiant (ici [supprimer-contact-2]) : le jeton d'un formulaire ne vaut que pour lui. Un jeton absent ou faux arrête l'action par une erreur 403 (Forbidden). (L'attribut [#[IsCsrfTokenValid('...')]], placé sur l'action, ferait la même vérification.)

4.9.5. Les vues

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

{% block contenu %}
  <h1 class="h3">Contacts</h1>
  <table class="table align-middle">
    <tbody>
      {% for contact in contacts %}
        <tr>
          <td>{{ contact.nom }}</td>
          <td>{{ contact.telephone }}</td>
          <td class="text-end">
            {# consulter : un simple lien ; path() place l'id dans le chemin : /contacts/2 #}
            <a class="btn btn-sm btn-outline-primary" href="{{ path('contacts_fiche', {id: contact.id}) }}">Voir</a>
            {# supprimer : un formulaire, dont l'envoi sera confirmé #}
            <form method="post" class="d-inline" action="{{ path('contacts_supprimer', {id: contact.id}) }}"
                  data-confirmation="Supprimer le contact {{ contact.nom }} ?">
              {# la méthode DELETE, qu'un formulaire HTML ne sait pas envoyer #}
              <input type="hidden" name="_method" value="DELETE">
              {# le jeton anti-CSRF, propre à ce contact #}
              <input type="hidden" name="_token" value="{{ csrf_token('supprimer-contact-' ~ contact.id) }}">
              <button class="btn btn-sm btn-outline-danger" type="submit">Supprimer</button>
            </form>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}
  • ligne 13 : un lien, présenté comme un bouton par les classes de [Bootstrap] ([btn]...) : l'apparence n'a rien à voir avec la méthode HTTP. [path('contacts_fiche', {id: contact.id})] place l'id dans le chemin : [/contacts/2] ;
  • lignes 15-22 : un formulaire pour chaque bouton « Supprimer », sans autre champ que la méthode et le jeton. La classe [d-inline] l'affiche sur la même ligne que le lien. L'attribut [data-confirmation] porte le message de confirmation : les attributs [data-...] sont libres, destinés au [JavaScript] de la page ;
  • ligne 18 : le champ caché [_method], qui fait de ce POST une requête DELETE ;
  • ligne 20 : le jeton anti-CSRF, fabriqué par la fonction [csrf_token] de [Twig], pour l'identifiant [supprimer-contact-2] (l'opérateur [~] concatène, exemple 16).
{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Contact n° {{ contact.id }}</h1>
  {{ form_start(formulaire) }}
    {{ form_row(formulaire.nom) }}
    {{ form_row(formulaire.telephone) }}
    {# deux boutons d'envoi pour un même formulaire, côte à côte #}
    <div class="d-flex gap-2">
      {{ form_widget(formulaire.enregistrer) }}
      {{ form_widget(formulaire.dupliquer) }}
      <a class="btn btn-link" href="{{ path('contacts') }}">Retour à la liste</a>
    </div>
  {{ form_end(formulaire) }}
{% endblock %}
  • lignes 10-11 : les deux boutons du formulaire, écrits par [form_widget], côte à côte ;
  • ligne 12 : le retour à la liste : un lien. [form_end] écrira le jeton anti-CSRF, que le composant Form vérifiera lui-même (exemple 18).

4.9.6. La fenêtre de confirmation

Le navigateur sait afficher une boîte de confirmation (fonction [JavaScript] [confirm]), mais elle est peu esthétique, et son apparence dépend du navigateur. On utilise ici une fenêtre modale de [Bootstrap], affichée par quelques lignes de [JavaScript]. La fenêtre est un fragment, inclus dans le gabarit, et donc présent, caché, dans toutes les pages :

{# une seule fenêtre pour toute la page, cachée (d-none) au départ #}
<div id="modale-confirmation" class="d-none">
  <div class="modal-backdrop fade show"></div>
  <div class="modal fade show d-block" tabindex="-1" role="alertdialog" aria-modal="true">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title">Confirmation</h5>
        </div>
        <div class="modal-body">
          <p class="mb-0" id="modale-confirmation-message"></p>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-action="non">Non, revenir</button>
          <button type="button" class="btn btn-danger" data-action="oui">Oui, confirmer</button>
        </div>
      </div>
    </div>
  </div>
</div>
  • ligne 2 : la classe [d-none] de [Bootstrap] cache l'élément ;
  • lignes 3-4 : le fond grisé, puis la fenêtre, dessinés par les classes de [Bootstrap] ;
  • ligne 11 : l'emplacement du message ;
  • lignes 14-15 : les deux boutons, de type [button] (et non [submit] : ils n'envoient aucun formulaire), distingués par leur attribut [data-action].
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
  <link rel="stylesheet" href="{{ path('bootstrap', {fichier: 'css/bootstrap.min.css'}) }}">
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
  <div class="container py-3" style="max-width: 40rem">
    {% for message in app.flashes('succes') %}
      <div class="alert alert-success" role="status">{{ message }}</div>
    {% endfor %}
    <main>
      {% block contenu %}{% endblock %}
    </main>
  </div>
  {# la fenêtre de confirmation, présente dans toutes les pages #}
  {% include 'fragments/confirmation.html.twig' %}
  {# le script, en fin de page : les éléments qu'il cherche existent déjà #}
  <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
  • ligne 19 : la fenêtre, incluse dans toutes les pages ;
  • ligne 21 : le script, placé en fin de page : quand il s'exécute, les éléments qu'il cherche existent déjà. Il est rangé dans le dossier [public/js].

Le script [public/js/app.js] :

// confirmation des formulaires portant l'attribut data-confirmation="..."
// Sans JavaScript, ces formulaires partent simplement sans confirmation.
(() => {
  const modale = document.getElementById('modale-confirmation');
  const message = document.getElementById('modale-confirmation-message');
  let enAttente = null; // le formulaire à envoyer si l'on confirme

  // 1. on intercepte l'envoi des formulaires à confirmer
  document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
    formulaire.addEventListener('submit', (evenement) => {
      evenement.preventDefault(); // l'envoi est suspendu
      enAttente = formulaire;
      message.textContent = formulaire.dataset.confirmation;
      modale.classList.remove('d-none'); // la fenêtre apparaît
    });
  });

  // 2. les deux boutons de la fenêtre
  modale.addEventListener('click', (evenement) => {
    const action = evenement.target.dataset.action;
    if (action === 'oui') {
      enAttente.submit(); // envoi, SANS redéclencher l'événement 'submit'
    } else if (action === 'non') {
      modale.classList.add('d-none');
      enAttente = null;
    }
  });
})();
  • ligne 3 : le code est enfermé dans une fonction anonyme, exécutée aussitôt : ses variables ne se mélangent pas à celles des autres scripts de la page ;
  • lignes 4-6 : la fenêtre, l'emplacement du message, et le formulaire en attente de confirmation ;
  • lignes 9-17 : pour chaque formulaire qui porte un attribut [data-confirmation], on intercepte l'événement [submit], déclenché par son envoi. [preventDefault()] suspend l'envoi ; le formulaire est mis en attente, son message ([dataset.confirmation], la valeur de l'attribut [data-confirmation]) est placé dans la fenêtre, qui apparaît ;
  • lignes 20-28 : un seul gestionnaire pour les clics dans la fenêtre : [evenement.target] est l'élément cliqué ; son attribut [data-action] dit de quel bouton il s'agit. « Oui » envoie le formulaire en attente, par la méthode [submit()], qui, contrairement au clic sur un bouton, ne déclenche pas l'événement [submit] (sinon, la fenêtre réapparaîtrait) ; « Non » cache la fenêtre.

Sans [JavaScript], le formulaire est simplement envoyé sans confirmation : la page reste utilisable. C'est l'amélioration progressive : le [JavaScript] ajoute un confort, mais l'application ne dépend pas de lui.

4.9.7. Exécution

La liste des contacts :

Image

Un clic sur « Supprimer » affiche la fenêtre de confirmation :

Image

Après confirmation, la requête DELETE (simulée), la redirection, et le message flash :

Image

La fiche d'un contact, avec ses deux boutons d'envoi :

Image

Une requête sans jeton anti-CSRF est refusée, et un contact inexistant donne une erreur 404 :

curl -i -X POST -d "_method=DELETE" http://localhost:8000/contacts/3
curl -i http://localhost:8000/contacts/9
1
2
3
4
5
6
HTTP/1.1 403 Forbidden
X-Debug-Exception: jeton%20anti-CSRF%20invalide
...
HTTP/1.1 404 Not Found
X-Debug-Exception: contact%20inconnu
...

Les pages d'erreur sont encore celles, détaillées, du mode debug de [Symfony]. Le chapitre sur le cycle de vie d'une requête montrera comment les remplacer par des pages « maison ».