11. Étude de cas : l'application RdvMedecins
11.1. Le problème
L'application RdvMedecins gère les rendez-vous d'un cabinet de médecins. Chaque médecin a des créneaux horaires (de 8 h 00 à 12 h 00 par tranches de 20 minutes, par exemple) ; un rendez-vous associe un jour, un créneau et un client (un patient). C'est la transposition exacte, en [Symfony], de l'application [Laravel] du cours précédent (elle-même transposée des versions [Flask], [Spring MVC] et [ASP.NET Core MVC]) : même base de données, mêmes règles de gestion, mêmes pages, mêmes adresses, mêmes textes. Les pages HTML sont fabriquées par le serveur, avec des vues [Twig] ; l'accès à la base passe par [Doctrine].
Trois rôles d'utilisateurs se partagent l'application :
ADMIN | l'administrateur ([admin]) | consulter les agendas, réserver et annuler pour n'importe quel patient ; gérer (lister, ajouter, modifier, supprimer) les médecins et les clients |
DOCTOR | les médecins | consulter les agendas, réserver et annuler pour n'importe quel patient |
USER | les clients (patients) | consulter les agendas sans voir le nom des autres patients ; réserver pour lui-même ; annuler son rendez-vous ; gérer son compte ; créer son compte |
Les règles de gestion principales :
- pas de rendez-vous dans le passé ;
- un créneau n'est réservé qu'une fois par jour ;
- un patient n'a qu'un rendez-vous à venir à la fois ;
- deux médecins (ou deux clients) ne portent pas les mêmes prénom et nom, et un login n'appartient qu'à un compte ;
- un médecin qui a des rendez-vous ne peut pas être supprimé ; un client est supprimé avec ses rendez-vous ;
- la page de connexion est protégée des robots (captcha, limitation des tentatives), et la création d'un compte client exige d'avoir d'abord résolu le captcha de cette page.
11.2. Utilisation de l'application
11.2.1. La page de connexion

La page propose deux boutons : Se connecter, et Créer un compte client. Tous deux exigent d'avoir recopié les caractères de l'image. Les boutons FR / EN changent la langue :

Une erreur (ici un captcha mal recopié) est signalée en bas de la page, sur un fond rose léger :

11.2.2. L'agenda, vu par l'administrateur et les médecins
Une fois connecté, l'administrateur arrive sur la page de l'agenda. Il choisit un médecin et un jour :


Chaque créneau est libre (bouton Réserver) ou occupé (nom du patient, bouton Annuler). Un clic sur Réserver ouvre une fenêtre qui demande le patient. Cette fenêtre est fabriquée par le serveur, comme le reste de la page : l'adresse contient désormais le paramètre [reserver=4], le numéro du créneau choisi :

Après la réservation, un message confirme le rendez-vous :

Un clic sur Annuler demande une confirmation (ici par le docteur Pélissier, dont la barre de navigation n'a pas les menus d'administration) :

11.2.3. L'espace du patient
Un patient (rôle USER) arrive sur Mon espace : son rendez-vous à venir, qu'il peut annuler, et des liens vers la gestion de son compte et vers l'agenda. Ici, il n'a pas encore de rendez-vous :

Dans l'agenda, il ne voit pas le nom des autres patients, remplacé par « Réservé » : ce nom ne figure même pas dans le code source de la page. Il peut réserver un créneau libre :

La fenêtre de réservation ne lui propose pas de choisir le patient : il réserve pour lui-même.


Son espace montre désormais son rendez-vous à venir, qu'il peut annuler :

Ayant un rendez-vous à venir, il ne peut pas en prendre un autre : aucun bouton Réserver n'est affiché, et, sur l'agenda d'un autre jour ou d'un autre médecin, un message lui explique pourquoi :

La gestion de son compte : modifier ses informations, son mot de passe, ou supprimer son compte (et ses rendez-vous). Le mot de passe actuel est exigé pour toute modification :

Un patient qui tente d'ouvrir une page réservée à l'administrateur (ici [/medecins], tapée à la main) reçoit une page d'erreur :

11.2.4. La gestion des médecins et des clients
L'administrateur a deux menus supplémentaires, Médecins et Clients :


Le formulaire d'ajout d'un médecin, envoyé avec des erreurs :

Un homonyme d'un médecin existant est refusé, même écrit en minuscules et avec un accent (c'est la base de données elle-même qui compare les noms sans tenir compte des majuscules ni des accents, cf. exemple 28) :

La suppression d'un client demande une confirmation :

11.2.5. La création d'un compte client
Depuis la page de connexion, le bouton Créer un compte client n'ouvre le formulaire que si le captcha a été correctement recopié :

Une fois le compte créé, l'utilisateur revient à la page de connexion, son login déjà saisi :

11.2.6. La fin de session
Le jeton de connexion a une durée de vie de deux heures. Au-delà (ou si le jeton a été altéré), la page demandée renvoie à la page de connexion, avec un message :

11.3. Installation et exécution
L'application se trouve dans le dossier [rdvmedecins2-symfony], à côté du dossier [exemples]. C'est un projet autonome, avec ses propres fichiers [composer.json] et [.env], et son propre dossier [vendor]. Le fichier [README.md] du dossier reprend tout ce qui suit. L'introduction du document a déjà décrit l'installation de [PHP] et de [Composer] ; nous la complétons ici.
11.3.1. Les logiciels nécessaires
Trois logiciels suffisent :
- [PHP] 8.4 ou plus récent, avec les extensions [pdo_mysql] et [intl] ([Symfony] 8 exige [PHP] 8.4, et l'application utilise des fonctions apparues avec cette version, comme [array_find]). On vérifie l'installation dans un terminal :
La seconde commande liste les extensions : [pdo_mysql] et [intl] doivent y figurer ;
- [Composer], le gestionnaire de bibliothèques de [PHP] (cf. annexes) ;
- un serveur [MySQL] 8, par exemple celui de [Laragon] sous [Windows] (cf. annexes). [MariaDB] convient aussi.
L'éditeur conseillé est [Visual Studio Code], avec les extensions [PHP Intelephense] et [Twig Language 2] (cf. annexes) ; mais tout peut se faire en ligne de commande.
11.3.2. Installer les bibliothèques
Le dossier [vendor] n'est pas fourni : on le fabrique, une fois pour toutes, dans un terminal ouvert sur le dossier [rdvmedecins2-symfony] (il faut être connecté à Internet) :
[Composer] lit le fichier [composer.json] (commenté plus loin), télécharge [Symfony], [Doctrine], [Twig], [firebase/php-jwt] et les bibliothèques dont ils dépendent, et fabrique le chargeur de classes [vendor/autoload.php], ainsi que le fichier [vendor/autoload_runtime.php] du composant [Runtime] (plus loin).
11.3.3. Créer la base de données
La base [dbrdvmedecins2] est créée par le script [database/dbrdvmedecins2.sql]. Avec [Laragon], on ouvre l'outil [HeidiSQL] (bouton Base de données), puis on exécute le script par le menu Fichier / Exécuter un fichier SQL. En ligne de commande, dans le dossier de l'application :
| mysql -u root -p < database/dbrdvmedecins2.sql
|
L'option [-p] fait demander le mot de passe de [root] ; on l'omet si le compte n'en a pas (c'est le cas avec [Laragon]). C'est la même base que celle des autres versions de l'application : si elle existe déjà, cette étape est facultative. Le script peut être exécuté à tout moment pour revenir aux données de départ.
Toute la configuration d'une installation est dans le fichier [.env] (commenté plus loin). Par défaut, l'application se connecte à [MySQL] sur [127.0.0.1], port 3306, avec l'utilisateur [root] sans mot de passe : les réglages par défaut de [Laragon]. Si votre compte [root] a un mot de passe, écrivez-le dans ce fichier, dans l'URL [DATABASE_URL], après [root:]. Une variable d'environnement du système l'emporte sur le fichier (exemple 25) : on peut ainsi donner l'URL de la base sans l'écrire dans un fichier du projet.
11.3.5. Lancer l'application
Dans un terminal ouvert sur le dossier [rdvmedecins2-symfony] :
| php -S localhost:8081 -t public
|
| [Sun Sep 27 18:40:12 2026] PHP 8.4.21 Development Server (http://localhost:8081) started
|
On ouvre ensuite l'adresse [http://localhost:8081] dans un navigateur, et l'on arrête l'application par [Ctrl-C]. Le port 8081 permet de faire tourner en même temps un exemple du cours (port 8000). L'outil en ligne de commande [symfony] (cf. annexes) offre un serveur plus complet, qui sait aussi servir l'application en HTTPS : [symfony server:start --port=8081].
11.3.6. Les comptes de démonstration
Le mot de passe de chaque compte est son login ; il faut aussi recopier les caractères de l'image, en majuscules ou en minuscules :
[admin] | ADMIN (l'administrateur) |
[pelissier], [tatou], [dupont] | DOCTOR (les médecins) |
[martin], [durand], [bistrou], [german] | USER (les patients) |
Un parcours pour découvrir l'application :
- [admin] : Agenda, Mme Marie PELISSIER, aujourd'hui : un créneau est déjà pris par Melle Brigitte BISTROU. Réserver un créneau pour Mr Jean MARTIN, puis tenter un second rendez-vous pour lui (refusé). Menus Médecins et Clients : ajouter, modifier, supprimer (le docteur PELISSIER, qui a des rendez-vous, ne peut pas être supprimé) ;
- [pelissier] : son agenda du jour s'affiche directement ; l'adresse [/medecins], tapée à la main, donne une page 403 ;
- [martin] : Mon espace, son rendez-vous, l'agenda où les autres patients apparaissent comme « Réservé » ; Gérer mon compte ;
- page de connexion : Créer un compte client, puis se connecter avec le nouveau compte ;
- boutons FR / EN : toute l'interface change de langue, dates comprises.
11.3.7. Le mode développement
Le fichier [.env] fourni règle l'application pour le développement ([APP_ENV=dev]) :
- la console du serveur affiche le journal de [Symfony] (exemple 24) : la route choisie, les étapes de la sécurité, et chaque ordre SQL envoyé à la base (exemple 28). Par exemple, pour l'affichage d'un agenda (lignes abrégées) :
| [info] Matched route "agenda".
[debug] Checking for authenticator support.
[debug] Checking support on authenticator.
[info] Authenticator successful!
[debug] Authenticator set no success response: request continues.
[info] Connecting with parameters [array]
[debug] Executing statement: SELECT m0_.id AS id_0, ..., u1_.login AS login_7, ... FROM medecins m0_ INNER JOIN users u1_ ON m0_.`user` = u1_.`id` WHERE m0_.id = ? (parameters: [array], types: [array])
[debug] Executing statement: SELECT r0_.id AS id_0, ... FROM rv r0_ INNER JOIN clients c1_ ON r0_.id_client = c1_.id INNER JOIN creneaux c2_ ON r0_.id_creneau = c2_.id WHERE r0_.jour = ? AND c2_.id_medecin = ? (parameters: [array], types: [array])
[debug] Executing statement: SELECT c0_.id AS id_0, ... FROM creneaux c0_ WHERE c0_.id_medecin = ? ORDER BY c0_.hdebut ASC, c0_.mdebut ASC (parameters: [array], types: [array])
127.0.0.1:55220 [200]: GET /agenda?idMedecin=1&jour=2026-09-27
[info] Disconnecting
|
On y reconnaît le routage, l'authentificateur, puis trois requêtes : le médecin et son compte, les rendez-vous du jour et leurs clients, les créneaux du médecin. La liste des médecins de la liste déroulante, elle, n'est pas lue : elle vient du cache (plus loin) ;
- une exception inattendue affiche la page de débogage de [Symfony] (exemple 03), avec le message de l'exception et la pile d'appels. En production, l'application affiche à la place sa propre page d'erreur 500 ;
- il n'y a rien à redémarrer après une modification : chaque requête relit les fichiers [PHP] (exemple 01), et, en mode [dev], [Symfony] reconstruit lui-même son cache (le conteneur, les routes, les vues compilées) quand un fichier a changé.
Le débogueur [Xdebug] permet en outre d'exécuter l'application pas à pas depuis [Visual Studio Code] (extension [PHP Debug]) : c'est une extension de [PHP], à installer à part (cf. xdebug.org). La barre de débogage de [Symfony] (le [WebProfilerBundle], [composer require --dev symfony/web-profiler-bundle]) affiche en bas de chaque page le détail de la requête : la route, l'utilisateur, les ordres SQL et leurs paramètres, les traductions, les temps d'exécution...
11.3.8. Un serveur de production
Le serveur de développement de [PHP] n'est pas fait pour la production. En production, l'application est servie par un serveur web ([nginx], [Apache]), qui confie les requêtes à [PHP-FPM], un gestionnaire de processus [PHP]. La racine du site est le dossier [public] : seuls ses fichiers sont accessibles depuis le navigateur ; le dossier [var] doit être accessible en écriture au serveur. Dans le fichier [.env], il faut indiquer le mode production, changer les clés secrètes, et déclarer que le site est servi en HTTPS :
| APP_ENV=prod
APP_SECRET=<une longue chaîne aléatoire>
JWT_SECRET=<une autre longue chaîne aléatoire>
COOKIE_SECURE=1
|
Une chaîne aléatoire convenable s'obtient avec [PHP] lui-même : [php -r "echo bin2hex(random_bytes(32));"]. On prépare ensuite l'application par deux commandes :
| composer install --no-dev --optimize-autoloader
php bin/console cache:clear
|
La seconde fabrique le cache de production (le conteneur compilé, les routes, les vues compilées...) : en mode [prod], [Symfony] ne vérifie plus si les fichiers ont changé, ce qui est plus rapide ; elle est donc à refaire après chaque modification. La commande [composer dump-env prod] transforme en outre le fichier [.env] en un fichier [PHP] ([.env.local.php]), lu plus vite.
11.3.9. Les problèmes fréquents
Une application [PHP] ne « démarre » pas : chaque requête reconstruit l'application (exemple 01). Une base inaccessible n'empêche donc pas le serveur de se lancer : l'erreur apparaît à la première page qui interroge la base (la page de connexion n'en interroge aucune ; la connexion, si). En mode [dev], elle est affichée par la page de débogage de [Symfony] ; dans tous les cas, elle est inscrite dans le journal, que le serveur de développement affiche dans la console. Par exemple, avec une base qui n'existe pas :
| [critical] Uncaught PHP Exception Doctrine\DBAL\Exception\ConnectionException: "An exception occurred in the driver: SQLSTATE[HY000] [1049] Unknown database 'dbinconnue'" at ExceptionConverter.php line 91
|
Les messages les plus fréquents :
[Failed opening required '.../vendor/autoload_runtime.php'] | la commande [composer install] n'a pas été exécutée |
[Your Composer dependencies require a PHP version ">= 8.4.0"] | la version de [PHP] est antérieure à 8.4 |
[could not find driver] | l'extension [pdo_mysql] de [PHP] n'est pas activée (fichier [php.ini], ou menu PHP / Extensions de [Laragon]) |
[SQLSTATE[HY000] [2002] Connection refused] | [MySQL] n'est pas démarré, ou n'écoute pas sur [127.0.0.1:3306] |
[SQLSTATE[HY000] [1045] Access denied for user 'root'@'localhost'] | le mot de passe de [MySQL] est à indiquer dans [DATABASE_URL] (cf. « Configurer l'accès à la base ») |
[SQLSTATE[HY000] [1049] Unknown database 'dbrdvmedecins2'] | le script [database/dbrdvmedecins2.sql] n'a pas été exécuté |
[JWT_SECRET doit comporter au moins 32 caractères (cf. .env).] | la clé secrète des jetons est trop courte dans [.env] |
[Failed to listen on localhost:8081 (reason: Address already in use)] | le port 8081 est déjà pris (une autre version de l'application tourne-t-elle ?) : l'arrêter, ou choisir un autre port |
11.4. L'architecture de l'application

- le navigateur envoie ses requêtes au serveur ([php -S], ou [nginx] + [PHP-FPM]), qui les confie à [Symfony] par le fichier [public/index.php]. Le noyau de [Symfony] publie les événements du traitement de la requête (chapitre sur le cycle de vie), auxquels réagissent des écouteurs : la langue de la requête ([FixerLangue]), le routage, la session, puis le pare-feu du composant de sécurité : l'utilisateur est établi à partir du jeton JWT de son cookie ([JetonAuthenticator]), et les règles [access_control] refusent les pages protégées aux visiteurs qui n'y ont pas droit. Les erreurs deviennent des pages HTML ([PageErreur]) ;
- [Controller] : les contrôleurs de la couche web. Ils passent tous par un objet unique, [ApplicationModel] (dossier [Web]), qui garde dans le cache les listes de médecins et de clients et relaie les autres demandes à la couche métier ; [Form] : les formulaires et leurs contraintes ; [Vues] : les modèles des vues ;
- [Securite] : les jetons JWT, l'authentificateur, le captcha, la limitation des tentatives, le laissez-passer de création de compte ;
- [Web] : ce que les contrôleurs ont en commun : le cache et la façade [ApplicationModel], la classe mère des contrôleurs, les messages flash, quelques utilitaires ;
- [Metier] : les règles de gestion et les transactions ;
- [Dao] : la couche DAO, une classe par table ;
- [Entity] : les entités [Doctrine], que la couche DAO lit et écrit dans la base ; [Domain] : les objets de l'agenda, calculés par la couche métier ;
- [templates] : les vues [Twig], qui fabriquent les pages HTML ; [translations] : les dictionnaires ; [public] : la feuille de style, le script de la page et [Bootstrap] ;
- le dossier [config] et le fichier [.env] : la configuration, qui assemble le tout.
Le dossier de l'application (sans [vendor], ni [var]) :
| rdvmedecins2-symfony/
├── bin/
│ └── console
├── composer.json
├── config/
│ ├── bundles.php
│ ├── packages/
│ │ ├── doctrine.yaml
│ │ ├── framework.yaml
│ │ ├── security.yaml
│ │ └── twig.yaml
│ ├── routes.yaml
│ └── services.yaml
├── database/
│ ├── dbrdvmedecins2.sql
│ └── README.md
├── .env
├── public/
│ ├── css/
│ │ └── styles.css
│ ├── index.php
│ ├── js/
│ │ └── app.js
│ └── lib/
│ └── bootstrap/
├── README.md
├── src/
│ ├── Controller/
│ │ ├── AgendaController.php
│ │ ├── AuthController.php
│ │ ├── ClientsController.php
│ │ ├── EspaceController.php
│ │ ├── InscriptionController.php
│ │ ├── LangueController.php
│ │ ├── MedecinsController.php
│ │ ├── PersonnesController.php
│ │ └── README.md
│ ├── Dao/
│ │ ├── ClientDao.php
│ │ ├── CreneauDao.php
│ │ ├── MedecinDao.php
│ │ ├── PersonneDao.php
│ │ ├── README.md
│ │ ├── RvDao.php
│ │ └── UserDao.php
│ ├── Domain/
│ │ ├── AgendaMedecinJour.php
│ │ ├── CreneauMedecinJour.php
│ │ └── README.md
│ ├── Entity/
│ │ ├── Client.php
│ │ ├── Creneau.php
│ │ ├── Medecin.php
│ │ ├── Personne.php
│ │ ├── README.md
│ │ ├── Role.php
│ │ ├── Rv.php
│ │ └── User.php
│ ├── EventListener/
│ │ ├── FixerLangue.php
│ │ ├── PageErreur.php
│ │ ├── README.md
│ │ └── VerifierPasseInscription.php
│ ├── Form/
│ │ ├── ChampsPersonneType.php
│ │ ├── InscriptionType.php
│ │ ├── MonCompteType.php
│ │ ├── PersonneType.php
│ │ └── README.md
│ ├── Kernel.php
│ ├── Metier/
│ │ ├── Dates.php
│ │ ├── Descripteur.php
│ │ ├── DonneesPersonne.php
│ │ ├── Metier.php
│ │ ├── MetierException.php
│ │ ├── MetierService.php
│ │ └── README.md
│ ├── Securite/
│ │ ├── AuthService.php
│ │ ├── Captcha.php
│ │ ├── CaptchaService.php
│ │ ├── DessinCaptcha.php
│ │ ├── JetonAuthenticator.php
│ │ ├── LimiteurTentatives.php
│ │ ├── PasseInscription.php
│ │ ├── PasseInscriptionExige.php
│ │ ├── README.md
│ │ ├── SessionInvalide.php
│ │ └── UtilisateurConnecte.php
│ ├── Twig/
│ │ └── VuesExtension.php
│ ├── Vues/
│ │ ├── AgendaVue.php
│ │ ├── ClesPersonnes.php
│ │ ├── LigneAgenda.php
│ │ ├── OptionVue.php
│ │ ├── README.md
│ │ ├── RendezVousVue.php
│ │ └── ReservationVue.php
│ └── Web/
│ ├── ApplicationModel.php
│ ├── Controleur.php
│ ├── Flash.php
│ ├── Message.php
│ ├── Outils.php
│ └── README.md
├── templates/
│ ├── agenda.html.twig
│ ├── compte.html.twig
│ ├── erreur.html.twig
│ ├── espace.html.twig
│ ├── fragments/
│ │ ├── captcha.html.twig
│ │ ├── confirmation.html.twig
│ │ ├── erreurs.html.twig
│ │ ├── navbar.html.twig
│ │ ├── reservation.html.twig
│ │ └── succes.html.twig
│ ├── inscription.html.twig
│ ├── layout.html.twig
│ ├── login.html.twig
│ ├── macros.html.twig
│ ├── personnes/
│ │ ├── formulaire.html.twig
│ │ └── liste.html.twig
│ └── README.md
└── translations/
├── messages+intl-icu.en.yaml
└── messages+intl-icu.fr.yaml
|
C'est la structure d'une application [Symfony] ordinaire, celle que fabrique la commande [composer create-project symfony/skeleton] (exemple 01), complétée des bundles dont l'application a besoin. Deux différences avec les exemples : l'application a son propre dossier [vendor] (les exemples partageaient celui du dossier [exemples]), et son point d'entrée utilise le composant [Runtime] de [Symfony] (plus loin). Les classes de l'application sont rangées dans le dossier [src], selon la règle PSR-4 (exemple 01) : la classe [App\Metier\MetierService] est dans le fichier [src/Metier/MetierService.php]. [Symfony] n'impose aucun de ces dossiers : [Controller], [Entity], [EventListener], [Form] et [Twig] sont ceux de l'usage (ceux qu'utilise le [MakerBundle]), les autres ([Dao], [Metier], [Securite]...) ceux de l'architecture en couches de l'exemple 28. [Bootstrap] fait cette fois partie de l'application (dossier [public/lib/bootstrap]) : l'étude de cas est autonome.
11.5. La base de données
La base [dbrdvmedecins2] a cinq tables :
- [users] : les utilisateurs autorisés à se connecter ;
- [medecins] et [clients] : les médecins et les patients, chacun lié à son compte utilisateur ;
- [creneaux] : les créneaux horaires de chaque médecin ;
- [rv] : les rendez-vous, un client sur un créneau, un jour donné.
11.5.1. database/dbrdvmedecins2.sql
| -- =========================================================================
-- Base de données [dbrdvmedecins2] - MySQL 8
--
-- Évolution de [dbrdvmedecins] :
-- 1. les tables [medecins] et [clients] reçoivent une colonne [user],
-- clé étrangère vers users(id) : chaque médecin et chaque client est
-- désormais aussi un utilisateur capable de se connecter ;
-- 2. un troisième rôle apparaît : DOCTOR (en plus de ADMIN et USER) ;
-- 3. le script crée une ligne [users] pour chaque médecin (rôle DOCTOR)
-- et pour chaque client (rôle USER) :
-- - login = nom en minuscules
-- - mot de passe = nom en minuscules, haché avec bcrypt (10 tours)
-- - nom affiché = prénom + nom
--
-- 4. contraintes d'unicité, toutes NOMMÉES (l'application reconnaît
-- le nom de la contrainte violée dans le message d'erreur MySQL) :
-- - unq_users_login : un login n'appartient qu'à un seul compte ;
-- - unq_medecins_prenom_nom : deux médecins ne peuvent pas avoir les
-- mêmes prénom et nom ;
-- - unq_clients_prenom_nom : idem pour les clients.
-- Attention : la collation utf8mb4_unicode_ci compare sans tenir compte
-- des majuscules ni des accents - "Hélène DUPONT" et "helene dupont"
-- sont donc considérés comme identiques.
--
-- La table [users] doit maintenant être créée AVANT [medecins] et [clients]
-- (elles la référencent), et supprimée APRÈS elles.
-- =========================================================================
-- le script est écrit en UTF-8
SET NAMES utf8mb4;
-- on repart d'une base vide à chaque exécution du script (pratique en développement)
CREATE DATABASE IF NOT EXISTS dbrdvmedecins2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbrdvmedecins2;
-- ordre de suppression : des tables « filles » vers les tables « mères »
DROP TABLE IF EXISTS rv;
DROP TABLE IF EXISTS creneaux;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS medecins;
DROP TABLE IF EXISTS users;
-- -------------------------------------------------------------------------
-- table [users] : les utilisateurs autorisés à se connecter à l'application
-- -------------------------------------------------------------------------
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
login VARCHAR(30) NOT NULL, -- identifiant de connexion
password VARCHAR(100) NOT NULL, -- mot de passe HACHÉ (bcrypt) - jamais en clair
nom VARCHAR(40) NOT NULL, -- nom affiché une fois connecté
role VARCHAR(10) NOT NULL, -- 'ADMIN', 'DOCTOR' ou 'USER'
-- un login ne peut appartenir qu'à un seul compte
CONSTRAINT unq_users_login UNIQUE (login)
);
-- -------------------------------------------------------------------------
-- table [medecins] : les médecins du cabinet
-- -------------------------------------------------------------------------
CREATE TABLE medecins (
id INT AUTO_INCREMENT PRIMARY KEY, -- clé primaire (identifiant technique)
version INT NOT NULL DEFAULT 0, -- n° de version de la ligne (verrou optimiste)
titre VARCHAR(5) NOT NULL, -- Melle, Mme, Mr
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_medecins_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par médecin, et un médecin par compte
CONSTRAINT unq_medecins_user UNIQUE (`user`),
-- deux médecins ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_medecins_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [clients] : les patients des médecins
-- -------------------------------------------------------------------------
CREATE TABLE clients (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
titre VARCHAR(5) NOT NULL,
nom VARCHAR(20) NOT NULL,
prenom VARCHAR(20) NOT NULL,
`user` INT NOT NULL, -- clé étrangère -> users(id)
CONSTRAINT fk_clients_user FOREIGN KEY (`user`) REFERENCES users(id),
-- un compte par client, et un client par compte
CONSTRAINT unq_clients_user UNIQUE (`user`),
-- deux clients ne peuvent pas porter les mêmes prénom et nom
CONSTRAINT unq_clients_prenom_nom UNIQUE (prenom, nom)
);
-- -------------------------------------------------------------------------
-- table [creneaux] : les créneaux horaires de chaque médecin
-- -------------------------------------------------------------------------
CREATE TABLE creneaux (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
id_medecin INT NOT NULL, -- clé étrangère -> medecins(id)
hdebut INT NOT NULL, -- heure de début du créneau
mdebut INT NOT NULL, -- minute de début du créneau
hfin INT NOT NULL, -- heure de fin du créneau
mfin INT NOT NULL, -- minute de fin du créneau
CONSTRAINT fk_creneaux_medecin FOREIGN KEY (id_medecin) REFERENCES medecins(id)
);
-- -------------------------------------------------------------------------
-- table [rv] : les rendez-vous pris par les clients
-- -------------------------------------------------------------------------
CREATE TABLE rv (
id INT AUTO_INCREMENT PRIMARY KEY,
version INT NOT NULL DEFAULT 0,
jour DATE NOT NULL, -- jour du rendez-vous
id_creneau INT NOT NULL, -- clé étrangère -> creneaux(id)
id_client INT NOT NULL, -- clé étrangère -> clients(id)
CONSTRAINT fk_rv_creneau FOREIGN KEY (id_creneau) REFERENCES creneaux(id),
CONSTRAINT fk_rv_client FOREIGN KEY (id_client) REFERENCES clients(id),
-- un même créneau, un même jour, ne peut être réservé qu'une seule fois
CONSTRAINT unq1_rv UNIQUE (jour, id_creneau)
);
-- =========================================================================
-- Données de démonstration
-- =========================================================================
-- les utilisateurs : mots de passe hachés avec bcrypt (10 tours de salage)
-- admin/admin (ADMIN)
-- un compte DOCTOR par médecin, un compte USER par client
-- (le compte de démonstration user/user a disparu : un compte USER est
-- désormais TOUJOURS celui d'un client, qui peut réserver et gérer son compte)
-- (login = mot de passe = nom en minuscules)
INSERT INTO users (login, password, nom, role) VALUES
('admin', '$2b$10$YSNBJXXHX9mp13nPZb6YGu8M1jR6uvPwO1s2X73z0QAqBR7TOQjoq', 'Administrateur', 'ADMIN'),
-- médecins
('pelissier', '$2b$10$DnSDOK5ONrdMxTTr1NKCHOphR3WSp5QAyG9S9ueruw9HwlxI.16ba', 'Marie PELISSIER', 'DOCTOR'),
('tatou', '$2b$10$PeovBO.gmJUKP4PglI/fwu4xtWOYSxoM5VTWjTj7uQ4wcd9P9.htS', 'Paul TATOU', 'DOCTOR'),
('dupont', '$2b$10$az7wvPKC1h0EiAxXwu/PU.vYjzmB04rd5B7QnK0mvxp4niYIHVoc.', 'Sophie DUPONT', 'DOCTOR'),
-- clients
('martin', '$2b$10$GT6S5pp.WdGapFw/UmocXesbC6LEjJfZzgatCUyG9Sf4o1iBNlqZW', 'Jean MARTIN', 'USER'),
('durand', '$2b$10$l2gl/zCder8E7Nh4VxGRX.0.SsO3FDiXt6YwqYq3v9nFzgC/jQCy6', 'Alice DURAND', 'USER'),
('bistrou', '$2b$10$naeRp2YUU6c.QDl5itJMAum3Y5aHdUV6jwIcsbp0D.64jPG4F6E9m', 'Brigitte BISTROU', 'USER'),
('german', '$2b$10$tTWxh3ROWF7NjhZOMnWDMOsZcnSWUiwwfy5C3G0.xhccTXALKyLeW', 'Luc GERMAN', 'USER');
-- les médecins : la colonne [user] est retrouvée à partir du login,
-- plutôt que de supposer les valeurs des clés primaires de [users]
INSERT INTO medecins (titre, nom, prenom, `user`) VALUES
('Mme', 'PELISSIER', 'Marie', (SELECT id FROM users WHERE login = 'pelissier')),
('Mr', 'TATOU', 'Paul', (SELECT id FROM users WHERE login = 'tatou')),
('Melle','DUPONT', 'Sophie', (SELECT id FROM users WHERE login = 'dupont'));
INSERT INTO clients (titre, nom, prenom, `user`) VALUES
('Mr', 'MARTIN', 'Jean', (SELECT id FROM users WHERE login = 'martin')),
('Mme', 'DURAND', 'Alice', (SELECT id FROM users WHERE login = 'durand')),
('Melle','BISTROU', 'Brigitte', (SELECT id FROM users WHERE login = 'bistrou')),
('Mr', 'GERMAN', 'Luc', (SELECT id FROM users WHERE login = 'german'));
-- créneaux du docteur PELISSIER (id_medecin = 1) : de 8h00 à 12h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(1, 8, 0, 8, 20), (1, 8, 20, 8, 40), (1, 8, 40, 9, 0),
(1, 9, 0, 9, 20), (1, 9, 20, 9, 40), (1, 9, 40, 10, 0),
(1, 10, 0, 10, 20), (1, 10, 20, 10, 40), (1, 10, 40, 11, 0),
(1, 11, 0, 11, 20), (1, 11, 20, 11, 40), (1, 11, 40, 12, 0);
-- créneaux du docteur TATOU (id_medecin = 2) : de 14h00 à 18h00 par tranches de 20 minutes
INSERT INTO creneaux (id_medecin, hdebut, mdebut, hfin, mfin) VALUES
(2, 14, 0, 14, 20), (2, 14, 20, 14, 40), (2, 14, 40, 15, 0),
(2, 15, 0, 15, 20), (2, 15, 20, 15, 40), (2, 15, 40, 16, 0),
(2, 16, 0, 16, 20), (2, 16, 20, 16, 40), (2, 16, 40, 17, 0),
(2, 17, 0, 17, 20), (2, 17, 20, 17, 40), (2, 17, 40, 18, 0);
-- un rendez-vous déjà pris, pour voir tout de suite un créneau occupé dans l'agenda
INSERT INTO rv (jour, id_creneau, id_client) VALUES
(CURDATE(), 2, 3);
|
- ligne 33 : la base est créée si elle n'existe pas, avec le jeu de caractères [utf8mb4] et la collation [utf8mb4_unicode_ci], qui compare les chaînes sans tenir compte des majuscules ni des accents (exemple 28) ;
- lignes 37-41 : les tables sont supprimées, puis recréées : le script remet la base dans son état de départ. L'ordre compte : une table ne peut pas être supprimée tant qu'une autre la référence par une clé étrangère ;
- lignes 46-55 : les utilisateurs : un login, un mot de passe haché par [bcrypt] (exemple 25), le nom affiché une fois connecté, et le rôle ([ADMIN], [DOCTOR] ou [USER]). La contrainte [unq_users_login] garantit qu'un login n'appartient qu'à un compte ;
- lignes 60-72 : chaque table a une clé primaire [id], générée par la base, et une colonne [version], le verrou optimiste (exemple 28). La colonne [user] est une clé étrangère vers [users(id)] : la base refuse une valeur qui ne serait pas l'id d'un utilisateur existant. [user] étant un mot du langage SQL, il est entouré d'accents graves. Trois contraintes : [fk_medecins_user] (la clé étrangère), [unq_medecins_user] (un compte par médecin, et un médecin par compte) et [unq_medecins_prenom_nom] (pas d'homonymes). Toutes les contraintes sont nommées : l'application reconnaît une contrainte violée à son nom ;
- lignes 77-89 : la table des clients a exactement les mêmes colonnes ;
- lignes 94-103 : un créneau appartient à un médecin ; ses heures de début et de fin sont rangées en heures et minutes ;
- lignes 108-118 : un rendez-vous : un jour (type [DATE], sans heure), un créneau et un client. La contrainte [unq1_rv] interdit de réserver deux fois un même créneau un même jour : c'est la base qui garantit cette règle, même si deux utilisateurs réservent au même instant ;
- lignes 130-140 : les utilisateurs de démonstration, dont le mot de passe est le login. Les hachages commencent par [$2b$10$] : l'algorithme ([bcrypt], variante [2b]) et le coût (10). Viennent ensuite le sel et le hachage. Ces hachages ont été calculés par la version [Flask] de l'application ; [PHP] écrit les siens avec le préfixe [$2y$], mais vérifie aussi bien les deux ;
- lignes 144-147 : la colonne [user] de chaque médecin est retrouvée par une sous-requête, à partir du login, plutôt qu'en supposant les valeurs des clés primaires ;
- lignes 170-171 : un rendez-vous déjà pris, aujourd'hui ([CURDATE()]), sur le deuxième créneau du docteur PELISSIER, pour Melle BISTROU : l'agenda montre ainsi tout de suite un créneau occupé.
Contrairement à l'usage de [Symfony], la base n'est pas créée par [Doctrine] à partir des entités, ni par des migrations (exemple 28) : elle est partagée avec les autres versions de l'application, et le script SQL est commun à toutes. La commande [php bin/console doctrine:schema:validate] vérifie que la description des entités est cohérente, et qu'elle correspond aux tables de la base.
11.6. La configuration du projet
11.6.1. composer.json
| {
"name": "rdvmedecins/rdvmedecins2-symfony",
"description": "RdvMedecins : prise de rendez-vous médicaux, pages HTML fabriquées par le serveur (Symfony + Twig + Doctrine)",
"type": "project",
"license": "MIT",
"require": {
"php": ">=8.4",
"ext-intl": "*",
"ext-pdo_mysql": "*",
"doctrine/doctrine-bundle": "^3.3",
"doctrine/orm": "^3.7",
"firebase/php-jwt": "^7.2",
"symfony/asset": "^8.1",
"symfony/console": "^8.1",
"symfony/dotenv": "^8.1",
"symfony/form": "^8.1",
"symfony/framework-bundle": "^8.1",
"symfony/lock": "^8.1",
"symfony/rate-limiter": "^8.1",
"symfony/runtime": "^8.1",
"symfony/security-bundle": "^8.1",
"symfony/translation": "^8.1",
"symfony/twig-bundle": "^8.1",
"symfony/validator": "^8.1",
"symfony/yaml": "^8.1",
"twig/twig": "^3.30"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"allow-plugins": {
"symfony/runtime": true
},
"optimize-autoloader": true,
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}
|
- lignes 6-27 : les bibliothèques dont l'application a besoin, et les contraintes qu'elles doivent respecter. [^8.1] se lit « la version 8.1 ou une version plus récente de la même version majeure 8 » : [Composer] accepte les versions qui corrigent des erreurs ou ajoutent des fonctionnalités, mais pas celles qui pourraient casser l'application (la version 9). Les lignes [php], [ext-intl] et [ext-pdo_mysql] ne désignent pas des bibliothèques, mais des exigences sur l'environnement : [Composer] refuse l'installation si la version de [PHP] ou les extensions ne conviennent pas, plutôt que de laisser l'application échouer plus tard. [Symfony] n'est pas une bibliothèque unique : chacun de ses composants (le routage, les formulaires, la sécurité...) est une bibliothèque, et l'application ne réclame que ceux dont elle a besoin ; [Composer] installe en plus ceux dont ils dépendent ;
- lignes 28-32 : la règle PSR-4 de l'application : les classes de l'espace de noms [App] sont dans le dossier [src] (exemple 01 : dans les exemples, le fichier [public/index.php] déclarait lui-même cette règle au chargeur de classes, par [addPsr4]) ;
- lignes 34-36 : le composant [Runtime] est un greffon (plugin) de [Composer] : il intervient pendant l'installation, pour écrire le fichier [vendor/autoload_runtime.php]. [Composer] demande l'autorisation d'exécuter le code d'un greffon ; elle est donnée ici.
[composer install] installe les versions qui respectent ces contraintes, et note les versions exactes installées dans un fichier [composer.lock]. Les installations suivantes (sur une autre machine, par exemple) utilisent ce fichier : elles installent exactement les mêmes versions. [composer update], au contraire, cherche les versions les plus récentes permises par [composer.json], et met à jour [composer.lock]. La commande [composer show] liste ce qui est installé dans le dossier [vendor]. Dans un projet ordinaire, ce fichier est complété par [Symfony Flex], à chaque [composer require] (exemple 01).
11.6.2. .env
| # =========================================================================
# .env : les réglages propres à CETTE installation, lus au démarrage de chaque
# requête par le composant Dotenv (cf. public/index.php). Ils deviennent des
# variables d'environnement, que la configuration (dossier config) lit par
# %env(NOM)%. Une vraie variable d'environnement du système l'emporte sur ce fichier.
# =========================================================================
# dev : développement ; prod : production (cf. README.md)
APP_ENV=dev
# la clé secrète de Symfony (jetons anti-CSRF, signatures...). En production :
# une autre valeur, longue et aléatoire
APP_SECRET=34b2364cc4c93df6e4ee35251a048088
# ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql) :
# le SGBD, l'utilisateur et son mot de passe (ceux de Laragon : root, sans mot de
# passe), le serveur, son port, la base, puis la version du serveur (pour MariaDB :
# serverVersion=10.11.2-MariaDB, par exemple) et le jeu de caractères
DATABASE_URL="mysql://root:@127.0.0.1:3306/dbrdvmedecins2?serverVersion=8.0.32&charset=utf8mb4"
# ---- l'authentification (cf. src/Securite/AuthService.php)
# clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256)
JWT_SECRET=ef1cfc9709f10b5b5d183b4deb6d5dbbe69518629c3b12a8
# durée de validité d'un jeton, en minutes (120 min = 2 h)
JWT_DUREE_MINUTES=120
# 1 en production (site servi en HTTPS) : les cookies ne circulent jamais en clair
COOKIE_SECURE=0
# ---- la protection contre les robots (cf. src/Securite/LimiteurTentatives.php)
# échecs de connexion autorisés pour un même login depuis une même adresse IP, puis
# blocage pendant cette durée (en minutes) ; une adresse IP est aussi bloquée si elle
# cumule LOGIN_MAX_ECHECS_IP échecs, tous logins confondus (un robot qui essaie des
# milliers de logins différents)
LOGIN_MAX_ECHECS=5
LOGIN_MAX_ECHECS_IP=25
LOGIN_BLOCAGE_MINUTES=15
# comptes clients créés au plus par heure et par adresse IP. En salle de cours, tous
# les postes sortent souvent par la même adresse : augmenter si besoin
INSCRIPTION_MAX_PAR_HEURE=10
|
Le fichier des réglages propres à cette installation (exemple 25) : ils changent d'une machine à l'autre (la base de données, ses identifiants) ou sont secrets (les clés). Dans un vrai projet, les secrets de production n'y figurent pas : ils sont donnés par de vraies variables d'environnement, par un fichier [.env.local] que l'on ne publie jamais, ou par le coffre des secrets de [Symfony] (exemple 25).
- ligne 9 : le mode de l'application : [dev] (développement : mode debug, page de débogage, cache reconstruit à chaque modification) ou [prod] ;
- ligne 12 : la clé secrète de [Symfony], qui sert notamment aux jetons anti-CSRF (exemple 21) ;
- ligne 18 : la connexion à la base (exemple 28) ;
- lignes 20-38 : les réglages propres à l'application : les jetons JWT (exemple 25) et la protection contre les robots (exemple 27).
11.6.3. config/bundles.php et config/packages/framework.yaml
| <?php
// config/bundles.php : les bundles de l'application (cf. exemple 01) : le cœur de
// Symfony, les vues Twig, la sécurité, et Doctrine (l'accès à la base)
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
];
|
Les quatre bundles de l'application : le cœur de [Symfony], [Twig] (exemple 13), la sécurité (exemple 25) et [Doctrine] (exemple 28).
| # =========================================================================
# config/packages/framework.yaml : la configuration du FrameworkBundle
# =========================================================================
framework:
secret: '%env(APP_SECRET)%'
# ---- les langues (cf. exemple 22) : le français par défaut, l'anglais ; le
# service de traduction et ses dictionnaires (dossier translations)
default_locale: fr
enabled_locales: ['fr', 'en']
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks: ['fr']
# ---- la validation des formulaires : ses messages sont des CLÉS de nos
# dictionnaires (ERRORS.FIRSTNAME_REQUIRED...), traduites dans le domaine
# [messages], le même que celui des vues (par défaut : le domaine [validators])
validation:
translation_domain: messages
# ---- la SESSION (messages flash, jetons anti-CSRF) : des fichiers du dossier
# var/sessions. Le cookie de session n'est jamais lisible par le JavaScript de
# la page (httponly), ni envoyé avec une requête POST venue d'un autre site
# (samesite lax), ni, en production, envoyé en clair (secure)
session:
handler_id: null
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
name: rdvmedecins_session
cookie_httponly: true
cookie_samesite: lax
cookie_secure: '%env(bool:COOKIE_SECURE)%'
csrf_protection: true
# ---- les verrous (cf. exemple 20), pour les limiteurs de tentatives
lock: 'flock://%kernel.project_dir%/var/lock'
# ---- les LIMITEURS de tentatives (cf. exemple 27 et src/Securite/LimiteurTentatives.php).
# fixed_window : au plus [limit] tentatives par période [interval], comptée
# depuis la première tentative
rate_limiter:
# les échecs de connexion d'un login, depuis une adresse IP
login:
policy: fixed_window
limit: '%env(int:LOGIN_MAX_ECHECS)%'
interval: '%env(LOGIN_BLOCAGE_MINUTES)% minutes'
# les échecs de connexion d'une adresse IP, tous logins confondus
login_ip:
policy: fixed_window
limit: '%env(int:LOGIN_MAX_ECHECS_IP)%'
interval: '%env(LOGIN_BLOCAGE_MINUTES)% minutes'
# les comptes créés depuis une adresse IP, par heure
inscription:
policy: fixed_window
limit: '%env(int:INSCRIPTION_MAX_PAR_HEURE)%'
interval: '1 hour'
|
- lignes 9-13 : les langues et le traducteur (exemple 22) ;
- lignes 18-19 : le composant de validation traduit ses messages dans le domaine [validators] (exemple 09) ; nos contraintes ont pour messages des clés de nos dictionnaires ([ERRORS.FIRSTNAME_REQUIRED]...), qui sont dans le domaine [messages] : on le lui indique. Un seul dictionnaire par langue suffit ainsi à toute l'application ;
- lignes 25-32 : la session (exemple 20) : des fichiers, dans le dossier [var/sessions]. L'application n'y met que peu de choses : les messages flash et les jetons anti-CSRF. Le cookie de session porte le nom [rdvmedecins_session] ; il n'est jamais lisible par le [JavaScript] de la page, ni envoyé avec une requête POST venue d'un autre site, ni, en production, envoyé en clair (le préfixe [bool:] convertit la variable en booléen, exemple 25) ;
- ligne 35 : les verrous (exemple 20), qu'utilisent les limiteurs de tentatives et le service des captchas ;
- lignes 40-55 : les trois limiteurs de la protection contre les robots (exemple 27), dont les réglages viennent du fichier [.env]. Une valeur peut mêler du texte et une variable : ['15 minutes'].
Le cache de l'application (le service [cache.app], exemple 20) n'a pas besoin d'être configuré : il est rangé dans des fichiers, dans le dossier [var/cache/dev/pools] (ou [prod]). L'application y met les listes de médecins et de clients, les captchas, les compteurs de tentatives et les laissez-passer : ce qui doit être partagé par toutes les requêtes. Avec plusieurs serveurs, on choisirait un cache partagé, comme [Redis] ([framework.cache.app: cache.adapter.redis]).
11.6.4. config/packages/security.yaml
| # =========================================================================
# config/packages/security.yaml : la sécurité (cf. exemples 25 et 26)
# =========================================================================
security:
# le hachage des mots de passe des comptes : bcrypt, 10 tours, comme les
# hachages du script SQL. PHP vérifie aussi les hachages $2b$ (Python) et $2a$
# (Java) des autres versions de l'application, qui partagent la même base
password_hashers:
App\Entity\User:
algorithm: bcrypt
cost: 10
# le fournisseur d'utilisateurs : sans objet ici, l'utilisateur connecté est
# décrit par son jeton (cf. src/Securite/JetonAuthenticator.php) ; Symfony en
# exige toutefois un : une liste vide
providers:
aucun:
memory: ~
firewalls:
# UN pare-feu pour toute l'application, SANS ÉTAT : l'utilisateur n'est pas
# rangé dans la session, il est retrouvé à chaque requête à partir du jeton
# JWT de son cookie
principal:
stateless: true
provider: aucun
custom_authenticators:
- App\Securite\JetonAuthenticator
# ---------------------------------------------------------------------
# LA RÈGLE D'ACCÈS : les règles sont examinées dans l'ordre, la première dont
# le motif (path) correspond au chemin de l'URL s'applique :
# - quelques URL PUBLIQUES (PUBLIC_ACCESS) : l'accueil, la connexion, le
# choix de la langue, la création d'un compte ;
# - des URL réservées à certains rôles (une liste de rôles : l'UN d'eux suffit) ;
# - toutes les AUTRES exigent un utilisateur authentifié (dernière règle).
# C'est la règle "tout est interdit sauf ce qui est permis" : une URL ajoutée
# plus tard, et qu'on aurait oublié de protéger, l'est d'office.
# Un visiteur anonyme refusé est envoyé à la page de connexion (le point
# d'entrée du pare-feu) ; un utilisateur authentifié sans le rôle demandé
# reçoit une page d'erreur 403.
# ---------------------------------------------------------------------
access_control:
- { path: ^/$, roles: PUBLIC_ACCESS }
- { path: ^/(login|logout)$, roles: PUBLIC_ACCESS }
- { path: ^/langue/, roles: PUBLIC_ACCESS }
- { path: ^/inscription, roles: PUBLIC_ACCESS }
# un patient annule SON rendez-vous depuis "Mon espace", pas depuis l'agenda
- { path: ^/agenda/annuler$, roles: [ROLE_ADMIN, ROLE_DOCTOR] }
- { path: ^/(medecins|clients), roles: ROLE_ADMIN }
- { path: ^/mon-, roles: ROLE_USER }
- { path: ^/, roles: IS_AUTHENTICATED }
|
La configuration de la sécurité (exemples 25 et 26) :
- lignes 8-11 : le hachage des mots de passe : [bcrypt], 10 tours, comme les hachages du script SQL. Pour vérifier un mot de passe, [PHP] lit l'algorithme et le coût dans le hachage lui-même : les hachages de la base, écrits par [Python] ([$2b$]) ou par [Java] ([$2a$]), sont vérifiés aussi bien que ceux de [PHP] ([$2y$]) ;
- lignes 16-18 : le fournisseur d'utilisateurs. L'authentificateur de l'application n'en a pas besoin : l'utilisateur connecté est décrit par son jeton, sans lecture de la base (exemple 25). Mais le composant de sécurité en exige un : un fournisseur [memory], sans utilisateurs ;
- lignes 24-28 : un seul pare-feu, sans état, pour toute l'application, avec notre authentificateur. Pas de motif ([pattern]) : il couvre toutes les URL. Les fichiers du dossier [public] ([Bootstrap], la feuille de style, le script) ne passent pas par [Symfony] : le serveur web les sert directement ;
- lignes 43-52 : la règle d'accès de l'application. Les règles [access_control] sont examinées dans l'ordre, et la première dont le motif correspond au chemin de l'URL s'applique. [PUBLIC_ACCESS] ouvre une URL à tous, y compris aux visiteurs anonymes : l'accueil, la connexion, le choix de la langue, la création de compte. Suivent les URL réservées à certains rôles : une liste de rôles (ligne 49) signifie que l'un d'eux suffit. La dernière règle, [^/], correspond à toutes les URL restantes, et exige un utilisateur authentifié. C'est la règle « tout est interdit sauf ce qui est permis » : une URL ajoutée plus tard, et qu'on aurait oublié de protéger, l'est d'office. L'erreur inverse (une URL publique qu'on aurait oublié d'ouvrir) se voit tout de suite ; l'erreur d'une page protégée oubliée, beaucoup moins.
Les rôles sont vérifiés ici, dans un seul fichier, par des motifs d'URL, plutôt que par des attributs [#[IsGranted]] sur les actions (exemple 26) : les deux sont possibles, et se combinent.
11.6.5. config/packages/doctrine.yaml, twig.yaml, config/routes.yaml
| # config/packages/doctrine.yaml : Doctrine, l'accès à la base (cf. exemple 28)
doctrine:
# la connexion : l'URL DATABASE_URL du fichier .env. En mode debug, chaque ordre
# SQL est écrit dans le journal, que le serveur de développement affiche
dbal:
url: '%env(resolve:DATABASE_URL)%'
# les ENTITÉS : les classes du dossier src/Entity, décrites par des attributs PHP
orm:
mappings:
App:
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
|
La configuration de [Doctrine] est celle de l'exemple 28.
| # config/packages/twig.yaml : les vues Twig (dossier templates)
twig:
default_path: '%kernel.project_dir%/templates'
# les formulaires sont dessinés avec les classes de Bootstrap 5 (cf. exemple 18)
form_themes: ['bootstrap_5_layout.html.twig']
|
Les champs des formulaires sont dessinés avec les classes de [Bootstrap] 5 (exemple 18).
| # config/routes.yaml : les routes sont décrites par les attributs #[Route] des
# contrôleurs du dossier src/Controller (cf. exemple 01)
controllers:
resource: routing.controllers
|
Les routes sont décrites par les attributs [#[Route]] des contrôleurs (exemple 01). La commande [php bin/console debug:router] les liste toutes (cf. plus loin, la couche web).
11.7. Le démarrage de l'application
11.7.1. public/index.php et bin/console
| <?php
// public/index.php : le point d'entrée de TOUTES les requêtes HTTP. Le dossier
// [public] est la racine du site : seuls ses fichiers sont accessibles depuis le
// navigateur (Bootstrap, feuille de style, script), jamais le code de l'application.
//
// Le fichier vendor/autoload_runtime.php est écrit par le composant Runtime de
// Symfony (la bibliothèque symfony/runtime) : il charge les classes, lit le fichier
// .env, active le mode debug, puis appelle la fonction ci-dessous, qui rend le
// noyau de l'application ; il lui fait enfin traiter la requête en cours, envoie la
// réponse, et termine la requête (cf. le fichier index.php des exemples)
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
|
Le point d'entrée des exemples (exemple 01) chargeait les classes, lisait le fichier [.env], activait le mode debug, créait le noyau, lui faisait traiter la requête, envoyait la réponse et terminait la requête. Tout cela est ici confié au composant [Runtime] de [Symfony] (ligne 14) : c'est la forme du fichier [public/index.php] d'une application [Symfony] ordinaire.
- ligne 14 : le fichier [vendor/autoload_runtime.php], écrit par [Composer] (cf. [composer.json]), charge le chargeur de classes, puis exécute le script qui l'a inclus, ici [public/index.php], pour obtenir sa valeur de retour : un fichier [PHP] inclus peut rendre une valeur ([return]), comme une fonction ;
- lignes 16-18 : cette valeur est une fonction anonyme, qui reçoit le contexte de l'application (les variables d'environnement, lues dans [.env] par le composant [Runtime]), et rend le noyau. Le composant [Runtime] se charge du reste : l'objet [Request], [handle], [send], [terminate].
L'intérêt de cette forme : le même [index.php] fonctionne avec d'autres serveurs que [PHP-FPM]. Avec le mode worker de [FrankenPHP] (chapitre sur les portées), par exemple, un autre « runtime » garde le noyau en mémoire et lui fait traiter des requêtes à la suite, sans que ce fichier change. Le fichier [bin/console] a la même forme : sa fonction rend l'application des commandes, construite autour du noyau.
11.7.2. src/Kernel.php et config/services.yaml
| <?php
// src/Kernel.php : le noyau de l'application (cf. exemple 01)
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function boot(): void
{
parent::boot();
// le fuseau horaire : celui du cabinet médical. Il fixe la date du jour (pas
// de rendez-vous dans le passé, cf. src/Metier/Dates.php)
date_default_timezone_set('Europe/Paris');
}
}
|
Le noyau des exemples (exemple 01), avec une méthode de plus :
- lignes 13-19 : [boot] est appelée au démarrage du noyau, à chaque requête. Elle fixe le fuseau horaire de [PHP]. Par défaut, [PHP] travaille dans le fuseau de sa configuration ([date.timezone], dans le fichier [php.ini]), souvent le temps universel (UTC) ; or la date du jour sert à une règle de gestion : pas de rendez-vous dans le passé. À Paris, entre minuit et deux heures du matin l'été, le jour UTC est encore la veille : le fuseau du cabinet s'impose.
| # =========================================================================
# config/services.yaml : les SERVICES de l'application (cf. exemple 06). Toutes les
# classes du dossier src/ sont déclarées au conteneur, qui les crée à la demande et
# fournit à chacune les objets dont elle a besoin (injection de dépendances) : un
# contrôleur reçoit l'ApplicationModel, qui reçoit la couche métier, qui reçoit les
# DAO, qui reçoivent le gestionnaire d'entités de Doctrine...
# Un seul objet de chaque classe pendant une requête.
# =========================================================================
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
# des objets de DONNÉES, créés par le code (ou par Doctrine), jamais par le
# conteneur : ce ne sont pas des services
exclude:
- '../src/Entity/'
- '../src/Domain/'
- '../src/Vues/'
- '../src/Kernel.php'
|
- lignes 10-15 : toutes les classes du dossier [src] sont des services, créés par le conteneur, qui fournit à chacun les objets qu'il demande dans son constructeur (exemple 06). Les services sont partagés : un seul [ApplicationModel], un seul [MetierService]... pour toute la requête (chapitre sur les portées) ;
- lignes 16-22 : sauf les classes de données : les entités, créées par [Doctrine] ou par la couche métier, les objets calculés de l'agenda et les modèles des vues, créés par les actions. Ils sont exclus : ce ne sont pas des services. (Les autres classes de données, comme [DonneesPersonne] ou [Message], sont déclarées, mais, comme personne ne les demande au conteneur, il les écarte lui-même.)
Il n'y a rien d'autre à déclarer : les écouteurs ([#[AsEventListener]]), les contrôleurs ([#[Route]]), les extensions [Twig], les types de formulaires et l'authentificateur sont reconnus par le conteneur d'après leurs attributs ou leur classe mère ([autoconfigure]), et configurés en conséquence. La commande [php bin/console debug:container App] liste les services de l'application.
11.8. Les entités
Les entités [Doctrine] (exemple 28) : une classe par table, dans le dossier [src/Entity].
11.8.1. src/Entity/Role.php
| <?php
// src/Entity/Role.php : les trois rôles d'un utilisateur (colonne users.role) :
// - ADMIN : l'administrateur - rendez-vous + gestion des médecins et clients ;
// - DOCTOR : un médecin - réserve et annule des rendez-vous ;
// - USER : un client (patient) - réserve pour lui-même, gère son compte.
// Une énumération "adossée" (backed enum) : chaque cas a une valeur, la chaîne
// rangée telle quelle en base ; Role::from('ADMIN') fait le chemin inverse.
namespace App\Entity;
enum Role: string
{
case ADMIN = 'ADMIN';
case DOCTOR = 'DOCTOR';
case USER = 'USER';
// le rôle au sens de Symfony (cf. config/packages/security.yaml) : ROLE_ADMIN...
public function roleSymfony(): string
{
return 'ROLE_'.$this->value;
}
}
|
Les trois rôles, sous la forme d'une énumération ([enum], [PHP] 8.1) : un type dont les seules valeurs possibles sont ses cas, [Role::ADMIN], [Role::DOCTOR] et [Role::USER]. Une variable de type [Role] ne peut pas contenir ['ADMINN'] : l'erreur de frappe est détectée. [enum Role: string] déclare une énumération adossée (backed enum) : chaque cas a une valeur, ici la chaîne rangée en base. [Role::ADMIN->value] vaut ['ADMIN'] ; [Role::from('ADMIN')] rend le cas correspondant (et lève une exception pour une valeur inconnue) ; [Role::tryFrom(...)] rend [null] dans ce cas. Une énumération peut avoir des méthodes : [roleSymfony()] (lignes 18-21) rend le rôle au sens de [Symfony], [ROLE_ADMIN], [ROLE_DOCTOR] ou [ROLE_USER] (exemple 25), celui que vérifient les règles [access_control]. [$this] y désigne le cas.
11.8.2. src/Entity/User.php
| <?php
// =========================================================================
// src/Entity/User.php : un utilisateur autorisé à se connecter (table [users]).
// Chaque médecin et chaque client possède SON compte (colonne [user] des tables
// [medecins] et [clients], cf. Personne.php).
//
// Contrainte d'unicité SQL : unq_users_login (un login n'appartient qu'à un seul
// compte). Elle est créée par le script database/dbrdvmedecins2.sql ; la couche
// métier la reconnaît par son NOM (cf. src/Metier/MetierService.php).
//
// PasswordAuthenticatedUserInterface : un utilisateur qui a un mot de passe (haché) ;
// c'est ce qu'attend le service de hachage de Symfony (cf. AuthService).
// =========================================================================
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User implements PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// le numéro de version de la ligne : le verrou optimiste (cf. exemple 28)
#[ORM\Version]
#[ORM\Column(type: Types::INTEGER)]
private int $version = 0;
public function __construct(
#[ORM\Column(length: 30)]
private string $login,
// le mot de passe HACHÉ (bcrypt), jamais le mot de passe lui-même
#[ORM\Column(length: 100)]
private string $password,
// le nom affiché une fois connecté : "Marie PELISSIER"
#[ORM\Column(length: 40)]
private string $nom,
// enumType : la colonne (un texte) devient un Role, et inversement
#[ORM\Column(length: 10, enumType: Role::class)]
private Role $role,
) {
}
public function getId(): ?int
{
return $this->id;
}
public function getVersion(): int
{
return $this->version;
}
public function getLogin(): string
{
return $this->login;
}
public function getPassword(): string
{
return $this->password;
}
public function getNom(): string
{
return $this->nom;
}
public function getRole(): Role
{
return $this->role;
}
public function setLogin(string $login): void
{
$this->login = $login;
}
public function setPassword(string $hachage): void
{
$this->password = $hachage;
}
public function setNom(string $nom): void
{
$this->nom = $nom;
}
}
|
- lignes 21-23 : l'entité de la table [users]. Elle implémente l'interface [PasswordAuthenticatedUserInterface] du composant de sécurité (exemple 25) : c'est ce qu'attend le service de hachage des mots de passe ;
- lignes 25-33 : la clé primaire et la version, le verrou optimiste (exemple 28) ;
- lignes 35-48 : les autres colonnes sont déclarées dans le constructeur, par la promotion de constructeur (exemple 03) : les attributs [#[ORM\Column]] se placent aussi bien sur un paramètre promu que sur une propriété. [length] donne la longueur des colonnes [VARCHAR]. Ligne 45 : l'option [enumType] convertit la colonne [role] (un texte) en [Role] à la lecture, et inversement à l'écriture : [$user->getRole()] rend un [Role], jamais une chaîne ;
- lignes 50-93 : les accesseurs, et trois méthodes de modification. La clé primaire et la version n'ont pas de méthode de modification : c'est [Doctrine] qui les tient à jour.
11.8.3. src/Entity/Personne.php
| <?php
// =========================================================================
// src/Entity/Personne.php : ce qu'ont en commun les médecins (table [medecins]) et
// les clients (table [clients]) : un titre, un nom, un prénom - et le compte
// utilisateur associé (colonne [user]).
//
// Les deux tables ont exactement les mêmes colonnes : elles sont décrites UNE fois,
// dans cette classe ABSTRAITE (on ne crée jamais une "Personne"), dont héritent
// Medecin et Client ; chacune ne précise que le nom de sa table.
// #[ORM\MappedSuperclass] : une classe mère dont les colonnes sont celles des
// entités qui en héritent, mais qui n'a pas de table à elle.
//
// Attention : [Client] désigne ici un PATIENT du cabinet médical, pas un client
// HTTP ! C'est le vocabulaire du domaine métier original, conservé tel quel.
// =========================================================================
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\MappedSuperclass]
abstract class Personne
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
protected ?int $id = null;
// verrou optimiste (cf. exemple 28)
#[ORM\Version]
#[ORM\Column(type: Types::INTEGER)]
protected int $version = 0;
public function __construct(
// Mr, Mme, Melle
#[ORM\Column(length: 5)]
protected string $titre,
#[ORM\Column(length: 20)]
protected string $nom,
#[ORM\Column(length: 20)]
protected string $prenom,
// -------------------------------------------------------------------
// La RELATION vers le compte utilisateur : une personne a UN compte,
// désigné par la clé étrangère [user]. #[ORM\OneToOne] : un compte par
// personne, une personne par compte ; #[ORM\JoinColumn] : la colonne de la
// clé étrangère (entourée d'accents graves : `user` est un mot du langage
// SQL de MySQL). $medecin->getCompte() rend un objet User, lu en base au
// premier accès - ou dès la requête, si elle le demande (cf. src/Dao)
// -------------------------------------------------------------------
#[ORM\OneToOne]
#[ORM\JoinColumn(name: '`user`', nullable: false)]
protected User $compte,
) {
}
public function getId(): ?int
{
return $this->id;
}
public function getVersion(): int
{
return $this->version;
}
public function getTitre(): string
{
return $this->titre;
}
public function getNom(): string
{
return $this->nom;
}
public function getPrenom(): string
{
return $this->prenom;
}
public function getCompte(): User
{
return $this->compte;
}
public function modifier(string $titre, string $nom, string $prenom): void
{
$this->titre = $titre;
$this->nom = $nom;
$this->prenom = $prenom;
}
// "Mme Alice DURAND"
public function libelle(): string
{
return "{$this->titre} {$this->prenom} {$this->nom}";
}
}
|
Les tables [medecins] et [clients] ont les mêmes colonnes. Leur description commune est écrite une fois, dans une classe abstraite ([abstract class]) : une classe dont on ne crée jamais d'objet, qui ne sert qu'à être héritée. Les classes [Medecin] et [Client] en héritent.
- ligne 22 : l'attribut [#[ORM\MappedSuperclass]] fait de la classe une super-classe : ses colonnes sont celles de chacune des entités qui en héritent, mais elle n'a pas de table à elle. ([Doctrine] propose aussi de vraies hiérarchies d'entités, rangées dans une seule table ou dans une table par classe : ce n'est pas le cas ici, où les deux tables sont indépendantes.) Les propriétés sont [protected] : visibles des classes filles ;
- lignes 51-53 : la relation vers le compte. [#[ORM\OneToOne]] : une personne a un compte, et un compte appartient au plus à une personne. [#[ORM\JoinColumn]] désigne la colonne de la clé étrangère, [user], et interdit qu'elle soit vide ([nullable: false]). Le nom de la colonne est entouré d'accents graves : [user] est un mot du langage SQL de [MySQL], et [Doctrine] l'entourera ainsi dans les ordres SQL qu'il écrit (cf. la sortie de la console, plus haut). La propriété [compte] est un objet [User], et non un numéro : c'est l'intérêt d'un ORM. Quand [Doctrine] lit une personne, il lit aussi son compte si la requête le demande (chargement anticipé, cf. la couche DAO) ; sinon, il place dans la propriété un objet [User] paresseux (lazy), qui ne sera lu en base qu'au premier accès à l'une de ses propriétés, sauf son id, qu'il connaît déjà ;
- lignes 87-92 : la modification des trois colonnes d'identité ;
- lignes 95-98 : le libellé d'une personne, « Mme Alice DURAND », utilisé par toutes les pages. Dans une chaîne entre guillemets, [{$this->titre}] insère la valeur d'une propriété.
11.8.4. src/Entity/Medecin.php et Client.php
| <?php
// src/Entity/Medecin.php : un médecin (table [medecins]).
// Contrainte d'unicité SQL : unq_medecins_prenom_nom (deux médecins ne peuvent pas
// porter les mêmes prénom et nom).
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'medecins')]
class Medecin extends Personne
{
}
|
| <?php
// src/Entity/Client.php : un client, c'est-à-dire un PATIENT (table [clients]).
// Contrainte d'unicité SQL : unq_clients_prenom_nom.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'clients')]
class Client extends Personne
{
}
|
Deux entités qui ne précisent que leur table : tout le reste est hérité. Un [Client] est un patient du cabinet, pas un client HTTP : c'est le vocabulaire du domaine métier original, conservé dans toutes les versions de l'application.
11.8.5. src/Entity/Creneau.php
| <?php
// src/Entity/Creneau.php : un créneau horaire d'un médecin (table [creneaux]) :
// 8h00 - 8h20, etc. Les créneaux sont créés par le script SQL : l'application ne
// fait que les lire.
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'creneaux')]
class Creneau
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Version]
#[ORM\Column(type: Types::INTEGER)]
private int $version = 0;
// plusieurs créneaux pour un même médecin : #[ORM\ManyToOne], par la clé
// étrangère [id_medecin]
#[ORM\ManyToOne]
#[ORM\JoinColumn(name: 'id_medecin', nullable: false)]
private Medecin $medecin;
#[ORM\Column]
private int $hdebut;
#[ORM\Column]
private int $mdebut;
#[ORM\Column]
private int $hfin;
#[ORM\Column]
private int $mfin;
public function getId(): ?int
{
return $this->id;
}
public function getMedecin(): Medecin
{
return $this->medecin;
}
// "8:00 - 8:20" ; sprintf('%02d') : "8:05" plutôt que "8:5"
public function horaire(): string
{
return sprintf('%d:%02d - %d:%02d', $this->hdebut, $this->mdebut, $this->hfin, $this->mfin);
}
}
|
- lignes 26-28 : la relation vers le médecin du créneau. [#[ORM\ManyToOne]] : plusieurs créneaux pour un même médecin, par la clé étrangère [id_medecin] ;
- lignes 30-40 : les heures et minutes de début et de fin. Les créneaux sont créés par le script SQL : la classe n'a pas de constructeur, et [Doctrine] remplit ses propriétés lui-même ;
- lignes 53-56 : l'horaire du créneau, « 8:00 - 8:20 ». Le format [%02d] de [sprintf] écrit un entier sur deux chiffres au moins, complété par des zéros : [8:05] plutôt que [8:5].
11.8.6. src/Entity/Rv.php
| <?php
// src/Entity/Rv.php : un rendez-vous (table [rv]) : un client, sur un créneau, un
// jour donné.
// Contrainte d'unicité SQL : unq1_rv (jour, id_creneau) - un créneau n'est réservé
// qu'une fois par jour. Créée par le script SQL, reconnue par son nom dans la
// couche métier.
namespace App\Entity;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'rv')]
class Rv
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Version]
#[ORM\Column(type: Types::INTEGER)]
private int $version = 0;
public function __construct(
// le type date_immutable : la colonne [jour] (type SQL DATE) devient une date
// DateTimeImmutable, à minuit ; elle est écrite en base au format aaaa-mm-jj
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private DateTimeImmutable $jour,
#[ORM\ManyToOne]
#[ORM\JoinColumn(name: 'id_creneau', nullable: false)]
private Creneau $creneau,
#[ORM\ManyToOne]
#[ORM\JoinColumn(name: 'id_client', nullable: false)]
private Client $client,
) {
}
public function getId(): ?int
{
return $this->id;
}
public function getJour(): DateTimeImmutable
{
return $this->jour;
}
public function getCreneau(): Creneau
{
return $this->creneau;
}
public function getClient(): Client
{
return $this->client;
}
}
|
- lignes 30-31 : la colonne [jour] (type SQL [DATE]) est convertie en [DateTimeImmutable], à minuit (type [Doctrine] [date_immutable]). Immuable : ses méthodes ne modifient jamais l'objet, elles en rendent un nouveau ([$jour->modify('+1 day')] rend le lendemain, [$jour] est inchangé). Toute l'application manipule des dates immuables : une date partagée entre plusieurs objets ne risque pas d'être modifiée par l'un d'eux à l'insu des autres ;
- lignes 32-37 : deux relations, vers le créneau et vers le client du rendez-vous.
11.9. Le domaine : l'agenda
11.9.1. src/Domain/CreneauMedecinJour.php et AgendaMedecinJour.php
| <?php
// src/Domain/CreneauMedecinJour.php : un créneau horaire, et le rendez-vous
// éventuellement pris sur ce créneau un jour donné (null si le créneau est libre).
// Un objet "métier" calculé, qui ne correspond à aucune table.
//
// readonly : les propriétés sont fixées une fois pour toutes par le constructeur -
// exactement ce qu'il faut pour un objet calculé, que personne ne modifie ensuite.
namespace App\Domain;
use App\Entity\Creneau;
use App\Entity\Rv;
readonly class CreneauMedecinJour
{
public function __construct(
public Creneau $creneau,
public ?Rv $rv,
) {
}
}
|
| <?php
// src/Domain/AgendaMedecinJour.php : l'agenda complet d'un médecin pour un jour
// donné, la liste de ses créneaux, chacun libre ou occupé. Calculé par
// MetierService::getAgendaMedecinJour, transformé en tableau HTML par
// AgendaController.
namespace App\Domain;
use App\Entity\Medecin;
use DateTimeImmutable;
readonly class AgendaMedecinJour
{
/** @param list<CreneauMedecinJour> $creneauxMedecinJour */
public function __construct(
public Medecin $medecin,
public DateTimeImmutable $jour,
public array $creneauxMedecinJour,
) {
}
}
|
Deux objets calculés, qui ne correspondent à aucune table : un créneau et son rendez-vous éventuel un jour donné ; l'agenda d'un médecin pour un jour, la liste de ses créneaux. Ce sont des classes [readonly] (exemple 03), dont les propriétés sont déclarées dans le constructeur. Le commentaire [/** @param list<CreneauMedecinJour> ... */] précise le contenu du tableau : [PHP] ne sait pas typer le contenu d'un tableau, mais les éditeurs ([PHP Intelephense]) et les outils d'analyse lisent ces commentaires, dits [PHPDoc].
11.10. La couche DAO
La couche DAO (exemple 28) regroupe les requêtes : une classe par table, sans règle de gestion. Ses méthodes n'ouvrent pas de transaction : c'est la couche métier qui les appelle, dans les siennes. Elles sont écrites en DQL (exemple 28), ou avec le constructeur de requêtes de [Doctrine], qui fabrique une requête DQL par des appels de méthodes ; les valeurs y sont des paramètres de la requête : pas d'injection SQL possible.
11.10.1. src/Dao/PersonneDao.php
| <?php
// =========================================================================
// src/Dao/PersonneDao.php : la couche DAO commune aux médecins et aux clients.
//
// Les DAO des médecins et des clients seraient identiques : cette classe ABSTRAITE
// les écrit une seule fois. $this->classe est la classe de l'entité concernée
// (Medecin::class ou Client::class) : les requêtes DQL portent sur SA table.
//
// Les requêtes sont écrites en DQL, le langage de requêtes de Doctrine (cf.
// exemple 28), que Doctrine traduit en SQL. Les valeurs (prénom, nom...) sont des
// PARAMÈTRES de la requête (:prenom) : pas de concaténation de chaînes, donc pas
// d'injection SQL. Ces méthodes n'ouvrent aucune transaction : c'est la couche
// métier qui les appelle, dans les siennes.
// =========================================================================
namespace App\Dao;
use App\Entity\Personne;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
abstract class PersonneDao
{
/** @param class-string<Personne> $classe */
public function __construct(protected EntityManagerInterface $em, protected string $classe)
{
}
// -----------------------------------------------------------------------
// une requête sur la table de la classe, compte utilisateur compris. Le
// CONSTRUCTEUR DE REQUÊTES de Doctrine (createQueryBuilder) fabrique la requête
// DQL par des appels de méthodes :
// SELECT p, c FROM App\Entity\Medecin p JOIN p.compte c
// JOIN p.compte c : la jointure SQL avec la table [users] ; SELECT p, c : les
// comptes sont chargés PAR LA MÊME requête. Sans cela, chaque accès à
// $personne->getCompte() déclencherait sa propre requête
// -----------------------------------------------------------------------
protected function requete(): QueryBuilder
{
return $this->em->createQueryBuilder()
->select('p', 'c')
->from($this->classe, 'p')
->join('p.compte', 'c');
}
// liste triée par nom puis prénom
/** @return list<Personne> */
public function lister(): array
{
return $this->requete()->orderBy('p.nom')->addOrderBy('p.prenom')->getQuery()->getResult();
}
// une personne et son compte ; null si elle n'existe pas
public function lire(int $id): ?Personne
{
return $this->requete()->where('p.id = :id')->setParameter('id', $id)
->getQuery()->getOneOrNullResult();
}
// la personne dont le compte utilisateur est [$idUser]
public function lireParUser(int $idUser): ?Personne
{
return $this->requete()->where('c.id = :idUser')->setParameter('idUser', $idUser)
->getQuery()->getOneOrNullResult();
}
// la personne qui porte ces prénom et nom, s'il y en a une. La comparaison est
// faite par MySQL, donc avec la collation de la base (utf8mb4_unicode_ci : sans
// tenir compte des majuscules ni des accents) - exactement la même règle que
// celle de la contrainte d'unicité SQL
public function homonyme(string $prenom, string $nom): ?Personne
{
return $this->requete()->where('p.prenom = :prenom')->andWhere('p.nom = :nom')
->setParameter('prenom', $prenom)->setParameter('nom', $nom)
->setMaxResults(1)->getQuery()->getOneOrNullResult();
}
// la personne sera insérée par le prochain flush (celui de la transaction de
// la couche métier) ; la base générera son id, que Doctrine rangera dans l'objet
public function ajouter(Personne $personne): void
{
$this->em->persist($personne);
}
// la personne sera supprimée par le prochain flush
public function supprimer(Personne $personne): void
{
$this->em->remove($personne);
}
}
|
La couche DAO commune aux médecins et aux clients, écrite une fois dans une classe abstraite.
- ligne 25 : le constructeur reçoit le gestionnaire d'entités (exemple 28) et la classe concernée, [Medecin::class] ou [Client::class] (une chaîne, le nom complet de la classe). Le commentaire [@param class-string<Personne>] le précise pour les éditeurs. Le conteneur de services ne sait pas quelle chaîne fournir : ce sont les classes filles qui appellent ce constructeur ;
- lignes 38-44 : une requête qui charge aussi les comptes des personnes lues. [createQueryBuilder] crée un constructeur de requêtes ; [select('p', 'c')], [from($this->classe, 'p')] et [join('p.compte', 'c')] écrivent la requête DQL [SELECT p, c FROM App\Entity\Medecin p JOIN p.compte c]. La jointure ([JOIN]) suit la relation [compte] de l'entité : [Doctrine] en déduit la condition SQL ([ON m0_.user = u1_.id]). Le [SELECT] des deux alias demande le chargement anticipé (eager loading) des comptes : une seule requête lit les personnes et leurs comptes. Sans lui, la liste des médecins affichée par l'administrateur déclencherait une requête par médecin, au premier accès à [$medecin->getCompte()->getLogin()] : c'est le problème dit « N + 1 requêtes ». Autre raison : les listes des médecins et des clients sont gardées dans le cache ([ApplicationModel], plus loin) ; les objets qui y entrent doivent être complets ;
- lignes 48-51 : la liste, triée par nom puis prénom ([orderBy], puis [addOrderBy] : un second critère ; un second [orderBy] remplacerait le premier). [getQuery()] rend la requête DQL, [getResult()] l'exécute et rend le tableau des entités ;
- lignes 54-58 : [where] ajoute une condition, avec un paramètre nommé ([:id]), dont [setParameter] donne la valeur. [getOneOrNullResult] rend l'unique entité trouvée, ou [null] ;
- lignes 61-65 : la personne dont le compte est [$idUser], condition sur l'id du compte joint ;
- lignes 71-76 : la personne de mêmes prénom et nom. [andWhere] ajoute une seconde condition (un second [where] remplacerait la première). La comparaison est faite par [MySQL], avec la collation de la base : sans tenir compte des majuscules ni des accents, exactement comme la contrainte d'unicité (exemple 28) ;
- lignes 80-89 : l'ajout et la suppression, qui seront envoyés à la base par le [flush] de la transaction de la couche métier (exemple 28).
Il n'y a pas de méthode de modification : la couche métier modifie l'entité lue, et [Doctrine] enverra l'[UPDATE] au [flush] (le suivi des modifications, exemple 28), avec le verrou optimiste.
11.10.2. src/Dao/MedecinDao.php et ClientDao.php
| <?php
// src/Dao/MedecinDao.php : le DAO des médecins - tout est hérité de PersonneDao.
namespace App\Dao;
use App\Entity\Medecin;
use Doctrine\ORM\EntityManagerInterface;
class MedecinDao extends PersonneDao
{
public function __construct(EntityManagerInterface $em)
{
parent::__construct($em, Medecin::class);
}
}
|
Tout est hérité : le constructeur reçoit le gestionnaire d'entités du conteneur, et le transmet à [PersonneDao] avec la classe de l'entité ([parent::__construct] appelle le constructeur de la classe mère).
| <?php
// src/Dao/ClientDao.php : le DAO des clients - celui des personnes, plus une
// méthode de verrouillage.
namespace App\Dao;
use App\Entity\Client;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
class ClientDao extends PersonneDao
{
public function __construct(EntityManagerInterface $em)
{
parent::__construct($em, Client::class);
}
// -----------------------------------------------------------------------
// pose un VERROU sur la ligne d'un client jusqu'à la fin de la transaction en
// cours (SELECT ... FOR UPDATE), et rend son id (null si le client n'existe
// pas). Une seconde transaction qui demande le même verrou ATTEND que la
// première soit terminée. LockMode::PESSIMISTIC_WRITE : le verrou "pessimiste"
// de Doctrine, qui exige une transaction ouverte.
// Utilisé pour la prise de rendez-vous (cf. MetierService::ajouterRv) : deux
// réservations simultanées pour le même patient sont ainsi traitées l'une
// APRÈS l'autre, et la seconde voit le rendez-vous créé par la première.
// -----------------------------------------------------------------------
public function verrouiller(int $id): ?int
{
$ligne = $this->em->createQuery('SELECT c.id FROM App\Entity\Client c WHERE c.id = :id')
->setParameter('id', $id)
->setLockMode(LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
return $ligne['id'] ?? null;
}
}
|
- lignes 28-35 : un verrou pessimiste, l'autre famille de verrous. [setLockMode(LockMode::PESSIMISTIC_WRITE)] ajoute [FOR UPDATE] à la requête : [SELECT c0_.id AS id_0 FROM clients c0_ WHERE c0_.id = ? FOR UPDATE]. La ligne lue est verrouillée jusqu'à la fin de la transaction en cours : une autre transaction qui demande le même verrou attend que la première se termine. [Doctrine] refuse ce verrou hors d'une transaction. La requête ne lit que l'id ([SELECT c.id]) : son résultat n'est pas une entité, mais un tableau associatif ([['id' => 3]]), ou [null]. Nous verrons, avec la couche métier, à quoi sert ce verrou.
11.10.3. src/Dao/CreneauDao.php
| <?php
// src/Dao/CreneauDao.php : couche DAO des créneaux horaires.
namespace App\Dao;
use App\Entity\Creneau;
use Doctrine\ORM\EntityManagerInterface;
class CreneauDao
{
public function __construct(private EntityManagerInterface $em)
{
}
// un créneau et son médecin
public function lire(int $id): ?Creneau
{
return $this->em->createQuery(
'SELECT c, m FROM App\Entity\Creneau c JOIN c.medecin m WHERE c.id = :id')
->setParameter('id', $id)->getOneOrNullResult();
}
// les créneaux d'un médecin, dans l'ordre chronologique. c.medecin = :idMedecin :
// une association comparée à un id, celui de la clé étrangère
/** @return list<Creneau> */
public function listerParMedecin(int $idMedecin): array
{
return $this->em->createQuery(
'SELECT c FROM App\Entity\Creneau c WHERE c.medecin = :idMedecin ORDER BY c.hdebut, c.mdebut')
->setParameter('idMedecin', $idMedecin)->getResult();
}
// supprime tous les créneaux d'un médecin (suppression d'un médecin) : une
// requête DQL DELETE, exécutée aussitôt :
// DELETE FROM creneaux WHERE id_medecin = ?
public function supprimerParMedecin(int $idMedecin): void
{
$this->em->createQuery('DELETE FROM App\Entity\Creneau c WHERE c.medecin = :idMedecin')
->setParameter('idMedecin', $idMedecin)->execute();
}
}
|
Un créneau et son médecin (chargement anticipé) ; les créneaux d'un médecin, par ordre chronologique : [c.medecin = :idMedecin] compare une relation à un id, celui de la clé étrangère ; et un [DELETE] écrit en DQL, exécuté aussitôt ([execute()]), sans lire les lignes d'abord.
11.10.4. src/Dao/RvDao.php
| <?php
// =========================================================================
// src/Dao/RvDao.php : couche DAO des rendez-vous.
//
// La table [rv] ne référence pas directement le médecin : on passe par le créneau
// (JOIN r.creneau cr ... WHERE cr.medecin = ...).
// Les dates sont passées avec leur type (Types::DATE_IMMUTABLE) : Doctrine les
// écrit au format de la base, aaaa-mm-jj.
// =========================================================================
namespace App\Dao;
use App\Entity\Rv;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
class RvDao
{
public function __construct(private EntityManagerInterface $em)
{
}
// un rendez-vous, son client et son créneau
public function lire(int $id): ?Rv
{
return $this->em->createQuery(
'SELECT r, cl, cr FROM App\Entity\Rv r JOIN r.client cl JOIN r.creneau cr WHERE r.id = :id')
->setParameter('id', $id)->getOneOrNullResult();
}
// les rendez-vous d'un médecin un jour donné, avec leur client
/** @return list<Rv> */
public function rvMedecinJour(int $idMedecin, DateTimeImmutable $jour): array
{
return $this->em->createQuery(
'SELECT r, cl FROM App\Entity\Rv r JOIN r.client cl JOIN r.creneau cr
WHERE r.jour = :jour AND cr.medecin = :idMedecin')
->setParameter('jour', $jour, Types::DATE_IMMUTABLE)
->setParameter('idMedecin', $idMedecin)
->getResult();
}
// -----------------------------------------------------------------------
// les rendez-vous d'un client à partir du jour [$depuis] (inclus), dans l'ordre
// chronologique, avec le créneau ET le médecin de ce créneau (deux jointures :
// JOIN r.creneau cr JOIN cr.medecin m, et SELECT r, cr, m)
// -----------------------------------------------------------------------
/** @return list<Rv> */
public function rvAVenirClient(int $idClient, DateTimeImmutable $depuis): array
{
return $this->em->createQuery(
'SELECT r, cr, m FROM App\Entity\Rv r JOIN r.creneau cr JOIN cr.medecin m
WHERE r.client = :idClient AND r.jour >= :depuis
ORDER BY r.jour, cr.hdebut, cr.mdebut')
->setParameter('idClient', $idClient)
->setParameter('depuis', $depuis, Types::DATE_IMMUTABLE)
->getResult();
}
// nombre de rendez-vous d'un client à partir du jour [$depuis] (inclus) ;
// getSingleScalarResult : la seule valeur de la seule ligne du résultat
public function compterAVenirClient(int $idClient, DateTimeImmutable $depuis): int
{
return (int) $this->em->createQuery(
'SELECT COUNT(r.id) FROM App\Entity\Rv r WHERE r.client = :idClient AND r.jour >= :depuis')
->setParameter('idClient', $idClient)
->setParameter('depuis', $depuis, Types::DATE_IMMUTABLE)
->getSingleScalarResult();
}
// nombre de rendez-vous (passés ou futurs) d'un médecin
public function compterParMedecin(int $idMedecin): int
{
return (int) $this->em->createQuery(
'SELECT COUNT(r.id) FROM App\Entity\Rv r JOIN r.creneau cr WHERE cr.medecin = :idMedecin')
->setParameter('idMedecin', $idMedecin)
->getSingleScalarResult();
}
// le rendez-vous sera inséré par le prochain flush
public function ajouter(Rv $rv): void
{
$this->em->persist($rv);
}
public function supprimer(Rv $rv): void
{
$this->em->remove($rv);
}
// supprime TOUS les rendez-vous (passés et futurs) d'un client, aussitôt
public function supprimerParClient(int $idClient): void
{
$this->em->createQuery('DELETE FROM App\Entity\Rv r WHERE r.client = :idClient')
->setParameter('idClient', $idClient)->execute();
}
}
|
La table [rv] ne référence pas directement le médecin : on passe par le créneau.
- lignes 34-42 : les rendez-vous d'un médecin un jour donné, et leurs clients. Deux jointures : l'une ([r.client cl]) pour charger les clients ([SELECT r, cl]), l'autre ([r.creneau cr]) pour la condition sur le médecin. Le troisième paramètre de [setParameter] donne le type [Doctrine] de la valeur : un [DateTimeImmutable] est alors écrit au format des colonnes [DATE], [aaaa-mm-jj] ;
- lignes 50-59 : les rendez-vous à venir d'un client, avec leur créneau et le médecin de ce créneau : [JOIN cr.medecin m] suit une relation de la relation. Le tri se fait aussi sur l'heure du créneau, une colonne d'une autre table : c'est la jointure qui le permet ;
- lignes 63-79 : des comptages. [COUNT(r.id)] compte les rendez-vous ; [getSingleScalarResult] rend la seule valeur de la seule ligne du résultat (une chaîne, convertie en entier par [(int)]) ;
- lignes 93-97 : la suppression de tous les rendez-vous d'un client, en DQL.
11.10.5. src/Dao/UserDao.php
| <?php
// src/Dao/UserDao.php : couche DAO des comptes utilisateurs. Créer (ou supprimer)
// un médecin ou un client crée (ou supprime) aussi son compte - cf. src/Metier.
// Un compte modifié l'est par ses méthodes (setLogin...) : Doctrine enverra
// l'UPDATE au prochain flush (suivi des modifications, cf. exemple 28).
namespace App\Dao;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
class UserDao
{
public function __construct(private EntityManagerInterface $em)
{
}
// null si aucun utilisateur ne porte ce login ; findOneBy : le dépôt de l'entité
// cherche la ligne dont les colonnes ont ces valeurs
public function lireParLogin(string $login): ?User
{
return $this->em->getRepository(User::class)->findOneBy(['login' => $login]);
}
public function ajouter(User $user): void
{
$this->em->persist($user);
}
public function supprimer(User $user): void
{
$this->em->remove($user);
}
}
|
Les comptes : lecture par login, avec le dépôt de l'entité (exemple 28) : [findOneBy(critères)] rend la seule entité qui correspond aux critères, ou [null] ; ajout et suppression.
11.11. La couche métier
La couche métier réunit les règles de gestion et les transactions (exemple 28). Elle est décrite par une interface, implémentée par deux classes : [MetierService], la couche métier proprement dite, et [ApplicationModel], le cache de la couche web (plus loin).
11.11.1. src/Metier/Dates.php
| <?php
// src/Metier/Dates.php : la date du jour. Elle sert à des RÈGLES DE GESTION (pas de
// réservation dans le passé, un seul rendez-vous À VENIR par patient) : elle vit
// donc dans la couche métier. La couche web la réutilise.
namespace App\Metier;
use DateTimeImmutable;
class Dates
{
// aujourd'hui à minuit, dans le fuseau horaire de l'application (cf. src/Kernel.php)
public static function aujourdhui(): DateTimeImmutable
{
return new DateTimeImmutable('today');
}
}
|
La date du jour, à minuit (['today']), dans le fuseau horaire de l'application (cf. [src/Kernel.php]). Elle sert à des règles de gestion (pas de réservation dans le passé, un seul rendez-vous à venir) : elle vit dans la couche métier. Une méthode statique ([static]) s'appelle sans créer d'objet : [Dates::aujourdhui()].
11.11.2. src/Metier/MetierException.php
| <?php
// =========================================================================
// src/Metier/MetierException.php : l'exception levée par la couche métier quand une
// RÈGLE DE GESTION est violée (login déjà pris, médecin qui a encore des
// rendez-vous, créneau déjà réservé...). Elle ne transporte pas un texte, mais une
// CLÉ de traduction (ex : "ERRORS.LOGIN_TAKEN") : c'est la couche web qui la
// traduira en français ou en anglais au moment d'afficher la page (cf. translations).
//
// Une MetierException n'est pas une anomalie : c'est une réponse normale ("ce login
// est déjà pris"), affichée à l'utilisateur. Toute autre exception est une vraie
// anomalie, qui aboutit à la page d'erreur 500.
// =========================================================================
namespace App\Metier;
use RuntimeException;
use Throwable;
class MetierException extends RuntimeException
{
public function __construct(
// clé de traduction du message
public readonly string $cle,
// paramètres éventuels du message ({minutes}...)
public readonly array $parametres = [],
?Throwable $previous = null,
) {
parent::__construct($cle, 0, $previous);
}
}
|
L'exception levée quand une règle de gestion est violée. Elle ne transporte pas un texte, mais une clé de traduction ([ERRORS.LOGIN_TAKEN]) et ses paramètres : c'est la couche web qui la traduira, dans la langue de la page (exemple 22). La couche métier ne sait rien des langues de l'interface. [RuntimeException] est une classe d'exception de [PHP]. Les propriétés [public readonly] du constructeur sont déclarées et initialisées par la promotion de constructeur (exemple 03) ; [parent::__construct($cle, 0, $previous)] appelle le constructeur de [RuntimeException], avec le message, le code et l'exception précédente (celle qui a provoqué celle-ci, une erreur de la base, par exemple : elle figure dans le journal avec la nouvelle).
11.11.3. src/Metier/DonneesPersonne.php
| <?php
// src/Metier/DonneesPersonne.php : les données saisies dans un formulaire "médecin"
// ou "client", telles que la couche web les transmet à la couche métier.
namespace App\Metier;
readonly class DonneesPersonne
{
public function __construct(
public string $titre, // Mr, Mme, Melle
public string $nom,
public string $prenom,
// création : facultatif (null), vaut par défaut le nom en minuscules
// modification : facultatif, le login est inchangé s'il est absent
public ?string $login = null,
// création : facultatif, vaut par défaut le login
// modification : facultatif, le mot de passe est inchangé s'il est absent
public ?string $password = null,
// modification : la version de la fiche au moment où le formulaire a été
// affiché (verrou optimiste)
public ?int $version = null,
) {
}
}
|
Les données d'un formulaire « personne », telles que la couche web les transmet à la couche métier : une classe [readonly]. Le login, le mot de passe et la version sont facultatifs ([?string], [?int], valeur par défaut [null]) ; les commentaires disent ce que signifie leur absence.
11.11.4. src/Metier/Metier.php
| <?php
// =========================================================================
// src/Metier/Metier.php : le CONTRAT de la couche métier : lectures, agenda,
// rendez-vous, gestion des médecins et des clients.
//
// Une INTERFACE décrit les méthodes qu'une classe doit avoir, sans dire comment
// elles sont écrites. MetierService (la couche métier) et ApplicationModel (le cache
// de la couche web) l'implémentent tous les deux ; PHP vérifie, au chargement de
// la classe, qu'aucune méthode ne manque.
//
// ?Type : un résultat qui peut être absent (null : un médecin qui n'existe pas...).
// list<Medecin> (dans un commentaire /** */) : un tableau de médecins, indexé 0, 1, 2...
// =========================================================================
namespace App\Metier;
use App\Domain\AgendaMedecinJour;
use App\Entity\Client;
use App\Entity\Creneau;
use App\Entity\Medecin;
use App\Entity\Rv;
use DateTimeImmutable;
interface Metier
{
// ------------------------------------------------ lectures
/** @return list<Client> */
public function getAllClients(): array;
/** @return list<Medecin> */
public function getAllMedecins(): array;
/** @return list<Creneau> */
public function getAllCreneaux(int $idMedecin): array;
/** @return list<Rv> */
public function getRvMedecinJour(int $idMedecin, DateTimeImmutable $jour): array;
public function getClientById(int $id): ?Client;
public function getMedecinById(int $id): ?Medecin;
public function getRvById(int $id): ?Rv;
public function getCreneauById(int $id): ?Creneau;
public function getAgendaMedecinJour(int $idMedecin, DateTimeImmutable $jour): ?AgendaMedecinJour;
// le client (patient) dont le compte utilisateur est [$idUser]
public function getClientByUserId(int $idUser): ?Client;
// les rendez-vous d'un client, d'aujourd'hui ou futurs, avec créneau et médecin
/** @return list<Rv> */
public function getRvAVenirClient(int $idClient): array;
// ------------------------------------------------ rendez-vous (ADMIN, DOCTOR, USER)
// lève une MetierException si :
// - le jour est passé ("ERRORS.PAST_DATE") ;
// - le patient a déjà un rendez-vous à venir ("ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT") ;
// - le créneau est déjà pris ce jour-là ("ERRORS.SLOT_ALREADY_BOOKED").
public function ajouterRv(DateTimeImmutable $jour, Creneau $creneau, Client $client): Rv;
public function supprimerRv(Rv $rv): void;
// ------------------------------------------------ médecins (ADMIN)
// crée le médecin ET son compte utilisateur (rôle DOCTOR)
public function ajouterMedecin(DonneesPersonne $donnees): Medecin;
// modifie le médecin ET son compte utilisateur
public function modifierMedecin(int $id, DonneesPersonne $donnees): Medecin;
// supprime le médecin, ses créneaux ET son compte - refusé s'il a des rendez-vous
public function supprimerMedecin(int $id): void;
// ------------------------------------------------ clients
// (l'ADMIN ; ajouterClient sert aussi à l'inscription d'un patient,
// modifierClient et supprimerClient au patient pour son propre compte)
// crée le client ET son compte utilisateur (rôle USER)
public function ajouterClient(DonneesPersonne $donnees): Client;
public function modifierClient(int $id, DonneesPersonne $donnees): Client;
// supprime le client, son compte ET tous ses rendez-vous (passés et futurs)
public function supprimerClient(int $id): void;
}
|
Le contrat de la couche métier : une interface (exemple 06), la liste des méthodes, sans leur code. Les lectures rendent des entités, ou des tableaux d'entités (le commentaire [@return list<Client>] précise leur contenu) ; [?Client] : un client, ou [null] s'il n'existe pas. Les commentaires précisent les règles de chaque écriture, et les clés des [MetierException] qu'elle peut lever.
11.11.5. src/Metier/Descripteur.php
| <?php
// src/Metier/Descripteur.php : tout ce qui distingue un médecin d'un client pour les
// règles de gestion (cf. MetierService). Les médecins et les clients suivent les
// mêmes règles : le code commun est écrit UNE fois, et paramétré par un descripteur.
namespace App\Metier;
use App\Dao\PersonneDao;
use App\Entity\Role;
use Closure;
readonly class Descripteur
{
public function __construct(
public PersonneDao $dao,
public Role $role, // DOCTOR ou USER
// la classe de l'entité, pour créer une personne : Medecin::class ou Client::class
public string $classe,
public string $cleIntrouvable, // message si la personne n'existe pas
public string $cleExisteDeja, // message si les mêmes prénom et nom existent déjà
// facultatif (null) : si présent, la suppression est REFUSÉE tant que la
// personne a des rendez-vous (compterRv(id) > 0), avec le message cleAvecRv
public ?Closure $compterRv,
public ?string $cleAvecRv,
// facultatif : ce qu'il faut supprimer AVANT la personne (les lignes qui la
// référencent par une clé étrangère) ; reçoit son id
public ?Closure $avantSuppression,
) {
}
}
|
Médecins et clients suivent les mêmes règles de gestion, à quelques détails près : le DAO, le rôle du compte, la classe de l'entité à créer, les messages d'erreur, la règle de suppression. Ce descripteur rassemble ces détails ; le code commun les lit. Deux propriétés sont des fonctions ([?Closure] : une fonction, ou [null]) : le comptage des rendez-vous qui interdit la suppression (médecins seulement), et la suppression de ce qui référence la personne.
11.11.6. src/Metier/MetierService.php
| <?php
// =========================================================================
// src/Metier/MetierService.php : l'implémentation de la couche métier.
//
// La gestion des médecins et des clients obéit à trois règles de gestion :
//
// 1. une personne (médecin ou client) et son compte utilisateur vont TOUJOURS
// ensemble : on les crée, modifie et supprime ensemble, dans une même
// TRANSACTION - soit tout réussit, soit rien n'est écrit ;
// 2. le login est unique dans toute l'application, et deux médecins (ou deux
// clients) ne peuvent pas porter les mêmes prénom et nom ;
// 3. suppression :
// - un MÉDECIN qui a des rendez-vous ne peut pas être supprimé (ses patients
// perdraient leur rendez-vous sans être prévenus) : il faut d'abord les
// annuler. Ses créneaux horaires disparaissent avec lui ;
// - un CLIENT, lui, disparaît avec TOUS ses rendez-vous (passés et futurs) -
// que la suppression vienne de l'ADMIN ou du patient lui-même.
//
// Et deux règles pour la prise de rendez-vous, quel que soit le rôle de celui qui
// réserve (ADMIN, DOCTOR ou le patient lui-même) :
// 4. pas de rendez-vous dans le passé ;
// 5. un patient n'a qu'UN rendez-vous à venir à la fois.
//
// Médecins et clients suivent exactement les mêmes règles : le code commun est
// écrit UNE fois (méthodes privées xxxPersonne), et paramétré par un descripteur
// qui dit à quel type de personne on a affaire.
//
// Les TRANSACTIONS sont ouvertes par la méthode transaction (en fin de fichier),
// qui traduit aussi en MetierException les erreurs que la base signale.
// =========================================================================
namespace App\Metier;
use App\Dao\ClientDao;
use App\Dao\CreneauDao;
use App\Dao\MedecinDao;
use App\Dao\RvDao;
use App\Dao\UserDao;
use App\Domain\AgendaMedecinJour;
use App\Domain\CreneauMedecinJour;
use App\Entity\Client;
use App\Entity\Creneau;
use App\Entity\Medecin;
use App\Entity\Personne;
use App\Entity\Role;
use App\Entity\Rv;
use App\Entity\User;
use Closure;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
class MetierService implements Metier
{
// -----------------------------------------------------------------------
// Erreurs de la base -> MetierException
//
// Quand une contrainte UNIQUE refuse une écriture, Doctrine lève une
// UniqueConstraintViolationException, dont le message est celui de MySQL. Il
// cite le NOM de la contrainte :
// Duplicate entry 'Marie-PELISSIER' for key 'medecins.unq_medecins_prenom_nom'
// (MySQL 8 préfixe le nom par celui de la table, MariaDB non). C'est pour cela
// que le script SQL NOMME toutes ses contraintes.
// -----------------------------------------------------------------------
private const MESSAGES_CONTRAINTES = [
'unq_users_login' => 'ERRORS.LOGIN_TAKEN',
'unq_medecins_prenom_nom' => 'ERRORS.DOCTOR_ALREADY_EXISTS',
'unq_clients_prenom_nom' => 'ERRORS.PATIENT_ALREADY_EXISTS',
'unq1_rv' => 'ERRORS.SLOT_ALREADY_BOOKED',
];
private const CONTRAINTE = "/for key '(?:[^'.]*\\.)?([^']+)'/";
private Descripteur $medecin;
private Descripteur $client;
// le hachage des mots de passe (bcrypt, cf. config/packages/security.yaml)
private PasswordHasherInterface $hacheur;
// toutes les dépendances sont fournies par le conteneur de services de Symfony
// (injection de dépendances, cf. exemple 06)
public function __construct(
private EntityManagerInterface $em,
private MedecinDao $medecins,
private ClientDao $clients,
private CreneauDao $creneaux,
private RvDao $rvs,
private UserDao $users,
PasswordHasherFactoryInterface $hacheurs,
) {
$this->hacheur = $hacheurs->getPasswordHasher(User::class);
$this->medecin = new Descripteur(
dao: $medecins,
role: Role::DOCTOR,
classe: Medecin::class,
cleIntrouvable: 'ERRORS.DOCTOR_NOT_FOUND',
cleExisteDeja: 'ERRORS.DOCTOR_ALREADY_EXISTS',
// $rvs->compterParMedecin(...) : la méthode elle-même, comme une Closure
// (syntaxe dite "first-class callable")
compterRv: $rvs->compterParMedecin(...),
cleAvecRv: 'ERRORS.DOCTOR_HAS_APPOINTMENTS',
// un médecin sans rendez-vous peut encore avoir des créneaux horaires :
// ils n'ont plus de sens sans lui, on les supprime avec lui (sans quoi la
// clé étrangère creneaux.id_medecin interdirait la suppression)
avantSuppression: $creneaux->supprimerParMedecin(...));
$this->client = new Descripteur(
dao: $clients,
role: Role::USER,
classe: Client::class,
cleIntrouvable: 'ERRORS.PATIENT_NOT_FOUND',
cleExisteDeja: 'ERRORS.PATIENT_ALREADY_EXISTS',
// pas de compterRv : la suppression d'un client n'est jamais refusée ;
// ses rendez-vous (passés et futurs) sont supprimés avec lui (sans quoi
// la clé étrangère rv.id_client interdirait la suppression)
compterRv: null,
cleAvecRv: null,
avantSuppression: $rvs->supprimerParClient(...));
}
// =======================================================================
// lectures
// =======================================================================
public function getAllClients(): array
{
return $this->clients->lister();
}
public function getAllMedecins(): array
{
return $this->medecins->lister();
}
public function getAllCreneaux(int $idMedecin): array
{
return $this->creneaux->listerParMedecin($idMedecin);
}
public function getRvMedecinJour(int $idMedecin, DateTimeImmutable $jour): array
{
return $this->rvs->rvMedecinJour($idMedecin, $jour);
}
public function getClientById(int $id): ?Client
{
return $this->clients->lire($id);
}
public function getMedecinById(int $id): ?Medecin
{
return $this->medecins->lire($id);
}
public function getRvById(int $id): ?Rv
{
return $this->rvs->lire($id);
}
public function getCreneauById(int $id): ?Creneau
{
return $this->creneaux->lire($id);
}
public function getClientByUserId(int $idUser): ?Client
{
return $this->clients->lireParUser($idUser);
}
public function getRvAVenirClient(int $idClient): array
{
return $this->rvs->rvAVenirClient($idClient, Dates::aujourdhui());
}
// l'agenda d'un médecin un jour donné : ses créneaux, et pour chacun le
// rendez-vous pris ce jour-là (null s'il est libre)
public function getAgendaMedecinJour(int $idMedecin, DateTimeImmutable $jour): ?AgendaMedecinJour
{
$medecin = $this->getMedecinById($idMedecin);
if ($medecin === null) {
return null;
}
// n° de créneau -> rendez-vous pris sur ce créneau
$reservations = [];
foreach ($this->getRvMedecinJour($idMedecin, $jour) as $rv) {
$reservations[$rv->getCreneau()->getId()] = $rv;
}
// array_map : applique la fonction à chaque créneau, et rend le tableau des résultats
$creneauxMedecinJour = array_map(
fn (Creneau $creneau) => new CreneauMedecinJour($creneau, $reservations[$creneau->getId()] ?? null),
$this->getAllCreneaux($idMedecin));
return new AgendaMedecinJour($medecin, $jour, $creneauxMedecinJour);
}
// =======================================================================
// rendez-vous
// =======================================================================
// -----------------------------------------------------------------------
// prise de rendez-vous, dans une transaction :
// 1. pas de rendez-vous dans le passé ;
// 2. on VERROUILLE la ligne du patient (SELECT ... FOR UPDATE) : si le même
// patient réserve deux créneaux au même instant (deux onglets, double
// clic...), la seconde transaction attend la fin de la première - et voit
// alors le rendez-vous qu'elle vient de créer ;
// 3. un seul rendez-vous à venir par patient ;
// 4. enfin l'insertion. Si le créneau vient d'être pris par un AUTRE patient,
// c'est la contrainte SQL unq1_rv (jour, id_creneau) qui refuse
// l'insertion - traduite en MetierException par transaction().
// -----------------------------------------------------------------------
public function ajouterRv(DateTimeImmutable $jour, Creneau $creneau, Client $client): Rv
{
return $this->transaction(function () use ($jour, $creneau, $client) {
$aujourdhui = Dates::aujourdhui();
if ($jour < $aujourdhui) {
throw new MetierException('ERRORS.PAST_DATE');
}
$idPatient = $this->clients->verrouiller($client->getId())
?? throw new MetierException('ERRORS.PATIENT_NOT_FOUND');
if ($this->rvs->compterAVenirClient($idPatient, $aujourdhui) > 0) {
throw new MetierException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
}
$rv = new Rv($jour, $creneau, $client);
$this->rvs->ajouter($rv);
return $rv;
});
}
public function supprimerRv(Rv $rv): void
{
$this->transaction(fn () => $this->rvs->supprimer($rv));
}
// =======================================================================
// médecins et clients - les méthodes publiques délèguent aux méthodes
// privées communes, avec le bon descripteur
// =======================================================================
public function ajouterMedecin(DonneesPersonne $donnees): Medecin
{
return $this->ajouterPersonne($this->medecin, $donnees);
}
public function modifierMedecin(int $id, DonneesPersonne $donnees): Medecin
{
return $this->modifierPersonne($this->medecin, $id, $donnees);
}
public function supprimerMedecin(int $id): void
{
$this->supprimerPersonne($this->medecin, $id);
}
public function ajouterClient(DonneesPersonne $donnees): Client
{
return $this->ajouterPersonne($this->client, $donnees);
}
public function modifierClient(int $id, DonneesPersonne $donnees): Client
{
return $this->modifierPersonne($this->client, $id, $donnees);
}
// le client, son compte ET tous ses rendez-vous (ADMIN, ou le patient lui-même
// depuis "Gérer mon compte" : même méthode, même règle)
public function supprimerClient(int $id): void
{
$this->supprimerPersonne($this->client, $id);
}
// -----------------------------------------------------------------------
// création : le compte utilisateur ET la personne (qui le référence), dans UNE
// transaction. Doctrine insère d'abord le compte, puis la personne, dont la clé
// étrangère [user] reçoit l'id que la base vient de générer pour le compte.
// -----------------------------------------------------------------------
private function ajouterPersonne(Descripteur $d, DonneesPersonne $donnees): Personne
{
$nom = mb_strtoupper(trim($donnees->nom));
$prenom = trim($donnees->prenom);
// les règles de la base dbrdvmedecins2 : login = nom en minuscules, mot de
// passe = login - sauf si l'ADMIN (ou le patient) en a saisi d'autres
$login = mb_strtolower($donnees->login ?: $nom);
$password = $donnees->password ?: $login;
return $this->transaction(function () use ($d, $donnees, $nom, $prenom, $login, $password) {
$this->verifierPersonneUnique($d, $prenom, $nom, null);
$this->verifierLoginLibre($login);
// hash : le hachage bcrypt du mot de passe
$user = new User($login, $this->hacheur->hash($password), "$prenom $nom", $d->role);
$this->users->ajouter($user);
// new $d->classe(...) : une instance de la classe dont le nom est dans la
// propriété [classe] du descripteur (Medecin ou Client)
$personne = new ($d->classe)($donnees->titre, $nom, $prenom, $user);
$d->dao->ajouter($personne);
return $personne;
});
}
// -----------------------------------------------------------------------
// modification : la personne ET son compte (le nom affiché "prénom NOM" doit
// suivre ; le login et le mot de passe ne changent que s'ils ont été saisis).
// -----------------------------------------------------------------------
private function modifierPersonne(Descripteur $d, int $id, DonneesPersonne $donnees): Personne
{
return $this->transaction(function () use ($d, $id, $donnees) {
$personne = $d->dao->lire($id) ?? throw new MetierException($d->cleIntrouvable);
// verrou optimiste : si la version en base n'est plus celle du
// formulaire, quelqu'un a modifié cette fiche entre-temps - on refuse
// plutôt que d'écraser silencieusement ses modifications
if ($donnees->version !== null && $donnees->version !== $personne->getVersion()) {
throw new MetierException('ERRORS.CONCURRENT_UPDATE');
}
$nom = mb_strtoupper(trim($donnees->nom));
$prenom = trim($donnees->prenom);
$this->verifierPersonneUnique($d, $prenom, $nom, $id);
$personne->modifier($donnees->titre, $nom, $prenom);
// son compte : le nom affiché, et, s'ils ont été saisis, le login et le
// mot de passe
$compte = $personne->getCompte();
$compte->setNom("$prenom $nom");
$login = $donnees->login ? mb_strtolower($donnees->login) : null;
if ($login !== null && $login !== $compte->getLogin()) {
$this->verifierLoginLibre($login);
$compte->setLogin($login);
}
if ($donnees->password) {
$compte->setPassword($this->hacheur->hash($donnees->password));
}
// rien d'autre à faire : à la fin de la transaction, Doctrine envoie les
// UPDATE des lignes modifiées, avec leur verrou optimiste
// UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?
// Si la fiche a été modifiée APRÈS la lecture ci-dessus, aucune ligne
// n'est modifiée, et Doctrine lève une OptimisticLockException
return $personne;
});
}
// -----------------------------------------------------------------------
// suppression, dans UNE transaction, dans l'ordre imposé par les clés
// étrangères :
// 1. refus éventuel s'il reste des rendez-vous (médecin) ;
// 2. ce qui référence la personne (créneaux d'un médecin, rendez-vous d'un
// client), aussitôt ;
// 3. la personne (elle référence son compte), puis son compte : Doctrine les
// supprime dans cet ordre à la fin de la transaction.
// -----------------------------------------------------------------------
private function supprimerPersonne(Descripteur $d, int $id): void
{
$this->transaction(function () use ($d, $id) {
$personne = $d->dao->lire($id) ?? throw new MetierException($d->cleIntrouvable);
if ($d->compterRv !== null && ($d->compterRv)($id) > 0) {
throw new MetierException($d->cleAvecRv);
}
if ($d->avantSuppression !== null) {
($d->avantSuppression)($id);
}
$d->dao->supprimer($personne);
$this->users->supprimer($personne->getCompte());
});
}
// refuse des prénom et nom déjà portés par une AUTRE personne du même type
// ($idCourant : la personne en cours de modification, qui a évidemment le droit
// de garder ses propres prénom et nom ; null pour une création)
private function verifierPersonneUnique(Descripteur $d, string $prenom, string $nom, ?int $idCourant): void
{
$homonyme = $d->dao->homonyme($prenom, $nom);
if ($homonyme !== null && $homonyme->getId() !== $idCourant) {
throw new MetierException($d->cleExisteDeja);
}
}
private function verifierLoginLibre(string $login): void
{
if ($this->users->lireParLogin($login) !== null) {
throw new MetierException('ERRORS.LOGIN_TAKEN');
}
}
// -----------------------------------------------------------------------
// exécute [$travail] dans une transaction (wrapInTransaction, cf. exemple 28) :
// si la fonction se termine normalement, Doctrine envoie à la base les
// écritures en attente (flush), puis valide la transaction (COMMIT) ; si une
// exception en sort (MetierException comprise), ROLLBACK : rien n'est écrit.
//
// Les contrôles "login libre" et "prénom + nom uniques" sont faits AVANT
// l'écriture, pour donner un message clair. Mais deux ADMIN pourraient
// enregistrer le même login (ou le même médecin) au même instant : dans ce cas
// rare, c'est une contrainte d'unicité SQL qui refuse la seconde écriture. De
// même, deux modifications simultanées d'une même fiche sont départagées par le
// verrou optimiste. On intercepte donc ces erreurs AUTOUR de la transaction,
// pour les traduire en MetierException. Toute autre erreur est une vraie
// anomalie : on la laisse remonter.
// Après une exception, Doctrine FERME le gestionnaire d'entités : la requête
// en cours ne doit plus rien écrire (les contrôleurs redirigent, ou
// réaffichent un formulaire).
// -----------------------------------------------------------------------
private function transaction(Closure $travail): mixed
{
try {
return $this->em->wrapInTransaction($travail);
} catch (UniqueConstraintViolationException $e) {
if (preg_match(self::CONTRAINTE, $e->getMessage(), $m)
&& isset(self::MESSAGES_CONTRAINTES[$m[1]])) {
throw new MetierException(self::MESSAGES_CONTRAINTES[$m[1]], previous: $e);
}
throw $e;
} catch (OptimisticLockException $e) {
throw new MetierException('ERRORS.CONCURRENT_UPDATE', previous: $e);
}
}
}
|
Le fichier le plus important de l'application. Nous le parcourons par thème.
11.11.6.1. Les dépendances et les descripteurs
Lignes 86-124 : le constructeur reçoit du conteneur le gestionnaire d'entités (pour les transactions), les cinq DAO, et la fabrique des services de hachage ([PasswordHasherFactoryInterface]), à qui l'on demande celui des comptes : [getPasswordHasher(User::class)] rend le service qui applique l'algorithme choisi pour la classe [User] dans [security.yaml] ([bcrypt], 10 tours). Suivent les deux descripteurs. Les arguments nommés ([dao: $medecins], exemple 01) rendent l'appel lisible. [$rvs->compterParMedecin(...)] (avec trois points dans les parenthèses) ne compte rien : c'est la syntaxe de [PHP] 8.1 qui fabrique, à partir d'une méthode, une [Closure], une fonction qu'on appellera plus tard ([($d->compterRv)($id)], ligne 359). Un médecin est supprimé avec ses créneaux ([supprimerParMedecin]), mais pas s'il a des rendez-vous ; un client, avec tous ses rendez-vous ([supprimerParClient]).
11.11.6.2. Les lectures et l'agenda
Les lectures (lignes 130-178) délèguent à la couche DAO. L'agenda d'un médecin (lignes 182-198) est calculé : le médecin, ses créneaux, et les rendez-vous de ce jour-là. Les rendez-vous sont rangés dans un tableau indexé par le numéro de créneau : [$reservations[$creneau->getId()] ?? null] rend le rendez-vous d'un créneau, ou [null] s'il est libre. [array_map] transforme chaque créneau en [CreneauMedecinJour].
11.11.6.3. La prise de rendez-vous
Lignes 216-232 : quatre étapes, dans une transaction.
- pas de rendez-vous dans le passé : les objets [DateTimeImmutable] se comparent avec les opérateurs habituels ([<], [>=]...) ;
- la ligne du patient est verrouillée ([verrouiller], [SELECT ... FOR UPDATE]). Pourquoi ? La règle « un seul rendez-vous à venir par patient » est vérifiée par un comptage (troisième étape), puis l'insertion a lieu (quatrième étape). Si le même patient réserve deux créneaux au même instant (deux onglets, un double clic), les deux transactions pourraient compter zéro rendez-vous, puis insérer chacune le sien : la règle serait violée. Avec le verrou, la seconde transaction attend la fin de la première, puis compte... un rendez-vous. [?? throw new MetierException(...)] : si le client n'existe plus, [verrouiller] rend [null], et l'opérateur [??] évalue son second membre, qui lève l'exception (depuis [PHP] 8, [throw] est une expression) ;
- le comptage des rendez-vous à venir ;
- l'insertion, à la fin de la transaction. Si le créneau vient d'être pris par un autre patient, la contrainte [unq1_rv] de la base refuse l'insertion : la méthode [transaction] traduit cette erreur en [MetierException].
11.11.6.4. La gestion des médecins et des clients
Les méthodes publiques (lignes 244-274) appellent les méthodes privées communes, avec le bon descripteur.
- lignes 281-302 : la création. Le nom est mis en majuscules ([mb_strtoupper] : la version de [strtoupper] qui connaît les lettres accentuées), le prénom et le nom débarrassés de leurs espaces superflus ([trim]). Le login vaut par défaut le nom en minuscules, et le mot de passe le login (les règles des comptes de démonstration) : [$a ?: $b] vaut [$a] s'il est « vrai » (ni [null], ni chaîne vide), [$b] sinon. Dans la transaction : les vérifications (prénom et nom libres, login libre), puis le compte, dont le mot de passe est haché ([hash]), puis la personne, qui référence le compte. [new ($d->classe)(...)] crée un objet de la classe dont le nom est dans la propriété [classe] du descripteur (les parenthèses sont nécessaires autour d'une expression). [Doctrine] insérera le compte avant la personne : il connaît les relations entre les entités, et en déduit l'ordre des [INSERT]. [use (...)] : une fonction anonyme ordinaire ([function () use ($d, ...)]) ne voit pas les variables de la méthode qui la contient ; [use] les lui transmet (une fonction fléchée, [fn () =>], les voit d'elle-même, mais ne peut contenir qu'une expression) ;
- lignes 308-344 : la modification, avec deux verrous optimistes. Le premier est une comparaison (lignes 316-318) : la version du formulaire (champ caché, exemple 28) doit être celle de la fiche en base ; sinon, quelqu'un a modifié la fiche entre l'affichage du formulaire et son envoi. Le second est celui de [Doctrine] : les [UPDATE] envoyés à la fin de la transaction portent la condition [WHERE version = ?], qui détecte une modification survenue entre la lecture et l'écriture ([OptimisticLockException]). Le compte suit : son nom affiché (« prénom NOM ») est toujours mis à jour ; son login et son mot de passe seulement s'ils ont été saisis ; son propre verrou optimiste protège aussi la ligne de la table [users]. Il n'y a rien à « enregistrer » : l'entité lue est suivie par le gestionnaire d'entités, qui écrira ses modifications. À la fin de la transaction, [Doctrine] range dans les entités leurs nouvelles versions : l'appelant reçoit la personne à jour ;
- lignes 355-368 : la suppression, dans l'ordre imposé par les clés étrangères : d'abord ce qui référence la personne (créneaux ou rendez-vous), aussitôt, par les [DELETE] du DAO ; puis la personne, puis son compte, que [Doctrine] supprime dans cet ordre à la fin de la transaction. L'ordre inverse serait refusé par la base : on ne supprime pas une ligne encore référencée par une clé étrangère ;
- lignes 373-387 : la personne en cours de modification a le droit de garder ses propres prénom et nom ; [$idCourant] vaut [null] pour une création.
11.11.6.5. Les transactions et les erreurs de la base
Lignes 406-419 : toutes les écritures passent par cette méthode. [wrapInTransaction] exécute la fonction dans une transaction (exemple 28) : [flush] puis [COMMIT] si elle se termine normalement, [ROLLBACK] si une exception en sort - une [MetierException] comprise : une règle violée au milieu d'une opération n'en laisse aucune trace. Les vérifications « login libre » et « prénom et nom libres » sont faites avant d'écrire, pour donner un message clair. Mais deux administrateurs pourraient enregistrer le même login au même instant : chacun le trouve libre, et le second [INSERT] est refusé par la contrainte [unq_users_login]. [Doctrine] lève alors une [UniqueConstraintViolationException] (exemple 28), dont le message est celui de [MySQL] ; il contient le nom de la contrainte violée :
| SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'dupont' for key 'users.unq_users_login'
|
L'expression régulière de la ligne 75 extrait ce nom ([MySQL] 8 le préfixe par le nom de la table, [users.unq_users_login], [MariaDB] non : la partie [(?:[^'.]*\.)?] accepte les deux formes), et le tableau des lignes 68-73 donne la clé du message. Une autre violation de contrainte est une vraie anomalie : elle est relancée ([throw $e]), et aboutira à la page d'erreur 500. Enfin, le conflit détecté par le verrou optimiste de [Doctrine] ([OptimisticLockException]) devient la même [MetierException] que celui détecté par la comparaison des versions.
Rappelons une conséquence de [wrapInTransaction] (exemple 28) : après une exception, le gestionnaire d'entités est fermé, et ne peut plus écrire pendant la requête en cours. Les contrôleurs en tiennent compte : après une [MetierException], ils redirigent, ou réaffichent un formulaire, sans rien écrire d'autre.
11.12. L'authentification
Le dossier [src/Securite] reprend les exemples 25 à 27, avec les utilisateurs de la base de données, et quelques protections de plus. Rappelons le principe (exemple 25) : le pare-feu [principal] de [config/packages/security.yaml] est sans état ([stateless]) ; à chaque requête, un authentificateur de notre invention lit le jeton JWT du cookie [rdvmedecins_jwt], et en déduit l'utilisateur ; les règles [access_control] décident ensuite de l'accès aux pages.
11.12.1. src/Securite/UtilisateurConnecte.php
| <?php
// =========================================================================
// src/Securite/UtilisateurConnecte.php : l'utilisateur connecté, tel que le décrit
// son jeton JWT (cf. exemple 25). UserInterface : l'interface que Symfony attend des
// utilisateurs authentifiés ($this->getUser() dans un contrôleur, #[CurrentUser],
// app.user et is_granted() dans une vue...).
//
// Les claims de nos jetons (cf. AuthService::cookieJeton) :
// sub : l'id du compte utilisateur (convention JWT : "subject"), en texte
// login : le login
// nom : le nom affiché ("Marie PELISSIER"), pour la barre de navigation
// role : ADMIN, DOCTOR ou USER
// iat, exp : dates de création et d'expiration du jeton
//
// Cet objet ne vient PAS de la base : il est reconstruit, à chaque requête, à
// partir du jeton, dont la signature garantit le contenu.
// =========================================================================
namespace App\Securite;
use App\Entity\Role;
use Symfony\Component\Security\Core\User\UserInterface;
readonly class UtilisateurConnecte implements UserInterface
{
public function __construct(
public int $userId,
public string $login,
public string $nom,
public Role $role,
) {
}
// l'utilisateur décrit par les claims d'un jeton déjà vérifié ; null si un claim
// manque ou n'a pas la forme attendue. Role::tryFrom : null pour un rôle inconnu
public static function de(array $claims): ?self
{
$role = Role::tryFrom((string) ($claims['role'] ?? ''));
if (! isset($claims['sub'], $claims['login'], $claims['nom']) || $role === null
|| ! ctype_digit((string) $claims['sub'])) {
return null;
}
return new self((int) $claims['sub'], (string) $claims['login'], (string) $claims['nom'], $role);
}
public function estAdmin(): bool
{
return $this->role === Role::ADMIN;
}
public function estPatient(): bool
{
return $this->role === Role::USER;
}
// ---- l'interface UserInterface : l'identifiant de l'utilisateur (son login),
// et ses rôles au sens de Symfony (ROLE_ADMIN, ROLE_DOCTOR ou ROLE_USER, cf.
// l'énumération Role), que vérifient les règles access_control de
// config/packages/security.yaml
public function getUserIdentifier(): string
{
return $this->login;
}
public function getRoles(): array
{
return [$this->role->roleSymfony()];
}
}
|
L'utilisateur connecté, tel que le décrit son jeton : l'id de son compte, son login, son nom et son rôle. Comme l'[Utilisateur] de l'exemple 25, il implémente l'interface [UserInterface], que [Symfony] attend des utilisateurs authentifiés. Cet objet ne vient pas de la base : il est reconstruit à chaque requête, à partir du jeton, dont la signature garantit le contenu. L'application ne relit donc pas le compte en base à chaque requête. La classe est [readonly] (exemple 08) : ses propriétés, publiques, ne peuvent pas être modifiées après la construction.
- lignes 36-44 : l'utilisateur décrit par les affirmations (claims) d'un jeton déjà vérifié, ou [null] si l'une d'elles manque ou n'a pas la forme attendue. Un jeton correctement signé ne devrait jamais être mal formé ; mais la clé secrète pourrait être partagée avec une autre application, qui fabriquerait des jetons différents : la prudence ne coûte rien. [Role::tryFrom] rend le rôle, ou [null] pour une valeur inconnue ; [ctype_digit] vérifie qu'une chaîne n'est faite que de chiffres ;
- lignes 46-54 : deux questions que les vues et les contrôleurs posent souvent ;
- lignes 60-68 : l'interface [UserInterface]. L'identifiant de l'utilisateur est son login ; ses rôles, au sens de [Symfony], se réduisent à un seul, [ROLE_ADMIN], [ROLE_DOCTOR] ou [ROLE_USER], calculé par l'énumération [Role]. Ce sont eux que vérifient les règles [access_control], l'attribut [#[IsGranted]] et la fonction [is_granted] des vues. (La méthode [eraseCredentials], qui effaçait le mot de passe en clair après la connexion, a quitté l'interface avec [Symfony] 8.)
11.12.2. src/Securite/AuthService.php
| <?php
// =========================================================================
// src/Securite/AuthService.php : la logique d'authentification :
// - valider() : vérifie un couple login / mot de passe ;
// - cookieJeton() : fabrique le jeton JWT d'un utilisateur validé, et le
// cookie qui le transporte ;
// - utilisateur() : l'utilisateur décrit par un jeton.
//
// Où le jeton JWT voyage-t-il ? Dans un COOKIE (cf. exemple 25) : un navigateur
// qui affiche des pages HTML ordinaires le présente ainsi tout seul, à chaque
// requête. Les options de ce cookie comptent pour la sécurité :
// - HttpOnly : le JavaScript de la page ne peut pas lire le cookie (un script
// injecté ne pourrait donc pas voler le jeton) ;
// - SameSite=Lax : le navigateur n'envoie PAS le cookie avec un POST venant d'un
// autre site (première protection contre l'attaque dite CSRF ; la seconde est
// le jeton anti-CSRF des formulaires) ;
// - Secure : en production (HTTPS), le cookie ne circule jamais en clair ;
// - durée de vie : le cookie disparaît en même temps que le jeton expire.
// =========================================================================
namespace App\Securite;
use App\Dao\UserDao;
use App\Entity\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Throwable;
class AuthService
{
// le nom du cookie qui transporte le jeton
public const COOKIE = 'rdvmedecins_jwt';
// les réglages du fichier .env : #[Autowire(env: ...)] (cf. exemple 25)
public function __construct(
private UserDao $users,
private PasswordHasherFactoryInterface $hacheurs,
#[Autowire(env: 'JWT_SECRET')] private string $secret,
#[Autowire(env: 'int:JWT_DUREE_MINUTES')] private int $duree,
#[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure,
) {
// HMAC-SHA256 exige une clé de 256 bits au moins : on le dit clairement,
// plutôt que de laisser la bibliothèque échouer plus loin
if (strlen($this->secret) < 32) {
throw new RuntimeException('JWT_SECRET doit comporter au moins 32 caractères (cf. .env).');
}
}
// null si le login n'existe pas ou si le mot de passe est faux. verify hache le
// mot de passe saisi avec le "sel" contenu dans le hachage enregistré, puis
// compare les deux résultats
public function valider(string $login, string $password): ?User
{
$user = $this->users->lireParLogin($login);
if ($user === null
|| ! $this->hacheurs->getPasswordHasher(User::class)->verify($user->getPassword(), $password)) {
return null;
}
return $user;
}
// -----------------------------------------------------------------------
// le jeton JWT de [$user], dans le cookie qui le transporte. Le jeton est SIGNÉ
// (HMAC-SHA256) avec la clé secrète : le navigateur peut le lire (il n'est pas
// chiffré), mais pas le modifier - toute modification rendrait la signature
// invalide.
// -----------------------------------------------------------------------
public function cookieJeton(User $user): Cookie
{
$maintenant = time();
$claims = [
'sub' => (string) $user->getId(),
'login' => $user->getLogin(),
'nom' => $user->getNom(),
'role' => $user->getRole()->value,
'iat' => $maintenant,
'exp' => $maintenant + $this->duree * 60,
];
$jeton = JWT::encode($claims, $this->secret, 'HS256');
return Cookie::create(self::COOKIE, $jeton)
->withExpires($maintenant + $this->duree * 60)
->withSecure($this->cookieSecure)
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_LAX);
}
// efface le cookie du jeton : la réponse porte un cookie de même nom, vide, déjà expiré
public static function effacerCookie(Response $reponse): Response
{
$reponse->headers->clearCookie(self::COOKIE);
return $reponse;
}
// -----------------------------------------------------------------------
// l'utilisateur décrit par un jeton ; null si le jeton est expiré, falsifié ou
// mal formé. JWT::decode vérifie la signature ET la date d'expiration (claim exp).
// -----------------------------------------------------------------------
public function utilisateur(string $jeton): ?UtilisateurConnecte
{
try {
// decode rend un objet ; (array) le convertit en tableau associatif
$claims = (array) JWT::decode($jeton, new Key($this->secret, 'HS256'));
} catch (Throwable) {
return null;
}
return UtilisateurConnecte::de($claims);
}
}
|
- ligne 37 : le nom du cookie du jeton, une constante de classe, lue par [AuthService::COOKIE] ;
- lignes 40-52 : le service reçoit la couche DAO des comptes, la fabrique des services de hachage (celui qui correspond à la classe [User], bcrypt, est déclaré dans [security.yaml]), et trois réglages du fichier [.env], par l'attribut [#[Autowire(env: ...)]] (exemple 25). Les préfixes [int:] et [bool:] convertissent le texte de la variable d'environnement. Une clé trop courte est signalée dès la création du service, avec un message clair (cf. « Les problèmes fréquents ») ;
- lignes 57-65 : la vérification d'un login et d'un mot de passe (exemple 25), avec le compte lu en base par la couche DAO. Remarquons que ce service utilise directement la couche DAO, sans passer par la couche métier : la vérification d'un mot de passe n'est pas une règle de gestion ;
- lignes 73-90 : le jeton de l'utilisateur, et le cookie qui le transporte. Les affirmations sont celles de l'exemple 25, avec l'id du compte ([sub], une chaîne, comme le veut la norme) et le login. [Cookie::create] fabrique un cookie, que les méthodes [with...] complètent (chacune rend un nouveau cookie : l'objet est immuable) : sa date d'expiration (celle du jeton), [Secure] (en production, sur HTTPS), [HttpOnly] et [SameSite=Lax] (cf. le commentaire des lignes 9-18) ;
- lignes 93-97 : l'effacement du cookie. [clearCookie(nom)] ajoute à la réponse un cookie de même nom, vide, déjà expiré : le navigateur le supprime. La méthode est statique : elle n'a besoin d'aucun réglage, et les classes qui l'appellent n'ont pas à recevoir le service ;
- lignes 103-112 : la vérification d'un jeton (exemple 25).
11.12.3. src/Securite/JetonAuthenticator.php
| <?php
// =========================================================================
// src/Securite/JetonAuthenticator.php : l'AUTHENTIFICATEUR du pare-feu (cf.
// config/packages/security.yaml et exemple 25) : à chaque requête, il établit qui
// est l'utilisateur, d'après le jeton JWT du cookie [rdvmedecins_jwt].
//
// Il ne refuse rien lui-même : un jeton absent ou invalide laisse le visiteur
// ANONYME. Ce sont les règles access_control qui refusent ensuite l'accès aux pages
// protégées ; un visiteur anonyme refusé est alors confié au POINT D'ENTRÉE (la
// méthode start), qui le renvoie à la page de connexion.
// =========================================================================
namespace App\Securite;
use App\Web\Flash;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class JetonAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
// l'attribut de requête qui signale un jeton invalide, à effacer
private const JETON_INVALIDE = '_jeton_invalide';
public function __construct(
private AuthService $auth,
private Flash $flash,
private UrlGeneratorInterface $urls,
) {
}
// l'authentificateur n'agit que si la requête porte le cookie du jeton
public function supports(Request $request): ?bool
{
return $request->cookies->getString(AuthService::COOKIE) !== '';
}
// le jeton, vérifié par AuthService, décrit l'utilisateur : le passeport se
// valide lui-même, et le badge rend directement cet utilisateur (sans le
// chercher en base, cf. exemple 25)
public function authenticate(Request $request): Passport
{
$utilisateur = $this->auth->utilisateur($request->cookies->getString(AuthService::COOKIE))
?? throw new CustomUserMessageAuthenticationException('jeton invalide');
return new SelfValidatingPassport(new UserBadge($utilisateur->login, fn () => $utilisateur));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
// un jeton invalide (expiré, falsifié) : le visiteur reste anonyme (null), et le
// cookie sera effacé de la réponse, quelle qu'elle soit (cf. effacerJetonInvalide)
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->attributes->set(self::JETON_INVALIDE, true);
return null;
}
// -----------------------------------------------------------------------
// le POINT D'ENTRÉE : un visiteur anonyme demande une page protégée. Direction
// la page de connexion. S'il avait un cookie de jeton, c'est que son jeton n'est
// plus valide (expiré, falsifié) : sa session a expiré, on le lui dit
// -----------------------------------------------------------------------
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
if ($request->cookies->has(AuthService::COOKIE)) {
$this->flash->erreur('ERRORS.SESSION_EXPIRED');
}
return new RedirectResponse($this->urls->generate('login'));
}
// -----------------------------------------------------------------------
// un écouteur de kernel.response (cf. exemple 24), écrit dans la même classe :
// il efface le cookie d'un jeton invalide, sur toutes les réponses
// -----------------------------------------------------------------------
#[AsEventListener(event: KernelEvents::RESPONSE)]
public function effacerJetonInvalide(ResponseEvent $evenement): void
{
if ($evenement->getRequest()->attributes->get(self::JETON_INVALIDE)) {
AuthService::effacerCookie($evenement->getResponse());
}
}
}
|
L'authentificateur de l'exemple 25, déclaré dans [security.yaml] ([custom_authenticators]). Il sert aussi de point d'entrée au pare-feu : il implémente l'interface [AuthenticationEntryPointInterface], et [Symfony] le choisit de lui-même, puisque c'est le seul authentificateur du pare-feu.
- lignes 45-48 : [supports] dit si l'authentificateur doit intervenir : seulement si la requête porte le cookie du jeton. Sans cookie, le visiteur est anonyme, et rien d'autre n'est fait ;
- lignes 53-58 : [authenticate] vérifie le jeton. Un jeton invalide lève une [AuthenticationException] (sa sous-classe [CustomUserMessageAuthenticationException] porte un message libre). Sinon, un passeport qui se valide lui-même ([SelfValidatingPassport] : il n'y a pas de mot de passe à vérifier, la signature du jeton en tient lieu), dont le badge rend l'utilisateur construit à partir du jeton, sans le chercher en base ;
- lignes 60-63 : après un succès, [null] : la requête continue son chemin, vers l'action demandée ;
- lignes 67-71 : après un échec (jeton expiré ou falsifié), [null] aussi : le visiteur reste anonyme. Les règles [access_control] le renverront à la page de connexion s'il demande une page protégée, mais il peut consulter les pages publiques (la page de connexion elle-même !). On note seulement, dans un attribut de la requête, que le cookie est à effacer ;
- lignes 78-84 : le point d'entrée, appelé par le pare-feu quand un visiteur anonyme demande une page protégée : une redirection vers la page de connexion. Si la requête portait un cookie de jeton, c'est que ce jeton n'est plus valide : on affiche « Votre session a expiré, veuillez vous reconnecter » (un message flash, plus loin) ;
- lignes 90-96 : un écouteur de l'événement [kernel.response] (exemple 24), écrit dans la même classe : l'attribut [#[AsEventListener]] se place sur une méthode, et l'authentificateur, un service comme un autre, devient aussi écouteur. Il efface le cookie d'un jeton invalide, quelle que soit la réponse : la page de connexion, une page publique, ou la redirection du point d'entrée. Un cookie invalide ne sert à rien, et fausserait le message de la requête suivante.
11.12.4. src/Securite/SessionInvalide.php
| <?php
// src/Securite/SessionInvalide.php : le jeton est valide, mais le compte qu'il
// désigne n'existe plus : il a été supprimé depuis la connexion (par le patient
// lui-même dans un autre onglet, ou par l'ADMIN). On traite ce cas comme une
// session expirée : l'écouteur PageErreur efface le cookie et renvoie vers la page
// de connexion.
namespace App\Securite;
use RuntimeException;
class SessionInvalide extends RuntimeException
{
}
|
Un cas que l'exemple 25 ne connaissait pas : le jeton est valide, mais le compte qu'il désigne n'existe plus. Un patient a supprimé son compte dans un onglet, mais son autre onglet présente encore le jeton ; ou l'administrateur a supprimé le compte d'un patient connecté. La signature du jeton ne dit rien de l'existence du compte ; c'est quand l'application cherche le patient du compte ([Outils::patientConnecte], plus loin) qu'elle découvre qu'il n'existe plus. Elle lève alors cette exception, que l'écouteur [PageErreur] (plus loin) traite comme une session expirée. La classe est vide : seul son type compte, que l'écouteur reconnaît ([instanceof]).
11.12.5. src/Securite/CaptchaService.php, Captcha.php et DessinCaptcha.php
[DessinCaptcha.php] et [Captcha.php] sont ceux de l'exemple 27. Le service est presque le même :
| <?php
// =========================================================================
// src/Securite/CaptchaService.php : un CAPTCHA ("test pour distinguer un humain d'un
// robot") fabriqué par le serveur lui-même, sans service extérieur (cf. exemple 27).
//
// Fonctionnement :
// 1. generer() tire un texte au hasard, en fait une image, et range la bonne
// réponse DANS LE CACHE du serveur, sous un identifiant aléatoire ;
// 2. la page affiche l'image et place l'identifiant dans un champ caché du
// formulaire (la réponse, elle, ne quitte jamais le serveur) ;
// 3. verifier(id, saisie) compare la saisie à la réponse attendue, puis OUBLIE
// ce captcha : chaque image ne sert qu'une fois, un robot ne peut pas rejouer
// une réponse déjà trouvée.
//
// Pourquoi le cache ? Une application PHP ne garde RIEN en mémoire d'une requête à
// l'autre : chaque requête repart de zéro. Ce qui doit durer au-delà d'une requête
// va donc dans le cache (ici des fichiers, dans var/cache), partagé par toutes les
// requêtes, et même par plusieurs serveurs avec un cache comme Redis.
// =========================================================================
namespace App\Securite;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Lock\LockFactory;
class CaptchaService
{
// durée de validité d'une image, en secondes : 10 minutes pour remplir le formulaire
private const DUREE_VIE = 600;
public function __construct(private CacheItemPoolInterface $cache, private LockFactory $verrous)
{
}
public function generer(): Captcha
{
$texte = DessinCaptcha::texteAleatoire(5); // 5 caractères
$id = bin2hex(random_bytes(12)); // un identifiant impossible à deviner
// le cache oubliera de lui-même la réponse au bout de 10 minutes
$element = $this->cache->getItem("captcha.$id");
$this->cache->save($element->set($texte)->expiresAfter(self::DUREE_VIE));
return new Captcha($id, DessinCaptcha::svg($texte));
}
// vrai si [$saisie] est la bonne réponse au captcha [$id] (sans tenir compte des
// majuscules). Le captcha est consommé dans tous les cas.
public function verifier(?string $id, ?string $saisie): bool
{
// l'identifiant vient du navigateur : des chiffres hexadécimaux, ou rien
if ($id === null || ! ctype_xdigit($id) || $saisie === null) {
return false;
}
// si deux requêtes présentent le même captcha au même instant, une seule
// obtient le verrou (acquire(false) : sans attendre) - l'autre échoue
$verrou = $this->verrous->createLock("captcha.$id");
if (! $verrou->acquire(false)) {
return false;
}
try {
$element = $this->cache->getItem("captcha.$id");
$this->cache->deleteItem("captcha.$id");
} finally {
$verrou->release();
}
return $element->isHit() && strcasecmp($element->get(), trim($saisie)) === 0;
}
}
|
- lignes 35-43 : un texte de 5 caractères tirés au hasard, un identifiant aléatoire ([random_bytes], un générateur cryptographique, dont [bin2hex] écrit les octets en hexadécimal), et la bonne réponse rangée dans le cache (exemple 20), pour 10 minutes ([expiresAfter], en secondes) ;
- lignes 47-66 : une différence avec l'exemple 27. La réponse est lue, puis retirée du cache. Entre la lecture et le retrait, une seconde requête qui présenterait le même captcha pourrait la lire elle aussi : deux tentatives pour une seule image résolue. Un verrou (exemple 20), propre à ce captcha, protège donc la lecture et le retrait. [acquire(false)] le prend sans attendre : il rend [false] si une autre requête le détient déjà, et cette requête, qui perd la course, échoue. Le bloc [finally] libère le verrou dans tous les cas. La comparaison ignore les majuscules ([strcasecmp] rend 0 pour deux chaînes égales à la casse près) et les espaces autour de la saisie ([trim]).
11.12.6. src/Securite/LimiteurTentatives.php
| <?php
// =========================================================================
// src/Securite/LimiteurTentatives.php : second rempart contre les robots (le premier
// est le captcha) : on COMPTE les tentatives, et au-delà d'un seuil on BLOQUE
// pendant un moment (cf. exemple 27).
//
// - connexion : au bout de LOGIN_MAX_ECHECS échecs (mauvais captcha ou mauvais
// mot de passe) pour un même login depuis une même adresse IP, ce couple est
// bloqué jusqu'à la fin d'une fenêtre de LOGIN_BLOCAGE_MINUTES minutes, comptée
// depuis le premier échec. Une même adresse IP est aussi bloquée si elle cumule
// LOGIN_MAX_ECHECS_IP échecs, tous logins confondus (un robot qui essaie des
// milliers de logins différents) ;
// - création de compte : au plus INSCRIPTION_MAX_PAR_HEURE comptes créés par
// heure depuis une même adresse IP.
//
// Attention en salle de cours : tous les postes sortent souvent sur Internet par la
// MÊME adresse IP. C'est pourquoi le blocage de la connexion porte d'abord sur le
// couple (IP, login) - un étudiant qui se trompe de mot de passe ne bloque pas ses
// voisins - et pourquoi les seuils sont réglables (fichier .env).
//
// Les compteurs sont ceux des trois limiteurs du composant RateLimiter, déclarés
// dans config/packages/framework.yaml, et rangés dans le cache.
// =========================================================================
namespace App\Securite;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
class LimiteurTentatives
{
// les fabriques des limiteurs [login], [login_ip] et [inscription] : le nom
// du paramètre désigne le limiteur (cf. exemple 27)
public function __construct(
private RateLimiterFactoryInterface $loginLimiter,
private RateLimiterFactoryInterface $loginIpLimiter,
private RateLimiterFactoryInterface $inscriptionLimiter,
) {
}
// ---------------------------------------------------------------- connexion
// nombre de minutes de blocage restantes (0 = la tentative est autorisée)
public function minutesBlocageLogin(string $ip, string $login): int
{
return max(self::minutesRestantes($this->limiteurLogin($ip, $login)),
self::minutesRestantes($this->loginIpLimiter->create($ip)));
}
// consume() : un échec de plus ; le premier ouvre une fenêtre de
// LOGIN_BLOCAGE_MINUTES minutes, à la fin de laquelle le compteur repart de zéro
public function echecLogin(string $ip, string $login): void
{
$this->limiteurLogin($ip, $login)->consume();
$this->loginIpLimiter->create($ip)->consume();
}
// une connexion réussie remet à zéro le compteur de CE login
public function reussiteLogin(string $ip, string $login): void
{
$this->limiteurLogin($ip, $login)->reset();
}
// ---------------------------------------------------------------- inscription
public function minutesBlocageInscription(string $ip): int
{
return self::minutesRestantes($this->inscriptionLimiter->create($ip));
}
public function inscriptionReussie(string $ip): void
{
$this->inscriptionLimiter->create($ip)->consume();
}
// ---------------------------------------------------------------- mécanique commune
// le limiteur du couple (IP, login) : create(clé) rend un limiteur qui a son
// propre compteur, sous cette clé
private function limiteurLogin(string $ip, string $login): LimiterInterface
{
return $this->loginLimiter->create("$ip|".mb_strtolower(trim($login)));
}
// consume(0) consulte le limiteur sans rien consommer : plus aucun "jeton" ?
// getRetryAfter : la date de la fin de la fenêtre en cours ; les secondes qui
// restent d'ici là sont arrondies à la minute SUPÉRIEURE : 30 s -> "1 minute"
private static function minutesRestantes(LimiterInterface $limiteur): int
{
$etat = $limiteur->consume(0);
if ($etat->getRemainingTokens() > 0) {
return 0;
}
return max(1, (int) ceil(($etat->getRetryAfter()->getTimestamp() - time()) / 60));
}
}
|
Le second rempart contre les robots, après le captcha : on compte les tentatives, et au-delà d'un seuil, on bloque. L'exemple 27 a présenté le composant [RateLimiter] de [Symfony]. Les trois limiteurs sont déclarés dans [config/packages/framework.yaml] (politique [fixed_window] : une fenêtre de durée fixe, ouverte par le premier jeton consommé) ; le service reçoit leurs fabriques, désignées par le nom des paramètres (lignes 34-38) : [$loginLimiter] pour le limiteur [login], [$loginIpLimiter] pour [login_ip], [$inscriptionLimiter] pour [inscription]. Une fabrique fabrique, par sa méthode [create(clé)], un limiteur qui a son propre compteur, sous cette clé. Deux règles :
- la connexion (lignes 44-62) : chaque échec (mauvais captcha ou mauvais mot de passe) est compté deux fois : pour le couple (adresse IP, login), et pour l'adresse IP seule. Le couple est bloqué au bout de 5 échecs (réglage [LOGIN_MAX_ECHECS]), l'adresse IP au bout de 25 (réglage [LOGIN_MAX_ECHECS_IP]), tous logins confondus : c'est le robot qui essaie des milliers de logins différents. Pourquoi ne pas bloquer d'abord l'adresse IP ? En salle de cours (ou dans une entreprise), toutes les machines sortent souvent sur Internet par la même adresse IP : l'étudiant qui se trompe de mot de passe ne doit pas bloquer ses voisins. [consume()] consomme un jeton (compte un échec) ; le premier ouvre une fenêtre de 15 minutes (réglage [LOGIN_BLOCAGE_MINUTES]), à la fin de laquelle le compteur repart de zéro. Une connexion réussie remet à zéro le compteur de son couple ([reset]) ;
- la création de compte (lignes 66-74) : au plus 10 comptes par heure et par adresse IP (réglage [INSCRIPTION_MAX_PAR_HEURE]) ;
- lignes 80-83 : la clé du limiteur d'un couple. Le login est mis en minuscules : [Admin] et [admin] ne font qu'un (comme pour la base, cf. sa collation) ;
- lignes 88-95 : [consume(0)] consulte le limiteur sans rien consommer (exemple 27) : il reste des jetons, la tentative est permise ; sinon, [getRetryAfter] donne la date de la fin de la fenêtre en cours, et le nombre de secondes qui restent d'ici là est arrondi à la minute supérieure ([ceil]) : il reste 30 secondes, on annonce « 1 minute ». Le message affiché en dépend : « Trop de tentatives infructueuses. Réessayez dans 15 minutes. ».
Les compteurs des limiteurs sont rangés dans le cache : ils sont partagés par toutes les requêtes, et survivent à un redémarrage du serveur. La commande [php bin/console cache:pool:clear cache.rate_limiter] les remet tous à zéro (utile en développement, quand on s'est bloqué soi-même) ; [php bin/console cache:clear], qui vide tout le dossier [var/cache], aussi.
11.12.7. src/Securite/PasseInscription.php et PasseInscriptionExige.php
| <?php
// =========================================================================
// src/Securite/PasseInscription.php : le "laissez-passer" de la création de compte.
//
// Pour ouvrir la page "Créer un compte client", il faut d'abord avoir recopié le
// captcha de la page d'authentification (cf. InscriptionController, POST
// /inscription/acces). Le serveur délivre alors un laissez-passer :
// - un identifiant aléatoire, rangé dans un cookie HttpOnly ;
// - valable 15 minutes (le temps de remplir le formulaire, même en s'y reprenant
// à plusieurs fois après des erreurs de saisie) ;
// - consommé dès qu'un compte est créé : UN captcha résolu = UN compte au plus.
// Un robot ne peut pas créer des comptes en série.
//
// Sans laissez-passer valide, GET et POST /inscription renvoient vers la page
// d'authentification (cf. l'attribut #[PasseInscriptionExige] et l'écouteur
// VerifierPasseInscription) : taper directement l'URL /inscription ne permet pas
// de contourner le captcha.
//
// Comme les captchas, les laissez-passer vivent dans le cache.
// =========================================================================
namespace App\Securite;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
class PasseInscription
{
public const COOKIE = 'rdvmedecins_inscription';
// le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
private const CHEMIN = '/inscription';
// durée de vie d'un laissez-passer, en minutes
private const DUREE_VIE = 15;
public function __construct(
private CacheItemPoolInterface $cache,
#[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure,
) {
}
// délivre un nouveau laissez-passer ; rend le cookie qui le transporte
public function emettre(): Cookie
{
$id = bin2hex(random_bytes(12));
$element = $this->cache->getItem("passe.$id");
$this->cache->save($element->set(true)->expiresAfter(self::DUREE_VIE * 60));
return Cookie::create(self::COOKIE, $id)
->withExpires(time() + self::DUREE_VIE * 60)
->withPath(self::CHEMIN)
->withSecure($this->cookieSecure)
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_LAX);
}
// vrai si [$id] est un laissez-passer existant et non expiré ; hasItem : le
// cache a-t-il cet élément ?
public function estValide(?string $id): bool
{
return $id !== null && ctype_xdigit($id) && $this->cache->hasItem("passe.$id");
}
// le laissez-passer a servi : on l'oublie...
public function consommer(?string $id): void
{
if ($id !== null && ctype_xdigit($id)) {
$this->cache->deleteItem("passe.$id");
}
}
// ... et son cookie est effacé par un cookie de même nom et de même chemin, déjà expiré
public static function effacerCookie(Response $reponse): Response
{
$reponse->headers->clearCookie(self::COOKIE, self::CHEMIN);
return $reponse;
}
}
|
La création d'un compte client est protégée par le captcha de la page de connexion. Mais le formulaire de création de compte est une autre page ([/inscription]) : comment savoir, quand elle est demandée, que le captcha a été résolu juste avant ? Par un laissez-passer : quand le captcha est résolu, le serveur tire un identifiant au hasard, le range dans le cache pour 15 minutes, et l'envoie au navigateur dans un cookie. Le formulaire n'est affiché, et le compte n'est créé, qu'avec un laissez-passer valide ; il est consommé par la création du compte. Un captcha résolu permet ainsi de créer un compte, et taper directement l'adresse [/inscription] ne mène à rien.
- lignes 46-57 : le cookie a pour chemin [/inscription] ([withPath]) : le navigateur ne l'envoie qu'aux adresses qui commencent par ce chemin. Il ne contient qu'un identifiant aléatoire, impossible à deviner, dont seul le cache du serveur sait s'il est valide : le cookie n'a pas besoin d'être signé ;
- lignes 61-64 : [hasItem] dit si le cache contient un élément (non expiré) sous cette clé ;
- lignes 75-79 : pour effacer un cookie, il faut donner le même nom et le même chemin : pour le navigateur, deux cookies de même nom et de chemins différents sont deux cookies distincts.
| <?php
// src/Securite/PasseInscriptionExige.php : un ATTRIBUT PHP de notre invention (cf.
// exemple 24), placé sur les actions qui exigent un laissez-passer de création de
// compte (cf. PasseInscription et l'écouteur VerifierPasseInscription).
namespace App\Securite;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class PasseInscriptionExige
{
}
|
Un attribut [PHP] de notre invention, comme le [#[Chronometre]] de l'exemple 24, placé sur les actions qui exigent un laissez-passer. Il est lu par l'écouteur [VerifierPasseInscription].
11.13. Les écouteurs : src/EventListener
Trois écouteurs d'événements du noyau (exemple 24), rangés dans le dossier [src/EventListener], comme le veut l'usage. L'attribut [#[AsEventListener]], placé sur la classe, désigne l'événement et la priorité ; la méthode appelée est alors [__invoke].
11.13.1. src/EventListener/FixerLangue.php
| <?php
// =========================================================================
// src/EventListener/FixerLangue.php : la langue de la requête (cf. exemple 22) :
// celle du cookie [rdvmedecins_langue] si c'est une langue gérée, le français sinon.
//
// Priorité 40 : l'écouteur passe AVANT le routage de Symfony (priorité 32). Il
// s'exécute ainsi pour TOUTES les requêtes, même celles qui n'aboutissent à aucune
// route (page 404, 405...) : leur page d'erreur est dans la bonne langue. Pour la
// même raison, il fixe aussi lui-même la langue du traducteur, ce que ferait sinon
// un écouteur de Symfony (priorité 15) qu'une erreur de routage empêcherait
// d'intervenir.
// =========================================================================
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Translation\LocaleAwareInterface;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 40)]
class FixerLangue
{
// le cookie qui mémorise la langue choisie (cf. LangueController)
public const COOKIE = 'rdvmedecins_langue';
// les langues gérées (dossier translations) ; la première est la langue par défaut
public const LANGUES = ['fr', 'en'];
// LocaleAwareInterface : un service qui a une langue, ici le traducteur
public function __construct(private LocaleAwareInterface $traducteur)
{
}
public function __invoke(RequestEvent $evenement): void
{
$requete = $evenement->getRequest();
$langue = $requete->cookies->get(self::COOKIE);
$langue = in_array($langue, self::LANGUES, true) ? $langue : self::LANGUES[0];
$requete->setLocale($langue);
$this->traducteur->setLocale($langue);
}
}
|
La langue de la requête (exemple 22) : celle du cookie, si c'est une langue gérée, sinon la langue par défaut (le français). Une différence avec l'exemple 22 : la priorité 40 (ligne 21), supérieure à celle du routage (32). L'écouteur de l'exemple 22 (priorité 20) passait après le routage ; pour une URL qui ne correspond à aucune route, le routage lève une exception avant lui, et la page 404 aurait été en français même pour un utilisateur anglophone. Pour la même raison, l'écouteur fixe lui-même la langue du traducteur (ligne 41), ce que fait d'ordinaire un écouteur de [Symfony] ([LocaleAwareListener]) placé, lui aussi, après le routage. [LocaleAwareInterface] est l'interface des services qui ont une langue ; le traducteur en fait partie, et c'est lui que [Symfony] injecte ici.
11.13.2. src/EventListener/VerifierPasseInscription.php
| <?php
// =========================================================================
// src/EventListener/VerifierPasseInscription.php : les actions marquées de
// l'attribut #[PasseInscriptionExige] (GET et POST /inscription) exigent un
// laissez-passer valide (cf. src/Securite/PasseInscription.php) ; sinon, retour à
// la page d'authentification, avec un message. Taper directement l'URL
// /inscription ne permet donc pas de contourner le captcha.
//
// Un écouteur de kernel.controller (cf. exemple 24) : l'action est choisie, mais
// pas encore appelée. Pour refuser, il la REMPLACE (setController) par une
// fonction qui rend une redirection.
// =========================================================================
namespace App\EventListener;
use App\Securite\PasseInscription;
use App\Securite\PasseInscriptionExige;
use App\Web\Flash;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsEventListener(event: KernelEvents::CONTROLLER)]
class VerifierPasseInscription
{
public function __construct(
private PasseInscription $passes,
private Flash $flash,
private UrlGeneratorInterface $urls,
) {
}
public function __invoke(ControllerEvent $evenement): void
{
if ($evenement->getAttributes(PasseInscriptionExige::class) === []) {
return;
}
if (! $this->passes->estValide($evenement->getRequest()->cookies->get(PasseInscription::COOKIE))) {
$this->flash->erreur('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
$url = $this->urls->generate('login');
$evenement->setController(fn () => new RedirectResponse($url));
}
}
}
|
Un écouteur de [kernel.controller] (exemple 24) : l'action est choisie, mais pas encore appelée.
- lignes 37-39 : [getAttributes(classe)] rend les attributs de cette classe portés par l'action : une liste vide, l'action n'exige pas de laissez-passer, l'écouteur ne fait rien ;
- lignes 40-44 : pas de laissez-passer valide : un message, et l'action est remplacée ([setController]) par une fonction qui rend une redirection vers la page de connexion. Le noyau appelle cette fonction à la place de l'action. L'URL est calculée hors de la fonction fléchée, qui n'a ainsi besoin que d'une variable.
L'écouteur reçoit ses services par son constructeur : un écouteur est un service, créé par le conteneur, comme les contrôleurs.
11.13.3. src/EventListener/PageErreur.php
| <?php
// =========================================================================
// src/EventListener/PageErreur.php : comment les erreurs deviennent des pages HTML
// (cf. exemple 24). Trois cas :
// 1. une SessionInvalide (compte supprimé depuis la connexion) -> retour à la
// page de connexion, comme pour une session expirée ;
// 2. une erreur HTTP : requête invalide (400), rôle insuffisant (403), page
// inconnue (404), méthode non prise en charge (405), jeton anti-CSRF absent ou
// faux (419)... -> la page d'erreur, avec le code d'origine ;
// 3. toute autre exception (une vraie anomalie) : Symfony l'a déjà inscrite dans
// le journal, avec sa pile d'appels ; en production (APP_ENV=prod),
// l'utilisateur voit la page d'erreur 500 - jamais la pile d'appels ; en
// développement, la page de débogage de Symfony.
//
// Priorité -10 : après l'écouteur de Symfony qui inscrit l'exception dans le journal
// (0) et après celui de la sécurité (1), qui transforme un refus d'accès en
// redirection vers la page de connexion (visiteur anonyme) ou en erreur 403
// (utilisateur authentifié) ; avant celui qui fabrique la page d'erreur de Symfony
// (-128).
// =========================================================================
namespace App\EventListener;
use App\Securite\AuthService;
use App\Securite\SessionInvalide;
use App\Web\Flash;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
class PageErreur
{
public function __construct(
private Environment $twig,
private Flash $flash,
private UrlGeneratorInterface $urls,
// le mode debug (APP_ENV=dev) : le paramètre kernel.debug du conteneur
#[Autowire('%kernel.debug%')] private bool $debug,
) {
}
public function __invoke(ExceptionEvent $evenement): void
{
$e = $evenement->getThrowable();
if ($e instanceof SessionInvalide) {
$this->flash->erreur('ERRORS.SESSION_EXPIRED');
$evenement->setResponse(AuthService::effacerCookie(new RedirectResponse($this->urls->generate('login'))));
return;
}
if ($e instanceof HttpExceptionInterface) {
$evenement->setResponse($this->page($e->getStatusCode()));
return;
}
// une vraie anomalie : en développement, la page de débogage de Symfony
if (! $this->debug) {
$evenement->setResponse($this->page(500));
}
}
// la page d'erreur, avec le code HTTP de l'erreur et son message
private function page(int $code): Response
{
$message = match ($code) {
400 => 'ERRORS.BAD_REQUEST',
403 => 'ERRORS.FORBIDDEN',
404, 405 => 'ERRORS.NOT_FOUND',
419 => 'ERRORS.PAGE_EXPIRED',
default => 'ERRORS.SERVER_ERROR',
};
return new Response($this->twig->render('erreur.html.twig', ['titrePage' => 'ERROR_PAGE.TITLE',
'code' => $code, 'message' => $message]), $code);
}
}
|
Comment les erreurs deviennent des pages HTML : c'est l'écouteur [PageErreur] de l'exemple 24, complété. Il écoute l'événement [kernel.exception], avec la priorité -10 (ligne 37) : après l'écouteur de [Symfony] qui inscrit l'exception dans le journal (priorité 0) et après celui du composant de sécurité (priorité 1), qui transforme un refus d'accès soit en appel du point d'entrée (visiteur anonyme), soit en erreur 403 (utilisateur authentifié dont le rôle ne convient pas) ; avant celui qui fabrique la page d'erreur de [Symfony] (-128). Trois cas :
- lignes 53-57 : une [SessionInvalide] (le compte a disparu) : le message « session expirée », l'effacement du cookie du jeton, et la page de connexion ;
- lignes 59-62 : une erreur HTTP : [HttpExceptionInterface] est l'interface des exceptions qui portent un code HTTP ([NotFoundHttpException], [AccessDeniedHttpException], [HttpException(419)]...), que rend [getStatusCode]. La page d'erreur est affichée, avec ce code ;
- lignes 65-67 : une autre exception est une vraie anomalie. En développement ([kernel.debug], exemple 24), l'écouteur ne fait rien : la page de débogage de [Symfony], avec la pile d'appels, sera affichée. En production, la page d'erreur 500 : l'utilisateur ne voit jamais la pile d'appels, que le journal a déjà recueillie ;
- lignes 71-82 : la page d'erreur, la vue [erreur.html.twig], avec le code et la clé du message. [match] ([PHP] 8) choisit une valeur selon une autre, par l'égalité stricte ; [404, 405 =>] regroupe deux cas ; [default] traite tous les autres. Le code 405 (Method Not Allowed : un GET sur une adresse qui n'accepte que POST) est présenté comme une page introuvable.
11.14. Le contrôleur de la connexion
11.14.1. src/Controller/AuthController.php
| <?php
// =========================================================================
// src/Controller/AuthController.php : les routes PUBLIQUES de la connexion :
// GET / redirige vers la page d'accueil du rôle (ou vers /login)
// GET /login affiche le formulaire de connexion (avec son captcha)
// POST /login vérifie limite de tentatives, captcha, login / mot de passe
// POST /logout la déconnexion
//
// Protection contre les robots, dans cet ordre :
// 1. trop d'échecs récents pour ce login depuis cette adresse IP ?
// -> refus immédiat, sans même regarder le mot de passe ;
// 2. mauvaise réponse au captcha ? -> refus (compté comme un échec) ;
// 3. seulement alors, vérification du mot de passe.
// On ne vérifie JAMAIS le mot de passe d'une tentative refusée aux étapes 1 ou 2 :
// un robot ne peut donc rien apprendre en insistant.
// =========================================================================
namespace App\Controller;
use App\Entity\Role;
use App\Securite\AuthService;
use App\Securite\CaptchaService;
use App\Securite\LimiteurTentatives;
use App\Securite\UtilisateurConnecte;
use App\Web\Controleur;
use App\Web\Message;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AuthController extends Controleur
{
public function __construct(
private AuthService $auth,
private CaptchaService $captchas,
private LimiteurTentatives $limiteur,
) {
}
// la page d'accueil de chaque rôle, juste après la connexion : le nom de sa route
public static function pageAccueil(Role $role): string
{
return $role === Role::USER ? 'espace' : 'agenda';
}
// -----------------------------------------------------------------------
// GET /
// La route est publique : l'utilisateur, s'il y en a un, est connu
// ($this->getUser()), mais rien n'est exigé.
// -----------------------------------------------------------------------
#[Route('/', name: 'accueil', methods: ['GET'])]
public function accueil(): Response
{
$utilisateur = $this->getUser();
return $this->redirectToRoute($utilisateur instanceof UtilisateurConnecte
? self::pageAccueil($utilisateur->role) : 'login');
}
// -----------------------------------------------------------------------
// GET /login[?login=xxx] : le paramètre facultatif pré-remplit le champ login
// (utilisé juste après la création d'un compte, cf. InscriptionController)
// -----------------------------------------------------------------------
#[Route('/login', name: 'login', methods: ['GET'])]
public function formulaire(Request $request): Response
{
// déjà connecté (jeton encore valide) : directement à la page d'accueil
$utilisateur = $this->getUser();
if ($utilisateur instanceof UtilisateurConnecte) {
return $this->redirectToRoute(self::pageAccueil($utilisateur->role));
}
// mb_substr : 30 caractères au plus
return $this->page(mb_substr($request->query->getString('login'), 0, 30), null);
}
// -----------------------------------------------------------------------
// POST /login
// -----------------------------------------------------------------------
#[Route('/login', name: 'connexion', methods: ['POST'])]
public function connexion(Request $request): Response
{
$this->verifierCsrf($request);
$login = $request->request->getString('login');
$password = $request->request->getString('password');
$ip = (string) $request->getClientIp();
// 1. trop d'échecs récents ?
$minutes = $this->limiteur->minutesBlocageLogin($ip, $login);
if ($minutes > 0) {
return $this->page($login, new Message('ERRORS.TOO_MANY_ATTEMPTS', ['minutes' => $minutes]));
}
// 2. le captcha
if (! $this->captchas->verifier($request->request->getString('captchaId'),
$request->request->getString('captcha'))) {
$this->limiteur->echecLogin($ip, $login);
return $this->page($login, new Message('ERRORS.WRONG_CAPTCHA'));
}
// 3. le mot de passe
$user = $login !== '' && $password !== '' ? $this->auth->valider($login, $password) : null;
if ($user === null) {
$this->limiteur->echecLogin($ip, $login);
return $this->page($login, new Message('LOGIN.ERROR'));
}
$this->limiteur->reussiteLogin($ip, $login);
$reponse = $this->redirectToRoute(self::pageAccueil($user->getRole()));
$reponse->headers->setCookie($this->auth->cookieJeton($user));
return $reponse;
}
// -----------------------------------------------------------------------
// POST /logout : effacer le cookie du jeton, puis aller à la page de connexion.
// POST seulement : un simple lien (GET), placé sur un autre site, ne peut pas
// déconnecter l'utilisateur à son insu.
// -----------------------------------------------------------------------
#[Route('/logout', name: 'logout', methods: ['POST'])]
public function deconnexion(Request $request): Response
{
$this->verifierCsrf($request);
return AuthService::effacerCookie($this->redirectToRoute('login'));
}
// le formulaire, toujours avec un NOUVEAU captcha (chaque image ne sert qu'une
// fois) ; on réaffiche le login saisi, jamais le mot de passe. Une erreur :
// le code 422, comme pour un formulaire invalide (cf. exemple 19)
private function page(string $login, ?Message $erreur): Response
{
return $this->render('login.html.twig', ['titrePage' => 'LOGIN.TITLE', 'erreur' => $erreur,
'login' => $login, 'captcha' => $this->captchas->generer()],
new Response(status: $erreur ? 422 : 200));
}
}
|
Le contrôleur de la connexion, qui reprend l'exemple 27. Il hérite de la classe [Controleur] de l'application (plus loin), elle-même fille d'[AbstractController].
- lignes 41-44 : la page d'accueil de chaque rôle, le nom de sa route : « Mon espace » pour un patient, l'agenda pour les autres ;
- lignes 51-57 : l'adresse [/] renvoie vers la page d'accueil du rôle, ou vers la page de connexion. La route est publique (règle [access_control] de [security.yaml]) : [getUser()] rend l'utilisateur établi par l'authentificateur, s'il y en a un, sans rien exiger. [redirectToRoute] (exemple 04) fabrique la redirection vers une route nommée ;
- lignes 63-73 : un utilisateur déjà connecté va directement à sa page d'accueil. Le paramètre [login] de l'URL, facultatif, pré-remplit le champ (après la création d'un compte) ; [mb_substr] le coupe à 30 caractères ;
- lignes 78-110 : la connexion, dans l'ordre de l'exemple 27 : le jeton anti-CSRF (plus loin), le blocage éventuel, le captcha, le mot de passe. Un mot de passe n'est jamais vérifié pour une tentative refusée par le limiteur ou le captcha : un robot n'apprend rien en insistant. [getClientIp] rend l'adresse IP du client. En cas d'échec, la page est rendue directement (pas de redirection) : le formulaire réaffiché a toujours un nouveau captcha, et le login saisi. L'erreur est un objet [Message] (plus loin), une clé et ses paramètres, affiché en bas de page. En cas de succès, le compteur du login est remis à zéro, et le navigateur est redirigé vers la page d'accueil du rôle, avec le cookie du jeton ([headers->setCookie]) ;
- lignes 117-122 : la déconnexion efface le cookie du jeton (exemple 25). Elle n'accepte que POST, avec le jeton anti-CSRF : un simple lien placé sur un autre site ne peut pas déconnecter l'utilisateur à son insu ;
- lignes 127-132 : la page de connexion. Le troisième paramètre de [render] est la réponse à compléter : une erreur est rendue avec le code 422 (Unprocessable Content), comme un formulaire invalide (exemple 19). [new Response(status: ...)] utilise un argument nommé ([PHP] 8) : les deux premiers paramètres du constructeur gardent leur valeur par défaut.
La page de connexion reçoit la variable [titrePage], la clé de traduction du titre de la page : toutes les actions la fournissent à leur vue, pour le gabarit (plus loin).
11.15. La couche web : les services communs
Le dossier [src/Web] rassemble ce que les contrôleurs ont en commun.
11.15.1. src/Web/ApplicationModel.php
| <?php
// =========================================================================
// src/Web/ApplicationModel.php : le cache / façade de la couche métier (l'équivalent
// de la classe [ApplicationModel] des autres versions de l'application) : les
// listes de médecins et de clients sont lues en base une fois, puis servies depuis
// le cache.
//
// Pourquoi le CACHE de Symfony, et pas simplement une propriété de cet objet ? Parce
// qu'une application PHP ne garde rien en mémoire d'une requête à l'autre : cet
// objet est un service partagé, mais un service ne vit que le temps d'UNE requête.
// Le cache, lui, est partagé par toutes les requêtes (cf. exemples 20 et 23).
//
// Ces listes peuvent CHANGER (l'ADMIN ajoute, modifie, supprime des médecins et des
// clients) : chaque écriture passe donc par cette façade, qui OUBLIE la liste
// concernée juste après ; la requête suivante qui en a besoin la relit en base. C'est
// tout l'intérêt d'un point d'entrée UNIQUE vers la couche métier : la règle
// "écriture => mise à jour du cache" n'est écrite qu'ici.
// =========================================================================
namespace App\Web;
use App\Domain\AgendaMedecinJour;
use App\Entity\Client;
use App\Entity\Creneau;
use App\Entity\Medecin;
use App\Entity\Rv;
use App\Metier\DonneesPersonne;
use App\Metier\Metier;
use App\Metier\MetierService;
use DateTimeImmutable;
use Symfony\Contracts\Cache\CacheInterface;
// même contrat que la couche métier
class ApplicationModel implements Metier
{
private const MEDECINS = 'rdvmedecins.medecins';
private const CLIENTS = 'rdvmedecins.clients';
// CacheInterface : le cache de l'application (le service cache.app), vu par
// l'interface des "contrats" de Symfony, plus simple que celle de PSR-6
public function __construct(private MetierService $metier, private CacheInterface $cache)
{
}
// ------------------------------------------------ lectures depuis le cache
// get(clé, fonction) : la valeur du cache si elle existe ; sinon, la fonction
// est appelée, et son résultat rangé dans le cache (sans limite de durée) avant
// d'être rendu. Les entités y sont rangées sérialisées (cf. serialize) : chaque
// requête qui les lit en obtient des copies, que Doctrine ne suit pas
public function getAllMedecins(): array
{
return $this->cache->get(self::MEDECINS, fn () => $this->metier->getAllMedecins());
}
public function getAllClients(): array
{
return $this->cache->get(self::CLIENTS, fn () => $this->metier->getAllClients());
}
// le médecin dont le compte utilisateur est [$idUser] - sert à présélectionner,
// pour un médecin connecté, son propre agenda. array_find : le premier élément
// du tableau qui vérifie la condition (null s'il n'y en a pas)
public function getMedecinByUserId(int $idUser): ?Medecin
{
return array_find($this->getAllMedecins(), fn (Medecin $m) => $m->getCompte()->getId() === $idUser);
}
// ------------------------------------------------ simples délégations
public function getAllCreneaux(int $idMedecin): array
{
return $this->metier->getAllCreneaux($idMedecin);
}
public function getRvMedecinJour(int $idMedecin, DateTimeImmutable $jour): array
{
return $this->metier->getRvMedecinJour($idMedecin, $jour);
}
public function getClientById(int $id): ?Client
{
return $this->metier->getClientById($id);
}
public function getMedecinById(int $id): ?Medecin
{
return $this->metier->getMedecinById($id);
}
public function getRvById(int $id): ?Rv
{
return $this->metier->getRvById($id);
}
public function getCreneauById(int $id): ?Creneau
{
return $this->metier->getCreneauById($id);
}
public function getAgendaMedecinJour(int $idMedecin, DateTimeImmutable $jour): ?AgendaMedecinJour
{
return $this->metier->getAgendaMedecinJour($idMedecin, $jour);
}
// pas de cache ici : c'est la donnée la plus récente qui compte (un patient qui
// vient de modifier ou de supprimer son compte)
public function getClientByUserId(int $idUser): ?Client
{
return $this->metier->getClientByUserId($idUser);
}
public function getRvAVenirClient(int $idClient): array
{
return $this->metier->getRvAVenirClient($idClient);
}
public function ajouterRv(DateTimeImmutable $jour, Creneau $creneau, Client $client): Rv
{
return $this->metier->ajouterRv($jour, $creneau, $client);
}
public function supprimerRv(Rv $rv): void
{
$this->metier->supprimerRv($rv);
}
// ------------------------------------------------ écritures => cache oublié
// (delete : l'élément est retiré du cache)
public function ajouterMedecin(DonneesPersonne $donnees): Medecin
{
$medecin = $this->metier->ajouterMedecin($donnees);
$this->cache->delete(self::MEDECINS);
return $medecin;
}
public function modifierMedecin(int $id, DonneesPersonne $donnees): Medecin
{
$medecin = $this->metier->modifierMedecin($id, $donnees);
$this->cache->delete(self::MEDECINS);
return $medecin;
}
public function supprimerMedecin(int $id): void
{
$this->metier->supprimerMedecin($id);
$this->cache->delete(self::MEDECINS);
}
public function ajouterClient(DonneesPersonne $donnees): Client
{
$client = $this->metier->ajouterClient($donnees);
$this->cache->delete(self::CLIENTS);
return $client;
}
public function modifierClient(int $id, DonneesPersonne $donnees): Client
{
$client = $this->metier->modifierClient($id, $donnees);
$this->cache->delete(self::CLIENTS);
return $client;
}
public function supprimerClient(int $id): void
{
$this->metier->supprimerClient($id);
$this->cache->delete(self::CLIENTS);
}
}
|
Toutes les versions de l'application ont une classe [ApplicationModel] : un point d'entrée unique de la couche web vers la couche métier, qui garde en mémoire les listes des médecins et des clients. Elles sont demandées par presque toutes les pages (la liste déroulante des médecins de l'agenda, celle des patients de la fenêtre de réservation, les pages d'administration), et changent rarement.
Ce qui change, en [PHP], c'est le sens de « en mémoire ». Une application [Java], [C#] ou [Python] reste en mémoire entre deux requêtes : la version [Flask] gardait ces listes dans les propriétés d'un objet unique, créé au démarrage. Une application [PHP] est reconstruite à chaque requête (exemple 01) : l'objet [ApplicationModel] est bien un service partagé, mais il ne vit que le temps d'une requête (exemple 23). Les listes sont donc rangées dans le cache de [Symfony] (exemple 20), partagé par toutes les requêtes.
- ligne 34 : la classe implémente l'interface [Metier], comme [MetierService] : les contrôleurs appellent les mêmes méthodes, quelle que soit la classe qui les sert ;
- ligne 42 : le cache est reçu cette fois par l'interface [CacheInterface] des « contrats » de [Symfony] (le même service [cache.app]), plus simple que l'interface PSR-6 des exemples ;
- lignes 52-60 : [get(clé, fonction)] rend la valeur rangée sous cette clé ; si elle n'existe pas, il appelle la fonction (qui lit la liste en base), range son résultat dans le cache, sans limite de durée, et le rend. La première requête qui a besoin de la liste la lit en base ; les suivantes la trouvent dans le cache. Le cache range le tableau d'entités sous une forme sérialisée (exemple 20), et le reconstruit à la lecture, avec les relations chargées (le compte de chaque personne). Les entités reconstruites sont des copies, que le gestionnaire d'entités de [Doctrine] ne suit pas : elles servent à l'affichage, jamais à une écriture (la couche métier relit toujours en base ce qu'elle modifie) ;
- lignes 65-68 : le médecin d'un compte, cherché dans la liste du cache par [array_find] (exemple 06) ;
- lignes 72-127 : les autres méthodes du contrat [Metier] délèguent simplement à la couche métier. Le client d'un compte ([getClientByUserId]) est toujours relu en base, et non cherché dans le cache : c'est la donnée la plus récente qui compte (un patient qui vient de modifier ou de supprimer son compte) ;
- lignes 132-170 : les écritures. Chaque écriture d'un médecin ou d'un client oublie la liste concernée ([delete]) : la requête suivante qui en aura besoin la relira en base. C'est tout l'intérêt d'un point d'entrée unique : la règle « une écriture met à jour le cache » n'est écrite qu'ici. Si l'écriture échoue (une [MetierException]), la liste n'est pas oubliée : elle n'a pas changé. Une application qui écrirait dans la base par un autre chemin (un autre programme, une autre version de l'application sur la même base) laisserait le cache périmé : [php bin/console cache:pool:clear cache.app] le viderait.
11.15.2. src/Web/Message.php et Flash.php
| <?php
// src/Web/Message.php : un message à traduire : sa clé, et ses paramètres.
// Les vues l'affichent par {{ message.cle|trans(message.parametres) }}.
namespace App\Web;
readonly class Message
{
public function __construct(
public string $cle,
public array $parametres = [],
) {
}
}
|
Un message à traduire : une clé et ses paramètres. Les erreurs de la couche métier ([MetierException]) et celles des contrôleurs deviennent des [Message], que les vues traduisent dans la langue de la page.
| <?php
// =========================================================================
// src/Web/Flash.php : les messages "flash" : un message à afficher UNE fois, sur la
// page SUIVANTE (cf. exemple 20). C'est le complément naturel du schéma
// "Post / Redirect / Get" utilisé par tous les formulaires de l'application : un
// POST réussi ne fabrique pas de page, il redirige (302) vers une page GET ; le
// message, lui, doit survivre à la redirection.
//
// Symfony les range dans la SESSION (des fichiers du dossier var/sessions, cf.
// config/packages/framework.yaml).
//
// Un message ne contient pas un texte, mais une CLÉ de traduction et ses paramètres
// (un objet Message) : il est traduit au moment de l'affichage, dans la langue de
// la page (cf. templates/fragments). Deux listes : les succès (affichés en haut de
// la page) et les erreurs (affichées en bas).
// =========================================================================
namespace App\Web;
use Symfony\Component\HttpFoundation\RequestStack;
class Flash
{
// la pile des requêtes, pour atteindre la session de la requête en cours
public function __construct(private RequestStack $requetes)
{
}
public function succes(string $cle, array $parametres = []): void
{
$this->ajouter('succes', $cle, $parametres);
}
public function erreur(string $cle, array $parametres = []): void
{
$this->ajouter('erreurs', $cle, $parametres);
}
// getFlashBag : le "sac" des messages flash de la session ; add(type, message)
// ajoute un message à la liste de ce type (cf. exemple 20)
private function ajouter(string $type, string $cle, array $parametres): void
{
$this->requetes->getSession()->getFlashBag()->add($type, new Message($cle, $parametres));
}
}
|
Les messages flash (exemple 20) : un message déposé dans la session par une action, affiché par la page suivante, puis oublié. C'est le complément du schéma Post / Redirect / Get, que suivent tous les formulaires de l'application. Deux types : les succès s'affichent en haut de la page, les erreurs en bas.
- ligne 25 : la pile des requêtes ([RequestStack]), un service de [Symfony] qui connaît la requête en cours, et donc sa session. Un service partagé ne peut pas recevoir la requête elle-même par son constructeur : il est créé une fois, et pourrait servir à plusieurs requêtes (une requête principale et ses sous-requêtes) ;
- lignes 41-44 : le « sac » des messages flash de la session ([getFlashBag]), dont [add(type, message)] complète la liste de ce type. C'est ce que fait la méthode [addFlash] d'[AbstractController] (exemple 20) ; en faire un service permet de déposer un message hors d'un contrôleur : l'authentificateur, les écouteurs.
Les objets [Message] sont rangés tels quels dans la session : [PHP] sérialise les données de session (exemple 20), et les reconstruit à la requête suivante. Dans les vues, [app.flashes('erreurs')] rend la liste des messages de ce type, et les retire de la session.
11.15.3. src/Web/Controleur.php
| <?php
// =========================================================================
// src/Web/Controleur.php : la classe mère des contrôleurs de l'application. Elle
// hérite d'AbstractController (cf. exemple 03), et ajoute la vérification du jeton
// anti-CSRF des formulaires écrits à la main (cf. exemple 21).
// =========================================================================
namespace App\Web;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
abstract class Controleur extends AbstractController
{
// l'identifiant du jeton anti-CSRF des formulaires écrits à la main : un seul
// pour toute l'application (dans les vues : csrf_token('rdvmedecins')). Les
// formulaires du composant Form ont chacun le leur
public const CSRF = 'rdvmedecins';
// -----------------------------------------------------------------------
// un jeton anti-CSRF absent ou faux : la page a sans doute été affichée avant
// l'expiration de la session, qui contenait le jeton. Erreur 419, le code (non
// officiel, mais répandu) d'une page expirée : l'écouteur PageErreur affiche
// "La page a expiré : rechargez-la, puis recommencez."
// -----------------------------------------------------------------------
protected function verifierCsrf(Request $request): void
{
if (! $this->isCsrfTokenValid(self::CSRF, $request->request->getString('_token'))) {
throw new HttpException(419, 'jeton anti-CSRF invalide');
}
}
}
|
La classe mère des contrôleurs. Les formulaires écrits à la main dans les vues (la connexion, la déconnexion, les suppressions, la réservation...) portent un jeton anti-CSRF (exemple 21), d'identifiant [rdvmedecins] : un seul identifiant suffit ici, car toutes ces actions exigent déjà un utilisateur connecté, ou un captcha. Les formulaires du composant Form ont chacun le leur, vérifié par le composant.
Un jeton absent ou faux arrête l'action par une erreur 419 (lignes 27-32), le code (non officiel, mais répandu) d'une page expirée : le jeton est rangé dans la session, et une page affichée avant la fin de la session porte un jeton que le serveur ne connaît plus. L'écouteur [PageErreur] affiche alors « La page a expiré : rechargez-la, puis recommencez. ». [HttpException] est l'exception HTTP générique : son constructeur reçoit le code et un message (destiné au journal, pas à l'utilisateur).
11.15.4. src/Web/Outils.php
| <?php
// =========================================================================
// src/Web/Outils.php : petites fonctions utilitaires de la couche web :
// - dates : "aaaa-mm-jj", jours "mercredi 23 septembre 2026" ;
// - saisie : lecture d'un entier positif, d'une date, envoyés par le navigateur ;
// - utilisateur : l'utilisateur connecté, et son patient (entité Client).
// Les méthodes STATIQUES s'appellent sans objet (Outils::iso($jour)) ; les autres
// ont besoin de services (la requête en cours, la sécurité...) : la classe est un
// service, que les contrôleurs reçoivent par leur constructeur.
// =========================================================================
namespace App\Web;
use App\Entity\Client;
use App\Securite\SessionInvalide;
use App\Securite\UtilisateurConnecte;
use DateTimeImmutable;
use IntlDateFormatter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
class Outils
{
// Security : le service de sécurité de Symfony, qui connaît l'utilisateur de
// la requête en cours (celui qu'a établi l'authentificateur)
public function __construct(
private RequestStack $requetes,
private Security $securite,
private ApplicationModel $application,
) {
}
// ----------------------------------------------------------------- dates
// la date, si [$texte] est une date qui existe au format aaaa-mm-jj ; null sinon.
// createFromFormat accepte "2026-02-30" (qui devient le 2 mars) : on vérifie
// donc que la date relue s'écrit exactement comme le texte reçu
public static function dateValide(mixed $texte): ?DateTimeImmutable
{
if (! is_string($texte) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $texte)) {
return null;
}
// "!Y-m-d" : le point d'exclamation met l'heure à minuit
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $texte);
return $date !== false && $date->format('Y-m-d') === $texte ? $date : null;
}
public static function iso(DateTimeImmutable $jour): string
{
return $jour->format('Y-m-d');
}
// "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la langue
// de la requête (cf. exemple 16). Le motif ICU : EEEE le jour de la semaine en
// toutes lettres, d le jour, MMMM le mois en toutes lettres, y l'année
public function formaterJour(DateTimeImmutable $jour): string
{
$langue = $this->requetes->getCurrentRequest()?->getLocale() ?? 'fr';
return (new IntlDateFormatter($langue, IntlDateFormatter::NONE, IntlDateFormatter::NONE,
pattern: 'EEEE d MMMM y'))->format($jour);
}
// ----------------------------------------------------------------- saisie
// "3" -> 3 ; "", "abc", "-2", "+2", null -> null
public static function entierPositif(mixed $valeur): ?int
{
if (! is_string($valeur) || ! preg_match('/^\d{1,9}$/', $valeur)) {
return null;
}
$n = (int) $valeur;
return $n > 0 ? $n : null;
}
// ----------------------------------------------------------------- utilisateur
// l'utilisateur connecté. Les actions protégées ne sont appelées que s'il existe ;
// par précaution, son absence est traitée comme une session expirée
public function utilisateur(): UtilisateurConnecte
{
$utilisateur = $this->securite->getUser();
return $utilisateur instanceof UtilisateurConnecte ? $utilisateur : throw new SessionInvalide();
}
// -----------------------------------------------------------------------
// Le patient (entité Client) qui correspond à l'utilisateur connecté.
//
// Le jeton JWT ne contient que l'id du COMPTE (users.id). Pour un rôle USER, on
// retrouve le client correspondant grâce à la colonne clients.user.
//
// S'il n'y en a pas, c'est que le compte a été supprimé depuis la connexion
// alors que son jeton est encore valide : on le traite comme une session
// expirée (cf. SessionInvalide).
// -----------------------------------------------------------------------
public function patientConnecte(): Client
{
return $this->application->getClientByUserId($this->utilisateur()->userId)
?? throw new SessionInvalide();
}
}
|
Quelques fonctions utilitaires. Les méthodes statiques s'appellent sans objet ([Outils::iso($jour)]) ; les autres ont besoin de services : la classe est donc aussi un service, que les contrôleurs reçoivent par leur constructeur.
- lignes 38-46 : la date d'un paramètre [aaaa-mm-jj], ou [null]. Deux vérifications : la forme (une expression régulière), puis l'existence de la date. [createFromFormat] est tolérante : le 30 février devient le 2 mars. On relit donc la date obtenue, et on vérifie qu'elle s'écrit exactement comme le texte reçu. Le [!] du format met l'heure à minuit (sans lui, l'heure serait celle du moment). [mixed] : n'importe quel type ;
- lignes 56-61 : « samedi 26 septembre 2026 » ou « Saturday 26 September 2026 », selon la langue de la requête. [IntlDateFormatter] (exemple 16), avec un motif plutôt qu'un style : les codes sont ceux de la bibliothèque ICU, [EEEE] le jour de la semaine en toutes lettres, [d] le jour, [MMMM] le mois en toutes lettres, [y] l'année. Le paramètre [pattern] est passé par son nom : les paramètres qui le précèdent dans la signature, et que l'on ne donne pas, gardent leur valeur par défaut ;
- lignes 66-73 : un paramètre de requête est une chaîne, ou [null], ou même un tableau ([?idMedecin[]=1], exemple 10). On n'accepte que des chiffres (neuf au plus : pas de dépassement de capacité), et un nombre strictement positif ;
- lignes 79-83 : l'utilisateur connecté, établi par l'authentificateur, que rend le service [Security] de [Symfony]. Les actions protégées ne sont appelées que pour un utilisateur authentifié, mais on traite quand même son absence, comme une session expirée. [throw] est une expression ([PHP] 8) : il peut figurer dans l'opérateur [? :] ;
- lignes 95-99 : le patient (le [Client]) de l'utilisateur connecté. Le jeton ne contient que l'id du compte ; le client est retrouvé par la colonne [clients.user]. S'il n'existe pas, le compte a été supprimé depuis la connexion : [SessionInvalide].
11.15.5. src/Twig/VuesExtension.php
| <?php
// =========================================================================
// src/Twig/VuesExtension.php : une extension de Twig (cf. exemple 16), qui ajoute
// aux vues les fonctions dont TOUTES les pages ont besoin :
// - utilisateur() : l'utilisateur connecté (ou null), pour la barre de navigation ;
// - titres() : les titres possibles d'une personne (Mr, Mme, Melle).
// =========================================================================
namespace App\Twig;
use App\Form\ChampsPersonneType;
use App\Securite\AuthService;
use App\Securite\UtilisateurConnecte;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class VuesExtension extends AbstractExtension
{
public function __construct(
private Security $securite,
private RequestStack $requetes,
private AuthService $auth,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('utilisateur', $this->utilisateur(...)),
new TwigFunction('titres', fn () => ChampsPersonneType::TITRES),
];
}
// -----------------------------------------------------------------------
// l'utilisateur établi par le pare-feu. Une page d'erreur 404 ou 405 est
// fabriquée AVANT le pare-feu (le routage, qui échoue, passe avant lui, cf.
// exemple 24) : l'utilisateur est alors lu directement dans le jeton, pour que
// la barre de navigation de la page d'erreur soit complète
// -----------------------------------------------------------------------
public function utilisateur(): ?UtilisateurConnecte
{
$utilisateur = $this->securite->getUser();
if ($utilisateur instanceof UtilisateurConnecte) {
return $utilisateur;
}
$jeton = $this->requetes->getMainRequest()?->cookies->getString(AuthService::COOKIE);
return $jeton ? $this->auth->utilisateur($jeton) : null;
}
}
|
Une extension de [Twig] (exemple 16), qui ajoute aux vues deux fonctions dont toutes les pages ont besoin :
- [utilisateur()] : l'utilisateur connecté, ou [null], pour la barre de navigation. D'ordinaire, c'est celui du service [Security] (ligne 44). Mais une page d'erreur 404 ou 405 est fabriquée avant que le pare-feu ait examiné la requête : le routage, qui échoue, passe avant lui (exemple 24). L'utilisateur est alors lu directement dans le jeton (lignes 48-49), pour que la barre de navigation de la page d'erreur soit complète. [getMainRequest] rend la requête principale ; l'opérateur [?->] rend [null] sans appeler la méthode si l'objet est [null] ;
- [titres()] : les titres possibles d'une personne ([Mr], [Mme], [Melle]), une constante de la classe des formulaires de personnes (plus loin).
[$this->utilisateur(...)] (ligne 31) est la syntaxe des fonctions de première classe ([PHP] 8.1) : elle fabrique une fonction qui appelle la méthode [utilisateur] de cet objet. L'extension est déclarée à [Twig] automatiquement, comme celle de l'exemple 16 : le conteneur reconnaît une classe qui hérite d'[AbstractExtension].
11.16. Les dictionnaires
Les textes de l'interface sont ceux des autres versions de l'application, qui les rangeaient dans des fichiers JSON imbriqués :
| {
"LOGIN": {
"TITLE": "Connexion",
"USERNAME": "Login"
}
}
|
La version [Flask] les « aplatissait » au démarrage : [LOGIN.TITLE] désignait le texte [Connexion]. Les dictionnaires YAML de [Symfony] font de même (exemple 22) : les clés imbriquées sont jointes par des points. Nous avons donc simplement converti les fichiers JSON en YAML, et remplacé les paramètres [{{nom}}] des autres versions par ceux de la syntaxe ICU, [{nom}] (le suffixe [+intl-icu] du nom des fichiers, exemple 22) :
| APP:
TITLE: 'RdvMedecins'
LOGOUT: 'Se déconnecter'
MEDECINS:
ADDED: 'Le médecin {nom} a été ajouté (login : {login}).'
ERRORS:
TOO_MANY_ATTEMPTS: 'Trop de tentatives infructueuses. Réessayez dans {minutes, plural, one {# minute} other {# minutes}}.'
|
Les fichiers [translations/messages+intl-icu.fr.yaml] et [messages+intl-icu.en.yaml] ne sont pas reproduits ici. Une clé est traduite par [{{ 'APP.LOGOUT'|trans }}] dans une vue, un texte à paramètres par [{{ 'MEDECINS.ADDED'|trans({nom: ..., login: ...}) }}]. Une clé absente du dictionnaire est affichée telle quelle, ce qui signale immédiatement un oubli (et la commande [php bin/console debug:translation fr] liste les clés utilisées par les vues et absentes du dictionnaire). ICU permet ce que les autres versions ne savaient pas faire : le pluriel (« 1 minute », « 15 minutes »), avec le format [plural] (exemple 22). Un texte a été ajouté : [ERRORS.PAGE_EXPIRED], le message de la page 419, que les autres versions n'ont pas.
Les messages d'erreur des formulaires sont aussi des clés de ces dictionnaires ([ERRORS.FIRSTNAME_REQUIRED]...) : le composant de validation les traduit dans le domaine [messages], grâce à la ligne [translation_domain: messages] de [framework.yaml]. Leurs paramètres sont ceux des contraintes : [{limit}], pour la longueur minimale d'un mot de passe.
Les formulaires « personne » sont des formulaires du composant Form (exemples 18 et 19) : une classe de type décrit les champs et leurs contraintes ; l'action fabrique le formulaire ([createForm]), lui confie la requête ([handleRequest]), puis lui demande s'il a été envoyé et s'il est valide ([isSubmitted], [isValid]). Un formulaire invalide est réaffiché, avec la saisie de l'utilisateur et les messages d'erreur sous les champs (sans les mots de passe : un champ [PasswordType] n'est jamais réaffiché). Le jeton anti-CSRF de chaque formulaire est vérifié par le composant lui-même.
| <?php
// =========================================================================
// src/Form/ChampsPersonneType.php : les champs des formulaires "personne" et leurs
// contraintes (cf. exemples 18 et 19). Trois formulaires partagent les mêmes champs
// d'identité (titre, prénom, nom) :
//
// ChampsPersonneType titre, prénom, nom
// ├─ PersonneType + login / mot de passe FACULTATIFS, version
// │ (l'ADMIN ajoute ou modifie un médecin / client)
// ├─ InscriptionType + login / mot de passe / confirmation OBLIGATOIRES
// │ (un patient crée son compte)
// └─ MonCompteType + login, nouveau mot de passe facultatif, mot de
// passe ACTUEL obligatoire, version
// (un patient modifie son compte)
//
// Les classes filles HÉRITENT de celle-ci (au sens de PHP) : leur buildForm appelle
// d'abord celui de la mère, puis ajoute ses champs.
//
// Les formulaires n'ont pas de classe de données (data_class, cf. exemple 19) : la
// saisie validée est un tableau associatif, que la méthode donnees() transforme en
// DonneesPersonne pour la couche métier.
//
// Les libellés, les aides et les messages d'erreur sont des CLÉS des
// dictionnaires : le composant Form traduit les libellés et les aides, le
// composant Validator les messages (cf. config/packages/framework.yaml).
// Sequentially : les contraintes sont vérifiées dans l'ordre, et la vérification
// s'arrête à la première qui n'est pas respectée - un seul message par champ.
// Les longueurs maximales sont celles des colonnes SQL.
// =========================================================================
namespace App\Form;
use App\Metier\DonneesPersonne;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
abstract class ChampsPersonneType extends AbstractType
{
public const TITRES = ['Mr', 'Mme', 'Melle'];
// login : lettres minuscules, chiffres, point, tiret, souligné - 30 caractères au plus
public const FORMAT_LOGIN = '/^[a-z0-9._-]{1,30}$/';
// longueur minimale d'un mot de passe CHOISI par un patient
public const MOT_DE_PASSE_MIN = 6;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// array_combine : ['Mr' => 'Mr', ...], le libellé et la valeur de chaque
// option ; choice_translation_domain false : les titres ne sont pas traduits
->add('titre', ChoiceType::class, [
'label' => 'PERSONNES.TITLE_LABEL',
'choices' => array_combine(self::TITRES, self::TITRES),
'choice_translation_domain' => false,
'invalid_message' => 'ERRORS.INVALID_TITLE',
'constraints' => [new Assert\NotBlank(message: 'ERRORS.INVALID_TITLE')],
])
->add('prenom', TextType::class, [
'label' => 'PERSONNES.FIRSTNAME_LABEL',
'attr' => ['maxlength' => 20],
'constraints' => [new Assert\Sequentially([
new Assert\NotBlank(message: 'ERRORS.FIRSTNAME_REQUIRED'),
new Assert\Length(max: 20, maxMessage: 'ERRORS.FIRSTNAME_TOO_LONG'),
])],
])
->add('nom', TextType::class, [
'label' => 'PERSONNES.LASTNAME_LABEL',
'attr' => ['maxlength' => 20],
'constraints' => [new Assert\Sequentially([
new Assert\NotBlank(message: 'ERRORS.LASTNAME_REQUIRED'),
new Assert\Length(max: 20, maxMessage: 'ERRORS.LASTNAME_TOO_LONG'),
])],
]);
}
// les options communes : un formulaire invalide renvoyé tel quel au navigateur,
// sans validation par le navigateur lui-même (cf. exemple 19 : c'est le SERVEUR
// qui fait foi) ; le message d'un jeton anti-CSRF faux
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'attr' => ['novalidate' => 'novalidate'],
'csrf_message' => 'ERRORS.PAGE_EXPIRED',
]);
}
// -----------------------------------------------------------------------
// le champ du login, commun aux formulaires filles. Un login est toujours en
// minuscules : un TRANSFORMATEUR (CallbackTransformer) convertit la saisie AVANT
// sa validation. Sa première fonction transforme la valeur du formulaire en
// texte affiché (ici, rien à faire), la seconde la saisie en valeur du formulaire
// -----------------------------------------------------------------------
protected static function ajouterLogin(FormBuilderInterface $builder, array $options): void
{
$builder->add('login', TextType::class, ['label' => 'PERSONNES.LOGIN_LABEL', 'required' => false,
'attr' => ['maxlength' => 30, 'autocomplete' => 'off'], ...$options]);
$builder->get('login')->addModelTransformer(new CallbackTransformer(
fn (?string $login) => $login,
fn (?string $login) => $login === null ? null : mb_strtolower($login)));
}
// la saisie validée -> les données attendues par la couche métier. Les champs
// vides valent null (le composant Form les convertit ainsi)
public static function donnees(array $saisie): DonneesPersonne
{
$motDePasse = $saisie['password'] ?? null;
return new DonneesPersonne($saisie['titre'], $saisie['nom'], $saisie['prenom'],
$saisie['login'] ?? null, $motDePasse === '' ? null : $motDePasse,
isset($saisie['version']) && ctype_digit((string) $saisie['version']) ? (int) $saisie['version'] : null);
}
}
|
Trois formulaires partagent les champs d'identité (titre, prénom, nom) : celui de l'administrateur (un médecin ou un client), la création d'un compte client, et « Gérer mon compte ». Leur classe mère commune est abstraite ; chacune de ses filles complète ses champs. Aucun de ces formulaires n'a de classe de données (l'option [data_class] de l'exemple 19) : ses données sont un tableau associatif, un élément par champ.
- lignes 44-50 : les règles de saisie, sous forme de constantes, lues aussi par les vues (la liste des titres, par la fonction [titres()] de l'extension [Twig]) ;
- lignes 57-63 : le titre, une liste ([ChoiceType], exemple 18). [array_combine] fabrique le tableau [['Mr' => 'Mr', ...]] : le libellé de chaque option, et sa valeur. [choice_translation_domain: false] : les titres ne sont pas des clés de traduction. [invalid_message] : le message d'une valeur qui ne fait pas partie de la liste (une requête fabriquée à la main) ;
- lignes 64-79 : le prénom et le nom. La contrainte [Sequentially] (exemple 09) vérifie ses contraintes dans l'ordre, et s'arrête à la première qui n'est pas respectée : un seul message par champ. [Length(max: 20)] : 20 caractères au plus, la longueur des colonnes SQL ; l'attribut HTML [maxlength] empêche d'en taper davantage. Les espaces au début et à la fin d'une saisie sont retirés par le composant Form lui-même (l'option [trim] d'un champ texte vaut [true]), et une saisie vide devient [null] ; un champ [PasswordType], lui, n'est pas nettoyé : des espaces peuvent faire partie d'un mot de passe ;
- lignes 85-91 : les options communes. [novalidate] désactive les contrôles du navigateur ([required], [maxlength]...) à l'envoi du formulaire : c'est le serveur qui valide, et c'est lui qui fait foi (exemple 19). [csrf_message] : le message d'un jeton anti-CSRF faux, celui de la page 419 ;
- lignes 99-106 : le login, commun aux trois filles, qui lui donnent chacune ses options (l'opérateur [...] ajoute les éléments d'un tableau à un autre ; une clé présente dans les deux prend la dernière valeur). Un login est toujours en minuscules : un transformateur convertit la saisie avant sa validation. Un transformateur de modèle ([addModelTransformer]) a deux fonctions : la première transforme la donnée du formulaire en valeur affichée (ici, rien à faire), la seconde la valeur saisie en donnée du formulaire. [CallbackTransformer] fabrique un transformateur à partir de ces deux fonctions ;
- lignes 110-116 : la saisie validée, transformée en [DonneesPersonne] pour la couche métier : la couche métier ne connaît pas les formulaires. Un mot de passe vide vaut [null] (inchangé, ou la valeur par défaut) ; la version (le champ caché du verrou optimiste, exemple 28) est un entier, ou [null].
| <?php
// src/Form/PersonneType.php : le formulaire d'un médecin ou d'un client (ADMIN) :
// login et mot de passe FACULTATIFS (cf. src/Metier/DonneesPersonne.php).
namespace App\Form;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class PersonneType extends ChampsPersonneType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
parent::buildForm($builder, $options);
$creation = $options['creation'];
// un champ vide est accepté : les contraintes ne s'appliquent qu'à une valeur saisie
self::ajouterLogin($builder, [
'help' => $creation ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT',
'constraints' => [new Assert\Regex(self::FORMAT_LOGIN, message: 'ERRORS.INVALID_LOGIN')],
]);
$builder->add('password', PasswordType::class, [
'label' => 'PERSONNES.PASSWORD_LABEL',
'required' => false,
'help' => $creation ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT',
'attr' => ['maxlength' => 72, 'autocomplete' => 'new-password'],
// 72 caractères au plus : au-delà, bcrypt ignore la suite
'constraints' => [new Assert\Length(max: 72, maxMessage: 'ERRORS.PASSWORD_TOO_LONG')],
]);
// modification : la version de la fiche affichée, dans un champ caché (le
// verrou optimiste)
if (! $creation) {
$builder->add('version', HiddenType::class);
}
}
// une option à nous (cf. exemple 28) : formulaire de création, ou de modification
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
$resolver->setDefaults(['creation' => true]);
}
}
|
Le formulaire de l'administrateur. [parent::buildForm] (ligne 17) ajoute les champs de la classe mère ; viennent ensuite le login et le mot de passe, facultatifs : une contrainte ne s'applique qu'à une valeur saisie (sauf [NotBlank]), un champ vide est donc accepté. [Regex] vérifie la forme du login ; [Length(max: 72)] limite la longueur d'un mot de passe : [bcrypt] n'en prend en compte que les 72 premiers octets (exemple 25). L'option [creation] (lignes 40-44), une option de notre invention, déclarée avec sa valeur par défaut (exemple 28), distingue la création de la modification : les aides diffèrent (« par défaut, le nom en minuscules », « laisser vide pour ne pas le changer »), et seule la modification a le champ caché de la version.
| <?php
// src/Form/InscriptionType.php : "Créer un compte client" : login, mot de passe et
// confirmation OBLIGATOIRES.
namespace App\Form;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
class InscriptionType extends ChampsPersonneType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
parent::buildForm($builder, $options);
self::ajouterLogin($builder, [
'required' => true,
'help' => 'INSCRIPTION.LOGIN_HELP',
'attr' => ['maxlength' => 30, 'autocomplete' => 'username'],
'constraints' => [new Assert\Sequentially([
new Assert\NotBlank(message: 'ERRORS.LOGIN_REQUIRED'),
new Assert\Regex(self::FORMAT_LOGIN, message: 'ERRORS.INVALID_LOGIN'),
])],
]);
$builder->add('password', RepeatedType::class, self::motDePasseRepete(true));
}
// -----------------------------------------------------------------------
// le mot de passe et sa confirmation : un champ RÉPÉTÉ (cf. exemple 19), dont
// les deux saisies doivent être identiques (sinon : invalid_message). Commun à
// l'inscription (obligatoire) et au compte du patient (facultatif).
// {limit} : dans le message PASSWORD_TOO_SHORT, la longueur minimale ;
// help_translation_parameters : les paramètres du texte d'aide
// -----------------------------------------------------------------------
public static function motDePasseRepete(bool $obligatoire): array
{
$longueur = new Assert\Length(min: self::MOT_DE_PASSE_MIN, max: 72,
minMessage: 'ERRORS.PASSWORD_TOO_SHORT', maxMessage: 'ERRORS.PASSWORD_TOO_LONG');
return [
'type' => PasswordType::class,
'required' => $obligatoire,
'invalid_message' => 'ERRORS.PASSWORDS_DIFFER',
'first_options' => [
'label' => $obligatoire ? 'PERSONNES.PASSWORD_LABEL' : 'MON_COMPTE.NEW_PASSWORD_LABEL',
'help' => $obligatoire ? 'INSCRIPTION.PASSWORD_HELP' : 'PERSONNES.PASSWORD_HELP_EDIT',
'help_translation_parameters' => ['min' => self::MOT_DE_PASSE_MIN],
],
'second_options' => ['label' => 'COMPTE.CONFIRMATION_LABEL'],
'options' => ['attr' => ['maxlength' => 72, 'autocomplete' => 'new-password']],
'constraints' => $obligatoire
? [new Assert\Sequentially([new Assert\NotBlank(message: 'ERRORS.PASSWORD_REQUIRED'), $longueur])]
: [$longueur],
];
}
}
|
La création d'un compte client : login et mot de passe obligatoires, et confirmation du mot de passe. Le mot de passe est un champ répété ([RepeatedType], exemple 19) : deux champs [PasswordType], dont les saisies doivent être identiques (sinon, le message [invalid_message], affiché sous le premier). Ses options sont fabriquées par une méthode statique, partagée avec « Gérer mon compte » (lignes 36-55) :
- [first_options] et [second_options] : les options de chacun des deux champs ; [options] : celles qui leur sont communes ;
- [help_translation_parameters] : les paramètres du texte d'aide, « Au moins {min} caractères. » ;
- la longueur minimale n'est exigée que du mot de passe choisi par un patient (6 caractères). Le message [ERRORS.PASSWORD_TOO_SHORT] contient [{limit}], le paramètre que la contrainte [Length] fournit à son message ;
- [autocomplete="new-password"] : le navigateur propose de générer un mot de passe, plutôt que de remplir le champ avec le mot de passe enregistré.
| <?php
// src/Form/MonCompteType.php : "Gérer mon compte" (un patient) : login obligatoire,
// nouveau mot de passe facultatif, mot de passe ACTUEL obligatoire.
namespace App\Form;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
class MonCompteType extends ChampsPersonneType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
parent::buildForm($builder, $options);
self::ajouterLogin($builder, [
'required' => true,
'attr' => ['maxlength' => 30, 'autocomplete' => 'username'],
'constraints' => [new Assert\Sequentially([
new Assert\NotBlank(message: 'ERRORS.LOGIN_REQUIRED'),
new Assert\Regex(self::FORMAT_LOGIN, message: 'ERRORS.INVALID_LOGIN'),
])],
]);
// facultatif : vide = mot de passe inchangé
$builder->add('password', RepeatedType::class, InscriptionType::motDePasseRepete(false));
// le mot de passe ACTUEL, exigé pour toute modification du compte ; mapped
// false : il ne fait pas partie des données du formulaire, on le lit à part
$builder->add('current_password', PasswordType::class, [
'label' => 'MON_COMPTE.CURRENT_PASSWORD_LABEL',
'help' => 'MON_COMPTE.CURRENT_PASSWORD_HELP',
'mapped' => false,
'attr' => ['maxlength' => 72, 'autocomplete' => 'current-password'],
'constraints' => [new Assert\NotBlank(message: 'ERRORS.CURRENT_PASSWORD_REQUIRED')],
]);
$builder->add('version', HiddenType::class);
}
}
|
« Gérer mon compte » : le nouveau mot de passe est facultatif (vide : inchangé), le mot de passe actuel est obligatoire. [mapped: false] (ligne 33) : le mot de passe actuel ne fait pas partie des données du formulaire ([getData()] ne le contient pas) ; l'action le lit à part, par [$formulaire->get('current_password')->getData()]. Il n'est pas vérifié ici, mais par l'action : un mot de passe faux doit être compté par le limiteur de tentatives, comme un échec de connexion. ([Symfony] a une contrainte [UserPassword], qui compare la saisie au mot de passe de l'utilisateur connecté ; elle ne convient pas ici : notre utilisateur connecté, [UtilisateurConnecte], ne contient pas le hachage de son mot de passe.)
11.18. Les modèles des vues
Le dossier [src/Vues] contient les modèles de vue : des objets construits par une action, que la vue se contente de lire. La règle, suivie par toutes les pages : pas de logique dans les vues. Les actions préparent des données déjà mises en forme (« Mme Marie PELISSIER », « 8:00 - 8:20 ») et déjà filtrées selon le rôle de l'utilisateur ; les vues les placent dans le HTML. Toutes ces classes sont [readonly], et n'ont que des propriétés, promues par leur constructeur. Le dossier est exclu des services ([config/services.yaml]) : ce sont des données, pas des services.
| <?php
// src/Vues/OptionVue.php : une option d'une liste déroulante (médecin, patient).
//
// Le dossier src/Vues contient les "modèles de vue" (view models) : des objets
// construits en une fois par une action, que la vue se contente de lire. Règle
// suivie par toutes les pages : pas de logique dans les vues. Les actions
// préparent des données DÉJÀ mises en forme ("Mme Marie PELISSIER",
// "8:00 - 8:20", selectionne = true...) et déjà filtrées selon le rôle de
// l'utilisateur ; les vues se contentent de les placer dans le HTML.
namespace App\Vues;
readonly class OptionVue
{
public function __construct(
public int $id,
public string $libelle,
public bool $selectionne,
) {
}
}
|
Une option d'une liste déroulante : l'id, le libellé, et l'option est-elle choisie ?
| <?php
// src/Vues/LigneAgenda.php : une ligne de l'agenda affiché (un créneau).
namespace App\Vues;
readonly class LigneAgenda
{
public function __construct(
public int $idCreneau,
public string $horaire, // "8:00 - 8:20"
public bool $libre,
// nom affiché pour un créneau occupé ; null = « Réservé »
public ?string $patient,
public bool $moi, // le rendez-vous du patient connecté
public bool $reservable, // afficher le bouton "Réserver" ?
public string $urlReserver,
// renseigné seulement si le bouton "Annuler" est affiché
public ?int $idRv,
) {
}
}
|
Une ligne de l'agenda : un créneau, et ce que l'utilisateur connecté a le droit d'en voir et d'en faire. [patient] n'est renseigné que si l'utilisateur a le droit de connaître le nom du patient ; [reservable] dit s'il faut afficher le bouton Réserver ; [idRv], le bouton Annuler.
| <?php
// src/Vues/AgendaVue.php : l'agenda affiché : le médecin, le jour, les lignes.
namespace App\Vues;
readonly class AgendaVue
{
/** @param list<LigneAgenda> $lignes */
public function __construct(
public string $medecin, // "Mme Marie PELISSIER"
public string $jour, // "mercredi 23 septembre 2026"
public array $lignes,
) {
}
}
|
| <?php
// src/Vues/ReservationVue.php : la fenêtre de réservation d'un créneau.
namespace App\Vues;
readonly class ReservationVue
{
/** @param list<OptionVue> $clients */
public function __construct(
public int $idCreneau,
public string $horaire,
// ADMIN / DOCTOR : les patients parmi lesquels choisir
public array $clients,
// USER : le patient, c'est lui-même
public ?string $patient,
) {
}
}
|
L'agenda affiché, et la fenêtre de réservation : pour l'administrateur et les médecins, la liste des patients parmi lesquels choisir ; pour un patient, son propre nom. Le commentaire [/** @param list<LigneAgenda> $lignes */] précise, pour les éditeurs et les outils d'analyse, le contenu du tableau : [PHP] ne sait déclarer que le type [array].
| <?php
// src/Vues/ClesPersonnes.php : les clés de traduction propres à chaque type de
// personne (médecins ou clients), pour les vues communes personnes/liste et
// personnes/formulaire.
namespace App\Vues;
readonly class ClesPersonnes
{
public function __construct(
public string $titre, // "Médecins"
public string $ajouter, // "Ajouter un médecin"
public string $modifier, // "Modifier un médecin"
public string $vide, // "Aucun médecin."
public string $confirmerSuppression, // "Supprimer ce médecin... ?"
public string $ajoute, // "Le médecin {nom} a été ajouté (login : {login})."
public string $modifie,
public string $supprime,
) {
}
// les huit clés d'une section du dictionnaire ("MEDECINS", "CLIENTS")
public static function de(string $section): self
{
return new self("$section.TITLE", "$section.ADD", "$section.EDIT", "$section.EMPTY",
"$section.DELETE_CONFIRM", "$section.ADDED", "$section.UPDATED", "$section.DELETED");
}
}
|
Les clés de traduction propres aux médecins ou aux clients, pour les deux vues qui leur sont communes. La méthode statique [de] (lignes 23-27) fabrique les huit clés d'une section des dictionnaires : [ClesPersonnes::de('MEDECINS')] donne [MEDECINS.TITLE], [MEDECINS.ADD]... [new self(...)] crée un objet de la classe elle-même.
| <?php
// src/Vues/RendezVousVue.php : un rendez-vous à venir, dans l'espace du patient.
namespace App\Vues;
readonly class RendezVousVue
{
public function __construct(
public int $id,
public string $jour, // "mercredi 23 septembre 2026"
public string $horaire, // "8:00 - 8:20"
public string $medecin, // "Mme Marie PELISSIER"
public string $urlAgenda, // l'agenda du médecin ce jour-là
) {
}
}
|
Un rendez-vous à venir, dans l'espace du patient.
11.19. La couche web : les routes et les contrôleurs
11.19.1. Les routes
Les routes sont décrites par les attributs [#[Route]] des contrôleurs (exemple 02), que charge le fichier [config/routes.yaml]. La commande [debug:router] les liste toutes, avec leur nom, leurs méthodes et leur chemin :
| $ php bin/console debug:router
----------------------- ---------- --------------------------
Name Method Path
----------------------- ---------- --------------------------
agenda GET /agenda
agenda_reserver POST /agenda/reserver
agenda_annuler POST /agenda/annuler
accueil GET /
login GET /login
connexion POST /login
logout POST /logout
clients_liste GET /clients
clients_ajout GET|POST /clients/ajouter
clients_modification GET|POST /clients/{id}/modifier
clients_supprimer POST /clients/{id}/supprimer
espace GET /mon-espace
espace_annuler POST /mon-espace/annuler
compte GET|POST /mon-compte
compte_supprimer POST /mon-compte/supprimer
inscription_acces POST /inscription/acces
inscription GET|POST /inscription
langue GET /langue/{code}
medecins_liste GET /medecins
medecins_ajout GET|POST /medecins/ajouter
medecins_modification GET|POST /medecins/{id}/modifier
medecins_supprimer POST /medecins/{id}/supprimer
----------------------- ---------- --------------------------
|
Les URL sont celles des autres versions de l'application : la modification et la suppression d'un médecin sont des requêtes POST ([/medecins/{id}/modifier], [/medecins/{id}/supprimer]), et non PUT et DELETE comme à l'exemple 28. Le code fabrique toujours les URL par le nom des routes ([redirectToRoute('login')], [path('agenda')] dans les vues, exemple 04) : changer une URL ne demande de modifier que son attribut [#[Route]].
Les règles d'accès ne sont pas dans les contrôleurs, mais dans les règles [access_control] de [config/packages/security.yaml], vues plus haut : une règle par préfixe d'URL ([^/(medecins|clients)] pour l'administrateur, [^/mon-] pour les patients...), et une dernière règle qui exige un utilisateur authentifié pour toutes les autres URL. Les URL de l'application ont été choisies pour cela : toutes les pages d'un même rôle commencent de la même façon. Une URL qui ne correspond à aucune route donne une page 404, même à un visiteur anonyme ; une requête GET vers une URL qui n'existe qu'en POST ([/logout]), une page 405, présentée comme une page introuvable (écouteur [PageErreur]).
11.19.2. src/Controller/LangueController.php
| <?php
// =========================================================================
// src/Controller/LangueController.php : GET /langue/fr, GET /langue/en : les deux
// boutons FR / EN de la barre de navigation. Route publique : on doit pouvoir
// changer de langue avant de se connecter.
//
// Le choix est mémorisé dans un cookie : à chaque requête suivante, l'écouteur
// FixerLangue le relit et fixe la langue de la requête. Puis on revient sur la
// page d'où l'on vient (cf. exemple 22).
// =========================================================================
namespace App\Controller;
use App\EventListener\FixerLangue;
use App\Web\Controleur;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class LangueController extends Controleur
{
public function __construct(#[Autowire(env: 'bool:COOKIE_SECURE')] private bool $cookieSecure)
{
}
// {code} est limité aux langues gérées (requirements) : /langue/de donne une page 404
#[Route('/langue/{code}', name: 'langue', requirements: ['code' => 'fr|en'], methods: ['GET'])]
public function changer(Request $request, string $code): Response
{
$reponse = $this->redirect($this->pageDOrigine($request));
$reponse->headers->setCookie(Cookie::create(FixerLangue::COOKIE, $code)
->withExpires(new \DateTimeImmutable('+1 year'))
->withSecure($this->cookieSecure)
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_LAX));
return $reponse;
}
// -----------------------------------------------------------------------
// la page précédente, d'après l'entête HTTP "Referer" envoyé par le navigateur.
// On n'en garde que le chemin (/agenda?...) : rediriger vers une adresse
// complète fournie par la requête permettrait d'envoyer l'utilisateur sur un
// autre site (faille dite "open redirect").
// -----------------------------------------------------------------------
private function pageDOrigine(Request $request): string
{
$referer = $request->headers->get('Referer');
$url = $referer ? parse_url($referer) : false;
$chemin = $url['path'] ?? '';
// "//site.com/..." serait compris par le navigateur comme une adresse sur un
// AUTRE site : refusé aussi
if (! isset($url['scheme']) || ! str_starts_with($chemin, '/') || str_starts_with($chemin, '//')
|| str_starts_with($chemin, '/langue/')) {
return '/';
}
return isset($url['query']) ? "$chemin?{$url['query']}" : $chemin;
}
}
|
Les boutons FR / EN de la barre de navigation : le choix de la langue est mémorisé dans un cookie pour un an (exemple 22), puis le navigateur revient à la page d'où il vient. Le cookie est [HttpOnly] : le [JavaScript] de la page n'a pas à le lire. Il n'est pas signé : l'écouteur [FixerLangue] vérifie sa valeur, et une valeur inconnue donne le français (chapitre sur les portées).
- ligne 29 : [requirements: ['code' => 'fr|en']] n'accepte, pour le paramètre [{code}], que ces deux valeurs : [/langue/de] ne correspond à aucune route (page 404) ;
- lignes 47-59 : la page précédente est donnée par l'entête HTTP [Referer] que le navigateur envoie avec la requête. C'est une donnée fournie par le client, donc suspecte : rediriger vers une adresse complète venue de la requête permettrait à un pirate d'envoyer l'utilisateur sur son propre site, par un lien vers [/langue/fr] muni d'un faux [Referer] (la faille dite open redirect). On n'en garde donc que le chemin et les paramètres ([/agenda?idMedecin=1&...]), extraits par la fonction [parse_url] de [PHP], qui découpe une URL en ses parties ([scheme], [host], [path], [query]...). Un chemin qui commence par [//] est refusé aussi : un navigateur comprend [//site.com/page] comme une adresse sur un autre site. Enfin, on ne revient pas vers [/langue/...] : une boucle.
11.19.3. src/Controller/AgendaController.php
| <?php
// =========================================================================
// src/Controller/AgendaController.php : la page de l'agenda : choix d'un médecin et
// d'un jour, agenda de ce médecin ce jour-là, réservation et annulation de
// rendez-vous.
//
// GET /agenda?idMedecin=1&jour=2026-09-23 la page
// GET /agenda?idMedecin=1&jour=2026-09-23&reserver=7 la même page, avec la
// fenêtre de réservation
// du créneau 7 ouverte
// POST /agenda/reserver crée un rendez-vous
// POST /agenda/annuler annule un rendez-vous
//
// Toute la page est décrite par son URL : on peut la recharger, la mettre en
// favori, utiliser le bouton "Précédent" du navigateur - sans une ligne de
// JavaScript.
//
// Ce que chacun voit et peut faire :
//
// | rôle | créneau occupé affiché | Réserver | Annuler |
// |---------------|-----------------------------------|--------------------|---------|
// | ADMIN, DOCTOR | nom du patient | pour un patient | oui |
// | | | choisi dans liste | |
// | USER (patient)| « Réservé », ou SON nom si c'est | pour lui-même, s'il| non (*) |
// | | son propre rendez-vous | n'a pas déjà un rdv| |
// | | | à venir | |
//
// (*) le patient annule son rendez-vous depuis sa page "Mon espace".
//
// CONFIDENTIALITÉ : pour un patient, le nom des AUTRES patients n'est pas
// simplement masqué par la vue - il n'est jamais transmis à la vue. Le HTML envoyé
// au navigateur ne le contient donc pas, même caché (un simple "Afficher le code
// source" le révélerait sinon).
//
// Pas de réservation dans le passé : le bouton "Réserver" n'apparaît que pour
// aujourd'hui et les jours suivants (et la couche métier le refuserait de toute
// façon).
//
// Sécurité : toutes les URL /agenda exigent un utilisateur authentifié, et
// /agenda/annuler le rôle ADMIN ou DOCTOR (cf. config/packages/security.yaml).
// =========================================================================
namespace App\Controller;
use App\Domain\AgendaMedecinJour;
use App\Entity\Client;
use App\Entity\Personne;
use App\Entity\Role;
use App\Metier\Dates;
use App\Metier\MetierException;
use App\Vues\AgendaVue;
use App\Vues\LigneAgenda;
use App\Vues\OptionVue;
use App\Vues\ReservationVue;
use App\Web\ApplicationModel;
use App\Web\Controleur;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Outils;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/agenda')]
class AgendaController extends Controleur
{
public function __construct(
private ApplicationModel $application,
private Outils $outils,
private Flash $flash,
) {
}
// -----------------------------------------------------------------------
// GET /agenda
// Les paramètres de l'URL sont lus bruts ($request->query), pour distinguer un
// paramètre ABSENT (null) d'un paramètre VIDE (?idMedecin=).
// -----------------------------------------------------------------------
#[Route('', name: 'agenda', methods: ['GET'])]
public function afficher(Request $request): Response
{
$erreur = null;
$utilisateur = $this->outils->utilisateur();
$droits = $this->droits();
// le jour : celui de l'URL s'il est valide, sinon aujourd'hui
$jour = Dates::aujourdhui();
$jourBrut = $request->query->get('jour');
if ($jourBrut !== null) {
$jourDemande = Outils::dateValide($jourBrut);
if ($jourDemande !== null) {
$jour = $jourDemande;
} else {
$erreur = 'ERRORS.INVALID_DATE';
}
}
$jourReservable = $jour >= Dates::aujourdhui();
// le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
$idMedecinBrut = $request->query->get('idMedecin');
$idMedecin = Outils::entierPositif($idMedecinBrut);
if ($idMedecinBrut === null && $utilisateur->role === Role::DOCTOR) {
$idMedecin = $this->application->getMedecinByUserId($utilisateur->userId)?->getId();
}
// la liste déroulante des médecins
$medecins = array_map(fn (Personne $m) => self::option($m, $m->getId() === $idMedecin),
$this->application->getAllMedecins());
// l'agenda, si un médecin est choisi (et la date valide)
$agenda = null;
if ($idMedecin !== null && $erreur === null) {
$agendaMedecin = $this->application->getAgendaMedecinJour($idMedecin, $jour);
if ($agendaMedecin === null) {
$erreur = 'ERRORS.DOCTOR_NOT_FOUND';
} else {
$agenda = $this->agendaVue($agendaMedecin, $droits, $jourReservable);
}
}
// la fenêtre de réservation : seulement si le créneau demandé est réservable
// par cet utilisateur. array_find : le premier élément qui vérifie la
// condition (null s'il n'y en a pas)
$reservation = null;
$idCreneau = Outils::entierPositif($request->query->get('reserver'));
if ($agenda !== null && $idCreneau !== null) {
$ligne = array_find($agenda->lignes,
fn (LigneAgenda $l) => $l->idCreneau === $idCreneau && $l->reservable);
if ($ligne !== null) {
$reservation = new ReservationVue(
$ligne->idCreneau,
$ligne->horaire,
$droits['gestionnaire']
? array_map(fn (Client $c) => self::option($c, false), $this->application->getAllClients())
: [],
$droits['patient']?->libelle());
}
}
return $this->render('agenda.html.twig', [
'titrePage' => 'AGENDA.PAGE_TITLE',
'erreur' => $erreur ? new Message($erreur) : null,
'medecins' => $medecins,
'idMedecin' => $idMedecin,
'jour' => Outils::iso($jour),
'agenda' => $agenda,
'reservation' => $reservation,
// l'agenda affiché, sans fenêtre de réservation
'urlRetour' => $idMedecin !== null ? $this->urlAgenda($idMedecin, $jour) : $this->generateUrl('agenda'),
'info' => $this->infoPatient($droits, $agenda),
]);
}
// -----------------------------------------------------------------------
// POST /agenda/reserver champs : idMedecin, jour, idCreneau, idClient
//
// Pour un patient (USER), le champ idClient est IGNORÉ : il réserve toujours
// pour lui-même. Faire confiance à un champ envoyé par le navigateur
// permettrait à un patient malin de réserver au nom d'un autre.
//
// Schéma "Post / Redirect / Get" : quel que soit le résultat, on ne fabrique
// pas de page ici - on dépose un message flash et on redirige vers l'agenda
// (cf. exemple 20).
// -----------------------------------------------------------------------
#[Route('/reserver', name: 'agenda_reserver', methods: ['POST'])]
public function reserver(Request $request): Response
{
$this->verifierCsrf($request);
[$idMedecin, $jour] = $this->agendaDemande($request);
$idCreneau = Outils::entierPositif($request->request->get('idCreneau'));
if ($idMedecin === null || $idCreneau === null) {
// -> page "requête invalide" (cf. src/EventListener/PageErreur.php)
throw new BadRequestHttpException('agenda ou créneau invalide');
}
$utilisateur = $this->outils->utilisateur();
if ($utilisateur->estPatient()) {
$client = $this->outils->patientConnecte();
} else {
$idClient = Outils::entierPositif($request->request->get('idClient'));
if ($idClient === null) {
// aucun patient choisi : on rouvre la fenêtre de réservation
$this->flash->erreur('ERRORS.CHOOSE_PATIENT');
return $this->redirect($this->urlAgenda($idMedecin, $jour, $idCreneau));
}
$client = $this->application->getClientById($idClient);
}
$creneau = $this->application->getCreneauById($idCreneau);
if ($client === null) {
$this->flash->erreur('ERRORS.PATIENT_NOT_FOUND');
} elseif ($creneau === null || $creneau->getMedecin()->getId() !== $idMedecin) {
$this->flash->erreur('ERRORS.SLOT_NOT_FOUND');
} else {
try {
// les règles (pas dans le passé, un seul rendez-vous à venir par
// patient, créneau libre) sont vérifiées par la couche métier
$this->application->ajouterRv($jour, $creneau, $client);
$this->flash->succes('AGENDA.BOOKED');
} catch (MetierException $e) {
// même règle, mais un message à la 2e personne quand c'est le
// patient lui-même qui réserve (« Vous avez déjà... » plutôt que
// « Ce patient... »). Les autres exceptions (vraies anomalies) ne
// sont pas attrapées : page 500.
if ($e->cle === 'ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT' && $utilisateur->estPatient()) {
$this->flash->erreur('AGENDA.ALREADY_BOOKED_INFO');
} else {
$this->flash->erreur($e->cle, $e->parametres);
}
}
}
return $this->redirect($this->urlAgenda($idMedecin, $jour));
}
// -----------------------------------------------------------------------
// POST /agenda/annuler champs : idRv, idMedecin, jour
// (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
// -----------------------------------------------------------------------
#[Route('/annuler', name: 'agenda_annuler', methods: ['POST'])]
public function annuler(Request $request): Response
{
$this->verifierCsrf($request);
[$idMedecin, $jour] = $this->agendaDemande($request);
$idRv = Outils::entierPositif($request->request->get('idRv'));
if ($idMedecin === null || $idRv === null) {
throw new BadRequestHttpException('agenda ou rendez-vous invalide');
}
$rv = $this->application->getRvById($idRv);
if ($rv === null) {
// déjà annulé (par quelqu'un d'autre, ou double clic)
$this->flash->erreur('ERRORS.APPOINTMENT_NOT_FOUND');
} else {
$this->application->supprimerRv($rv);
$this->flash->succes('AGENDA.CANCELLED');
}
return $this->redirect($this->urlAgenda($idMedecin, $jour));
}
// -----------------------------------------------------------------------
// qui est connecté, et qu'a-t-il le droit de faire sur cette page ?
// gestionnaire : ADMIN ou DOCTOR ;
// patient : le client connecté (rôle USER), sinon null ;
// patientPeutReserver : un USER sans rendez-vous à venir.
// -----------------------------------------------------------------------
private function droits(): array
{
if (! $this->outils->utilisateur()->estPatient()) {
return ['gestionnaire' => true, 'patient' => null, 'patientPeutReserver' => false];
}
$patient = $this->outils->patientConnecte();
$rvAVenir = $this->application->getRvAVenirClient($patient->getId());
return ['gestionnaire' => false, 'patient' => $patient, 'patientPeutReserver' => $rvAVenir === []];
}
// -----------------------------------------------------------------------
// l'agenda du médecin, mis en forme pour la vue - et filtré selon les droits
// -----------------------------------------------------------------------
private function agendaVue(AgendaMedecinJour $agendaMedecin, array $droits, bool $jourReservable): AgendaVue
{
$idMedecin = $agendaMedecin->medecin->getId();
$jour = $agendaMedecin->jour;
$lignes = [];
foreach ($agendaMedecin->creneauxMedecinJour as $cmj) {
$creneau = $cmj->creneau;
$rv = $cmj->rv;
$libre = $rv === null;
// le rendez-vous du patient connecté ?
$moi = $rv !== null && $droits['patient'] !== null
&& $rv->getClient()->getId() === $droits['patient']->getId();
$lignes[] = new LigneAgenda(
idCreneau: $creneau->getId(),
horaire: $creneau->horaire(),
libre: $libre,
// LE point de confidentialité : un patient ne reçoit le nom que de
// SON rendez-vous ; pour les autres, null (la vue affiche « Réservé »)
patient: $rv !== null && ($droits['gestionnaire'] || $moi) ? $rv->getClient()->libelle() : null,
moi: $moi,
reservable: $libre && $jourReservable && ($droits['gestionnaire'] || $droits['patientPeutReserver']),
urlReserver: $this->urlAgenda($idMedecin, $jour, $creneau->getId()),
idRv: $rv !== null && $droits['gestionnaire'] ? $rv->getId() : null);
}
return new AgendaVue($agendaMedecin->medecin->libelle(), $this->outils->formaterJour($jour), $lignes);
}
// ----------------------------------------------------------------- fonctions utilitaires
// les champs cachés idMedecin / jour des formulaires POST ; [null, null] s'ils
// sont invalides
private function agendaDemande(Request $request): array
{
$idMedecin = Outils::entierPositif($request->request->get('idMedecin'));
$jour = Outils::dateValide($request->request->get('jour'));
return $idMedecin === null || $jour === null ? [null, null] : [$idMedecin, $jour];
}
// /agenda?idMedecin=1&jour=2026-09-23[&reserver=7] : generateUrl ajoute à la fin
// de l'URL les paramètres qui ne sont pas des paramètres du chemin (cf. exemple 16)
private function urlAgenda(int $idMedecin, DateTimeImmutable $jour, ?int $reserver = null): string
{
$parametres = ['idMedecin' => $idMedecin, 'jour' => Outils::iso($jour)];
if ($reserver !== null) {
$parametres['reserver'] = $reserver;
}
return $this->generateUrl('agenda', $parametres);
}
private static function option(Personne $p, bool $selectionne): OptionVue
{
return new OptionVue($p->getId(), $p->libelle(), $selectionne);
}
// -----------------------------------------------------------------------
// le message d'information d'un patient qui a déjà un rendez-vous à venir
// (pourquoi il n'y a pas de bouton "Réserver"), ou null. Inutile si SON
// rendez-vous figure dans l'agenda affiché (en bleu) : c'est le cas juste
// après qu'il vient de le prendre, où le message serait déplacé.
// array_any : un élément au moins vérifie-t-il la condition ?
// -----------------------------------------------------------------------
private function infoPatient(array $droits, ?AgendaVue $agenda): ?string
{
if ($droits['patient'] === null || $droits['patientPeutReserver']) {
return null;
}
$rvAffiche = $agenda !== null && array_any($agenda->lignes, fn (LigneAgenda $l) => $l->moi);
return $rvAffiche ? null : 'AGENDA.ALREADY_BOOKED_INFO';
}
}
|
La page principale de l'application. Le commentaire de tête résume ce que chacun voit et peut faire. L'attribut [#[Route('/agenda')]] de la classe (ligne 66) donne le début du chemin de toutes ses routes (exemple 02) : la route [''] est [/agenda] elle-même.
11.19.3.1. Les URL de la page
Toute la page est décrite par son URL : [/agenda?idMedecin=1&jour=2026-09-26] affiche l'agenda du médecin 1 ce jour-là ; [&reserver=4] ouvre en plus la fenêtre de réservation du créneau 4. On peut recharger la page, la mettre en favori, utiliser le bouton Précédent du navigateur, sans une ligne de [JavaScript]. Le formulaire du haut de la page (le choix du médecin et du jour) est un formulaire GET (exemple 17) : il recharge la page avec de nouveaux paramètres. La fenêtre de réservation est ouverte par un simple lien, et fermée par un lien vers la même page sans le paramètre [reserver].
11.19.3.2. GET /agenda
Lignes 81-154 :
- les paramètres de l'URL sont lus bruts ([$request->query->get('jour')]), sans validation par [Symfony] : un paramètre absent ([null]) n'est pas une erreur (on prend aujourd'hui, ou aucun médecin) ; une date invalide en est une, affichée en bas de la page (le choix du médecin et du jour reste affiché, mais pas l'agenda) ; un numéro de médecin ou de créneau qui n'est pas un entier positif est simplement ignoré ;
- un médecin connecté qui n'a pas choisi de médecin voit son agenda : son médecin est cherché par son compte ([getMedecinByUserId]). [?->getId()] : l'opérateur nullsafe rend [null] si l'objet est [null], au lieu de lever une erreur ;
- la liste déroulante des médecins : [array_map] transforme chaque médecin en [OptionVue] ;
- l'agenda, mis en forme et filtré selon les droits de l'utilisateur ([agendaVue], plus bas) ;
- la fenêtre de réservation, seulement si le créneau demandé est réservable par cet utilisateur : un patient qui fabriquerait lui-même l'URL [&reserver=2] d'un créneau occupé n'obtiendrait rien. [array_find] (exemple 06) rend le premier élément d'un tableau qui vérifie une condition, ou [null]. La liste des patients n'est fournie qu'à l'administrateur et aux médecins ; un patient ne reçoit que son propre nom ;
- la vue reçoit ses variables ([render], exemple 13), dont [titrePage] et [erreur], lues par le gabarit.
11.19.3.3. POST /agenda/reserver
Lignes 167-215 :
- le jeton anti-CSRF, puis les champs cachés du formulaire de réservation (le médecin, le jour, le créneau). Invalides, ce ne peut être qu'une requête fabriquée à la main : [BadRequestHttpException], la page « requête invalide » (400) ;
- pour un patient, le champ [idClient] est ignoré : il réserve toujours pour lui-même ([Outils::patientConnecte]). Faire confiance à un champ envoyé par le navigateur permettrait à un patient malin de réserver au nom d'un autre. Pour l'administrateur et les médecins, un patient doit avoir été choisi ; sinon, la fenêtre de réservation est rouverte, avec un message ;
- le créneau doit appartenir au médecin de la page ;
- les règles de gestion (pas dans le passé, un seul rendez-vous à venir, créneau libre) sont vérifiées par la couche métier. Une [MetierException] devient un message flash d'erreur ; ses propriétés publiques [cle] et [parametres] donnent le message. Le même refus, « un rendez-vous à venir », est formulé à la deuxième personne pour le patient lui-même (« Vous avez déjà... »). Toute autre exception n'est pas interceptée : c'est une vraie anomalie (page 500) ;
- dans tous les cas, schéma Post / Redirect / Get (exemple 20) : un message flash, et une redirection vers l'agenda.
11.19.3.4. POST /agenda/annuler
Lignes 221-239 : l'annulation d'un rendez-vous, par l'administrateur ou un médecin (la règle [^/agenda/annuler$] de [security.yaml]). Un rendez-vous qui n'existe plus (déjà annulé par quelqu'un d'autre, ou un double clic) n'est pas une anomalie : un simple message.
- lignes 247-255 : ce que l'utilisateur a le droit de faire sur la page, dans un tableau associatif : est-il un gestionnaire (ADMIN ou DOCTOR) ? un patient (et lequel) ? ce patient peut-il réserver (pas de rendez-vous à venir) ?
- lignes 260-285 : l'agenda du médecin, transformé en lignes pour la vue. Le point de confidentialité est la ligne 278 : un patient ne reçoit le nom que de son rendez-vous. Pour les autres, la propriété [patient] vaut [null], et la vue affiche « Réservé ». Le nom n'est pas masqué par la vue : il n'est jamais transmis à la vue, et ne figure donc pas dans le code source de la page, où il suffirait d'aller le lire. Le bouton Réserver est affiché pour un créneau libre, un jour qui n'est pas passé, à un gestionnaire ou à un patient qui peut réserver ; le bouton Annuler, aux gestionnaires seulement. Les arguments nommés ([idCreneau: $creneau->getId()]) rendent lisible la construction d'un objet qui a huit propriétés ;
- lignes 291-296 : les champs cachés des formulaires POST. [[$idMedecin, $jour] = ...] (lignes 171 et 225) déstructure le tableau rendu en deux variables ;
- lignes 300-307 : l'URL de l'agenda d'un médecin un jour donné. [generateUrl('agenda', [...])] ajoute à la fin de l'URL, en paramètres de requête, les valeurs qui ne sont pas des paramètres du chemin : [/agenda?idMedecin=1&jour=2026-09-26] ;
- lignes 321-328 : le message qui explique à un patient pourquoi il n'a pas de bouton Réserver (il a déjà un rendez-vous à venir), sauf si ce rendez-vous est dans l'agenda affiché (juste après l'avoir pris, le message serait déplacé). [array_any] ([PHP] 8.4) : un élément au moins vérifie-t-il la condition ?
11.19.4. src/Controller/PersonnesController.php
| <?php
// =========================================================================
// src/Controller/PersonnesController.php : la gestion des médecins et celle des
// clients (réservées à l'ADMIN) sont IDENTIQUES : même liste, même formulaire,
// mêmes règles. Plutôt que d'écrire deux fois les mêmes actions, on les écrit une
// fois dans cette classe ABSTRAITE ; MedecinsController et ClientsController en
// héritent, et ne précisent que ce qui les distingue : le nom de la section
// (medecins ou clients), les clés de traduction, et les méthodes de
// l'ApplicationModel à appeler.
//
// Les attributs #[Route] des actions sont hérités eux aussi : l'attribut #[Route]
// de CHAQUE classe fille leur donne le début de leur chemin (/medecins ou
// /clients) et de leur nom (medecins_ ou clients_). On obtient ainsi, pour chacune :
//
// GET /medecins liste medecins_liste
// GET /medecins/ajouter formulaire vide medecins_ajout
// POST /medecins/ajouter création medecins_ajout
// GET /medecins/{id}/modifier formulaire pré-rempli medecins_modification
// POST /medecins/{id}/modifier modification medecins_modification
// POST /medecins/{id}/supprimer suppression medecins_supprimer
//
// (et les mêmes URL sous /clients). Les vues sont elles aussi communes :
// templates/personnes/liste.html.twig et personnes/formulaire.html.twig.
//
// SÉCURITÉ : la règle access_control ^/(medecins|clients) de
// config/packages/security.yaml réserve toutes ces URL à l'ADMIN - y compris celles
// qu'on ajouterait plus tard.
// =========================================================================
namespace App\Controller;
use App\Entity\Personne;
use App\Form\ChampsPersonneType;
use App\Form\PersonneType;
use App\Metier\DonneesPersonne;
use App\Metier\MetierException;
use App\Vues\ClesPersonnes;
use App\Web\ApplicationModel;
use App\Web\Controleur;
use App\Web\Flash;
use App\Web\Message;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
abstract class PersonnesController extends Controleur
{
// les champs du formulaire concernés par chaque erreur métier "de saisie" (un
// doublon) : ils sont encadrés en rouge, le message étant en bas de page
public const CHAMPS_ERREURS_METIER = [
'ERRORS.LOGIN_TAKEN' => ['login'],
'ERRORS.DOCTOR_ALREADY_EXISTS' => ['prenom', 'nom'],
'ERRORS.PATIENT_ALREADY_EXISTS' => ['prenom', 'nom'],
];
public function __construct(protected ApplicationModel $application, protected Flash $flash)
{
}
// ---- ce que chaque type de personne doit fournir
// "medecins" ou "clients" : le début des noms des routes
abstract protected function section(): string;
abstract protected function cles(): ClesPersonnes;
/** @return list<Personne> */
abstract protected function lister(): array;
abstract protected function trouver(int $id): ?Personne;
abstract protected function creer(DonneesPersonne $donnees): Personne;
abstract protected function mettreAJour(int $id, DonneesPersonne $donnees): Personne;
abstract protected function effacer(int $id): void;
// -------------------------------------------------------------------
// GET /medecins (ou /clients)
// -------------------------------------------------------------------
#[Route('', name: 'liste', methods: ['GET'])]
public function liste(): Response
{
return $this->render('personnes/liste.html.twig', ['titrePage' => $this->cles()->titre,
'cles' => $this->cles(), 'section' => $this->section(), 'personnes' => $this->lister()]);
}
// -------------------------------------------------------------------
// GET /medecins/ajouter : le formulaire vide ; POST : la création
// -------------------------------------------------------------------
#[Route('/ajouter', name: 'ajout', methods: ['GET', 'POST'])]
public function ajout(Request $request): Response
{
$formulaire = $this->createForm(PersonneType::class, ['titre' => 'Mr']);
$formulaire->handleRequest($request);
if ($formulaire->isSubmitted() && $formulaire->isValid()) {
try {
$personne = $this->creer(ChampsPersonneType::donnees($formulaire->getData()));
} catch (MetierException $e) {
return $this->erreurMetier($e, $formulaire, null);
}
$this->flash->succes($this->cles()->ajoute, self::parametres($personne));
return $this->redirectToRoute($this->section().'_liste');
}
return $this->page($formulaire, null);
}
// -------------------------------------------------------------------
// GET /medecins/{id}/modifier : le formulaire pré-rempli ; POST : la modification.
// {id<\d+>} : un paramètre fait de chiffres (cf. exemple 02) :
// /medecins/abc/modifier ne correspond à aucune route (page 404)
// -------------------------------------------------------------------
#[Route('/{id<\d+>}/modifier', name: 'modification', methods: ['GET', 'POST'])]
public function modification(Request $request, int $id): Response
{
$personne = $this->trouver($id) ?? throw $this->createNotFoundException();
// le formulaire, pré-rempli avec les valeurs de la personne (le mot de
// passe, lui, n'est jamais affiché)
$formulaire = $this->createForm(PersonneType::class, [
'titre' => $personne->getTitre(), 'prenom' => $personne->getPrenom(),
'nom' => $personne->getNom(), 'login' => $personne->getCompte()->getLogin(),
'version' => $personne->getVersion(),
], ['creation' => false]);
$formulaire->handleRequest($request);
if ($formulaire->isSubmitted() && $formulaire->isValid()) {
try {
$personne = $this->mettreAJour($id, ChampsPersonneType::donnees($formulaire->getData()));
} catch (MetierException $e) {
return $this->erreurMetier($e, $formulaire, $personne);
}
$this->flash->succes($this->cles()->modifie, self::parametres($personne));
return $this->redirectToRoute($this->section().'_liste');
}
return $this->page($formulaire, $personne);
}
// -------------------------------------------------------------------
// POST /medecins/{id}/supprimer
// (la confirmation "Êtes-vous sûr ?" est demandée avant l'envoi du formulaire,
// cf. public/js/app.js)
// -------------------------------------------------------------------
#[Route('/{id<\d+>}/supprimer', name: 'supprimer', methods: ['POST'])]
public function supprimer(Request $request, int $id): Response
{
$this->verifierCsrf($request);
$personne = $this->trouver($id);
try {
$this->effacer($id);
$this->flash->succes($this->cles()->supprime, self::parametres($personne));
} catch (MetierException $e) {
$this->flash->erreur($e->cle, $e->parametres);
}
return $this->redirectToRoute($this->section().'_liste');
}
// ----------------------------------------------------------------- fonctions internes
// $personne null : formulaire de création ; sinon : formulaire de modification.
// Un formulaire refusé (saisie invalide, ou règle de gestion) : le code 422
private function page(FormInterface $formulaire, ?Personne $personne, ?Message $erreur = null,
array $champsInvalides = []): Response
{
$cles = $this->cles();
$refuse = $erreur !== null || ($formulaire->isSubmitted() && ! $formulaire->isValid());
return $this->render('personnes/formulaire.html.twig', [
'titrePage' => $personne === null ? $cles->ajouter : $cles->modifier,
'formulaire' => $formulaire,
'erreur' => $erreur,
'champsInvalides' => $champsInvalides,
'urlListe' => $this->generateUrl($this->section().'_liste'),
], new Response(status: $refuse ? 422 : 200));
}
// une règle de gestion a refusé l'écriture : le formulaire est réaffiché, avec
// la saisie de l'utilisateur (sans le mot de passe), le message de la couche
// métier (en bas de page), et les champs concernés, s'il y en a, encadrés en
// rouge pour que l'utilisateur voie tout de suite quoi corriger
private function erreurMetier(MetierException $e, FormInterface $formulaire, ?Personne $personne): Response
{
if (in_array($e->cle, ['ERRORS.DOCTOR_NOT_FOUND', 'ERRORS.PATIENT_NOT_FOUND'], true)) {
// supprimé entre-temps par un autre ADMIN : retour à la liste
$this->flash->erreur($e->cle, $e->parametres);
return $this->redirectToRoute($this->section().'_liste');
}
return $this->page($formulaire, $personne, new Message($e->cle, $e->parametres),
self::CHAMPS_ERREURS_METIER[$e->cle] ?? []);
}
// {nom} et {login} des messages "ajouté / modifié / supprimé"
private static function parametres(?Personne $personne): array
{
return $personne === null ? ['nom' => '', 'login' => '']
: ['nom' => "{$personne->getPrenom()} {$personne->getNom()}",
'login' => $personne->getCompte()->getLogin()];
}
}
|
La gestion des médecins et celle des clients sont identiques : même liste, même formulaire, mêmes règles. Les actions sont écrites une fois, dans une classe abstraite ; [MedecinsController] et [ClientsController] en héritent. Chacune doit fournir ce qui la distingue : c'est le rôle des méthodes abstraites des lignes 64-77, déclarées sans code : une classe fille qui ne les écrirait pas serait refusée par [PHP]. C'est le patron de conception dit « méthode modèle » (template method) : la classe mère écrit l'algorithme, les filles en fournissent les détails.
Les attributs [#[Route]] des actions sont hérités eux aussi. [Symfony] ignore la classe abstraite, mais examine les méthodes de chacune des classes filles, héritées comprises ; l'attribut [#[Route]] de la classe fille donne le début de leur chemin et de leur nom : l'action [liste] devient les routes [medecins_liste] ([/medecins]) et [clients_liste] ([/clients]). Pour ajouter une action à la gestion des médecins et à celle des clients, il suffit de l'écrire ici.
- lignes 51-55 : les champs concernés par chaque erreur métier de saisie : un login déjà pris concerne le champ login ; un homonyme, les champs prénom et nom. Ces champs seront encadrés en rouge ;
- ligne 57 : le constructeur, hérité par les classes filles. Ses propriétés sont [protected] : visibles des classes filles ;
- lignes 82-87 : la liste. La vue est commune aux deux sections : elle reçoit les clés de traduction de la section ([cles]) et son nom ([section]), qui sert à fabriquer les noms des routes ([section ~ '_ajout'] dans la vue) ;
- lignes 92-107 : l'ajout. Une seule action pour GET (le formulaire vide, avec le titre [Mr] choisi) et POST (la création), comme à l'exemple 19. Une saisie invalide réaffiche le formulaire. Une saisie valide va à la couche métier, qui peut refuser l'écriture (un homonyme, un login déjà pris) ; sinon, message flash de succès et retour à la liste (schéma PRG) ;
- lignes 114-136 : la modification, pour une personne qui existe : [?? throw $this->createNotFoundException()] (la page 404 ; [throw] est une expression, utilisable à droite de [??]). [{id<\d+>}] (ligne 114) n'accepte que des chiffres pour le paramètre [{id}] (exemple 02). Le formulaire est pré-rempli avec les valeurs de la personne, et sa version ; [handleRequest] les remplace par la saisie, pour un POST ;
- lignes 143-155 : la suppression. Le formulaire a demandé une confirmation, dans le navigateur (exemple 21). On lit la personne avant de la supprimer, pour le message de succès (« Le médecin Paul TATOU a été supprimé. ») ;
- lignes 161-173 : le formulaire, de création ([$personne] vaut [null]) ou de modification. Un formulaire refusé (saisie invalide, ou règle de gestion) est rendu avec le code 422 (exemple 19). La vue reçoit le formulaire lui-même ([render] le transforme en « vue de formulaire », exemple 18) ;
- lignes 179-188 : la couche métier a refusé l'écriture. Une personne supprimée entre-temps (par un autre administrateur) : retour à la liste. Sinon, le formulaire est réaffiché directement (sans redirection), comme pour une erreur de saisie : il contient déjà la saisie de l'utilisateur. S'y ajoutent le message de la couche métier (affiché en bas de la page, dans la zone d'erreur) et les champs à encadrer en rouge. Rappelons que le gestionnaire d'entités est fermé après l'exception (la couche métier, plus haut) : l'action ne fait plus qu'afficher ;
- lignes 191-196 : les paramètres [{nom}] et [{login}] des messages de succès.
11.19.5. src/Controller/MedecinsController.php et ClientsController.php
| <?php
// src/Controller/MedecinsController.php : la gestion des médecins (rôle ADMIN
// uniquement). Toutes les actions sont écrites par PersonnesController : il ne
// reste ici qu'à dire QUOI appeler et QUELS libellés afficher.
namespace App\Controller;
use App\Entity\Personne;
use App\Metier\DonneesPersonne;
use App\Vues\ClesPersonnes;
use Symfony\Component\Routing\Attribute\Route;
// le début du chemin et du nom de toutes les routes héritées de PersonnesController
#[Route('/medecins', name: 'medecins_')]
class MedecinsController extends PersonnesController
{
protected function section(): string
{
return 'medecins';
}
// MEDECINS.TITLE, MEDECINS.ADD... (cf. translations)
protected function cles(): ClesPersonnes
{
return ClesPersonnes::de('MEDECINS');
}
protected function lister(): array
{
return $this->application->getAllMedecins();
}
protected function trouver(int $id): ?Personne
{
return $this->application->getMedecinById($id);
}
protected function creer(DonneesPersonne $donnees): Personne
{
return $this->application->ajouterMedecin($donnees);
}
protected function mettreAJour(int $id, DonneesPersonne $donnees): Personne
{
return $this->application->modifierMedecin($id, $donnees);
}
protected function effacer(int $id): void
{
$this->application->supprimerMedecin($id);
}
}
|
Il ne reste ici qu'à dire quoi appeler (les méthodes de l'[ApplicationModel]) et quels libellés afficher (les clés [MEDECINS.xxx] des dictionnaires), et à donner le début des chemins et des noms des routes (ligne 14). L'[ApplicationModel] est dans la propriété [$this->application], déclarée par le constructeur de la classe mère. [ClientsController] est identique, avec les méthodes des clients et les clés [CLIENTS.xxx].
11.19.6. src/Controller/InscriptionController.php
| <?php
// =========================================================================
// src/Controller/InscriptionController.php : "Créer un compte client" : un patient
// qui n'a pas encore de compte le crée lui-même. Routes PUBLIQUES (on n'a pas
// encore de compte, par définition) :
//
// POST /inscription/acces bouton "Créer un compte client" de la page
// d'authentification : vérifie le captcha de CETTE
// page, puis ouvre le formulaire
// GET /inscription le formulaire
// POST /inscription la création, puis retour à la page de connexion
//
// Le parcours est protégé contre les robots par UN captcha, celui de la page
// d'authentification :
// 1. le bouton "Créer un compte client" envoie le formulaire de connexion vers
// /inscription/acces ; si le captcha est bien recopié, le serveur délivre un
// laissez-passer (cf. src/Securite/PasseInscription.php) ;
// 2. sans laissez-passer valide, /inscription renvoie à la page
// d'authentification (attribut #[PasseInscriptionExige], cf.
// src/EventListener/VerifierPasseInscription.php) ;
// 3. le laissez-passer est consommé à la création : un captcha résolu permet de
// créer UN compte ;
// 4. en plus, le nombre de comptes créés par heure et par adresse IP est limité
// (cf. src/Securite/LimiteurTentatives.php).
//
// Le compte créé est exactement celui que créerait l'ADMIN depuis la page "Clients"
// (même méthode de la couche métier : ajouterClient), si ce n'est que le patient
// CHOISIT son login et son mot de passe (obligatoires, avec confirmation).
// =========================================================================
namespace App\Controller;
use App\Form\ChampsPersonneType;
use App\Form\InscriptionType;
use App\Metier\MetierException;
use App\Securite\CaptchaService;
use App\Securite\LimiteurTentatives;
use App\Securite\PasseInscription;
use App\Securite\PasseInscriptionExige;
use App\Web\ApplicationModel;
use App\Web\Controleur;
use App\Web\Flash;
use App\Web\Message;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/inscription')]
class InscriptionController extends Controleur
{
public function __construct(
private ApplicationModel $application,
private CaptchaService $captchas,
private LimiteurTentatives $limiteur,
private PasseInscription $passes,
private Flash $flash,
) {
}
// -----------------------------------------------------------------------
// POST /inscription/acces champs : captchaId, captcha (ceux de la page
// d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
// -----------------------------------------------------------------------
#[Route('/acces', name: 'inscription_acces', methods: ['POST'])]
public function acces(Request $request): Response
{
$this->verifierCsrf($request);
$ip = (string) $request->getClientIp();
// trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
$minutes = $this->limiteur->minutesBlocageInscription($ip);
if ($minutes > 0) {
$this->flash->erreur('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
return $this->redirectToRoute('login');
}
// captcha absent ou faux : retour à la page d'authentification, avec une
// nouvelle image (un échec compte comme un échec de connexion)
if (! $this->captchas->verifier($request->request->getString('captchaId'),
$request->request->getString('captcha'))) {
$this->limiteur->echecLogin($ip, '');
$this->flash->erreur('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
return $this->redirectToRoute('login');
}
// captcha correct : on délivre le laissez-passer et on ouvre le formulaire
$reponse = $this->redirectToRoute('inscription');
$reponse->headers->setCookie($this->passes->emettre());
return $reponse;
}
// -----------------------------------------------------------------------
// GET /inscription : le formulaire ; POST /inscription : la création. Le
// laissez-passer a été vérifié AVANT l'appel de l'action (#[PasseInscriptionExige]).
// Le formulaire valide TOUTE la saisie (champs et confirmation du mot de passe) :
// l'utilisateur corrige toutes ses erreurs en une fois
// -----------------------------------------------------------------------
#[Route('', name: 'inscription', methods: ['GET', 'POST'])]
#[PasseInscriptionExige]
public function inscription(Request $request): Response
{
$formulaire = $this->createForm(InscriptionType::class, ['titre' => 'Mr']);
$formulaire->handleRequest($request);
if (! $formulaire->isSubmitted() || ! $formulaire->isValid()) {
return $this->page($formulaire);
}
$ip = (string) $request->getClientIp();
// 1. trop de comptes créés récemment depuis cette adresse ?
$minutes = $this->limiteur->minutesBlocageInscription($ip);
if ($minutes > 0) {
return $this->page($formulaire, new Message('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]));
}
// 2. la création (client + compte USER, dans une transaction)
$donnees = ChampsPersonneType::donnees($formulaire->getData());
try {
$this->application->ajouterClient($donnees);
} catch (MetierException $e) {
// login ou prénom + nom déjà pris : le laissez-passer reste valable
return $this->page($formulaire, new Message($e->cle, $e->parametres),
PersonnesController::CHAMPS_ERREURS_METIER[$e->cle] ?? []);
}
// 3. le laissez-passer a servi : il ne permettra pas de créer un 2e compte
$this->passes->consommer($request->cookies->get(PasseInscription::COOKIE));
$this->limiteur->inscriptionReussie($ip);
// 4. retour à la page de connexion, login pré-rempli
$this->flash->succes('INSCRIPTION.DONE', ['login' => $donnees->login]);
return PasseInscription::effacerCookie($this->redirectToRoute('login', ['login' => $donnees->login]));
}
// le formulaire ; un formulaire refusé : le code 422
private function page(FormInterface $formulaire, ?Message $erreur = null, array $champsInvalides = []): Response
{
$refuse = $erreur !== null || ($formulaire->isSubmitted() && ! $formulaire->isValid());
return $this->render('inscription.html.twig', ['titrePage' => 'INSCRIPTION.TITLE',
'formulaire' => $formulaire, 'erreur' => $erreur, 'champsInvalides' => $champsInvalides],
new Response(status: $refuse ? 422 : 200));
}
}
|
La création d'un compte client par le patient lui-même. Le parcours est protégé par un captcha, celui de la page de connexion.
- lignes 65-91 : le bouton Créer un compte client de la page de connexion envoie le formulaire de connexion à cette adresse (plus loin, la vue [login]). Si trop de comptes ont été créés récemment depuis cette adresse IP, inutile d'aller plus loin. Si le captcha est absent ou faux : retour à la page de connexion, avec une nouvelle image et un message ; l'échec compte comme un échec de connexion (avec un login vide). Si le captcha est bon : un laissez-passer, dans un cookie, et une redirection vers le formulaire ;
- lignes 99-107 : le formulaire. L'attribut [#[PasseInscriptionExige]] (ligne 100) fait vérifier le laissez-passer par l'écouteur [VerifierPasseInscription], avant l'appel de l'action. Le formulaire valide toute la saisie, confirmation du mot de passe comprise : l'utilisateur voit toutes ses erreurs d'un coup ;
- lignes 108-132 : la création. Le client et son compte sont créés par la même méthode de la couche métier que pour l'administrateur ([ajouterClient]) ; seuls le login et le mot de passe sont choisis par le patient. Un login ou un homonyme déjà pris : retour au formulaire, sans consommer le laissez-passer. Un compte créé consomme le laissez-passer (un captcha résolu, un compte), compte pour la limite horaire, et renvoie à la page de connexion, le login pré-rempli ([redirectToRoute('login', ['login' => ...])] ajoute le paramètre [?login=...]), avec un message de succès. Le cookie du laissez-passer est effacé.
11.19.7. src/Controller/EspaceController.php
| <?php
// =========================================================================
// src/Controller/EspaceController.php : les pages PERSONNELLES du patient (rôle
// USER), celle qu'il voit juste après s'être connecté :
//
// GET /mon-espace page d'accueil : [Gérer mon compte], [Voir un
// agenda], et son rendez-vous à venir
// POST /mon-espace/annuler annule SON rendez-vous
// GET /mon-compte formulaire de modification de son compte
// POST /mon-compte modification
// POST /mon-compte/supprimer suppression du compte (et de ses rendez-vous)
//
// Règle d'or de ce contrôleur : le patient concerné est TOUJOURS celui du jeton
// ($this->outils->patientConnecte()), jamais un identifiant envoyé par le
// navigateur. Un patient ne peut donc ni voir, ni modifier, ni annuler ce qui
// appartient à un autre patient, même en fabriquant lui-même ses requêtes.
//
// Toute modification ou suppression du compte exige le mot de passe ACTUEL :
// quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le
// mot de passe, ni supprimer le compte. Les erreurs de mot de passe actuel sont
// comptées comme des échecs de connexion (même limite).
//
// Sécurité : toutes ces URL sont réservées au rôle USER (règle ^/mon- de
// config/packages/security.yaml).
// =========================================================================
namespace App\Controller;
use App\Entity\Client;
use App\Entity\Rv;
use App\Form\ChampsPersonneType;
use App\Form\MonCompteType;
use App\Metier\MetierException;
use App\Securite\AuthService;
use App\Securite\LimiteurTentatives;
use App\Vues\RendezVousVue;
use App\Web\ApplicationModel;
use App\Web\Controleur;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Outils;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class EspaceController extends Controleur
{
public function __construct(
private ApplicationModel $application,
private AuthService $auth,
private LimiteurTentatives $limiteur,
private Outils $outils,
private Flash $flash,
private TranslatorInterface $traducteur,
) {
}
// -----------------------------------------------------------------------
// GET /mon-espace
// -----------------------------------------------------------------------
#[Route('/mon-espace', name: 'espace', methods: ['GET'])]
public function espace(): Response
{
$patient = $this->outils->patientConnecte();
$rendezVous = array_map(fn (Rv $rv) => new RendezVousVue(
$rv->getId(),
$this->outils->formaterJour($rv->getJour()),
$rv->getCreneau()->horaire(),
$rv->getCreneau()->getMedecin()->libelle(),
// lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
$this->generateUrl('agenda', ['idMedecin' => $rv->getCreneau()->getMedecin()->getId(),
'jour' => Outils::iso($rv->getJour())])),
$this->application->getRvAVenirClient($patient->getId()));
return $this->render('espace.html.twig', ['titrePage' => 'ESPACE.TITLE',
'patient' => $patient->libelle(), 'rendezVous' => $rendezVous]);
}
// -----------------------------------------------------------------------
// POST /mon-espace/annuler champ : idRv
// -----------------------------------------------------------------------
#[Route('/mon-espace/annuler', name: 'espace_annuler', methods: ['POST'])]
public function annulerRv(Request $request): Response
{
$this->verifierCsrf($request);
$patient = $this->outils->patientConnecte();
$idRv = Outils::entierPositif($request->request->get('idRv'));
$rv = $idRv !== null ? $this->application->getRvById($idRv) : null;
// un rendez-vous qui n'existe pas, et celui d'un AUTRE patient, reçoivent la
// même réponse : on ne révèle rien sur les rendez-vous des autres
if ($rv === null || $rv->getClient()->getId() !== $patient->getId()) {
$this->flash->erreur('ERRORS.APPOINTMENT_NOT_FOUND');
} else {
$this->application->supprimerRv($rv);
$this->flash->succes('AGENDA.CANCELLED');
}
return $this->redirectToRoute('espace');
}
// -----------------------------------------------------------------------
// GET /mon-compte : le formulaire, pré-rempli avec les valeurs du patient ;
// POST /mon-compte : la modification
// -----------------------------------------------------------------------
#[Route('/mon-compte', name: 'compte', methods: ['GET', 'POST'])]
public function compte(Request $request): Response
{
$patient = $this->outils->patientConnecte();
$formulaire = $this->createForm(MonCompteType::class, [
'titre' => $patient->getTitre(), 'prenom' => $patient->getPrenom(), 'nom' => $patient->getNom(),
'login' => $patient->getCompte()->getLogin(), 'version' => $patient->getVersion(),
]);
$formulaire->handleRequest($request);
if (! $formulaire->isSubmitted() || ! $formulaire->isValid()) {
return $this->page($formulaire);
}
// le mot de passe actuel ; faux : le message va SOUS le champ (une erreur
// ajoutée au champ, FormError), et en bas de page
$erreur = $this->verifierMotDePasseActuel($request, $patient,
$formulaire->get('current_password')->getData());
if ($erreur !== null) {
$formulaire->get('current_password')->addError(
new FormError($this->traducteur->trans($erreur->cle, $erreur->parametres)));
return $this->page($formulaire, $erreur);
}
try {
$client = $this->application->modifierClient($patient->getId(),
ChampsPersonneType::donnees($formulaire->getData()));
} catch (MetierException $e) {
return $this->page($formulaire, new Message($e->cle, $e->parametres),
PersonnesController::CHAMPS_ERREURS_METIER[$e->cle] ?? []);
}
// le login et le nom affiché sont DANS le jeton : on en délivre un nouveau,
// sinon la barre de navigation garderait l'ancien nom
$this->flash->succes('MON_COMPTE.UPDATED');
$reponse = $this->redirectToRoute('espace');
$reponse->headers->setCookie($this->auth->cookieJeton($client->getCompte()));
return $reponse;
}
// -----------------------------------------------------------------------
// POST /mon-compte/supprimer champ : current_password
// Le compte, le client et TOUS ses rendez-vous disparaissent ensemble, puis le
// patient est déconnecté.
// -----------------------------------------------------------------------
#[Route('/mon-compte/supprimer', name: 'compte_supprimer', methods: ['POST'])]
public function supprimerCompte(Request $request): Response
{
$this->verifierCsrf($request);
$patient = $this->outils->patientConnecte();
$erreur = $this->verifierMotDePasseActuel($request, $patient,
$request->request->getString('current_password'));
if ($erreur !== null) {
$this->flash->erreur($erreur->cle, $erreur->parametres);
return $this->redirectToRoute('compte');
}
$this->application->supprimerClient($patient->getId());
$this->flash->succes('MON_COMPTE.DELETED');
return AuthService::effacerCookie($this->redirectToRoute('login'));
}
// ----------------------------------------------------------------- fonctions internes
// la page du compte ; un formulaire refusé : le code 422
private function page(FormInterface $formulaire, ?Message $erreur = null, array $champsInvalides = []): Response
{
$refuse = $erreur !== null || ($formulaire->isSubmitted() && ! $formulaire->isValid());
return $this->render('compte.html.twig', ['titrePage' => 'MON_COMPTE.TITLE',
'formulaire' => $formulaire, 'erreur' => $erreur, 'champsInvalides' => $champsInvalides],
new Response(status: $refuse ? 422 : 200));
}
// null si le mot de passe actuel est correct ; sinon le message d'erreur
private function verifierMotDePasseActuel(Request $request, Client $patient, ?string $motDePasse): ?Message
{
$ip = (string) $request->getClientIp();
$login = $patient->getCompte()->getLogin();
$minutes = $this->limiteur->minutesBlocageLogin($ip, $login);
if ($minutes > 0) {
return new Message('ERRORS.TOO_MANY_ATTEMPTS', ['minutes' => $minutes]);
}
if (! $motDePasse || $this->auth->valider($login, $motDePasse) === null) {
$this->limiteur->echecLogin($ip, $login);
return new Message('ERRORS.WRONG_CURRENT_PASSWORD');
}
return null;
}
}
|
Les pages personnelles du patient. Règle d'or de ce contrôleur : le patient concerné est toujours celui du jeton ([Outils::patientConnecte]), jamais un identifiant envoyé par le navigateur. Un patient ne peut donc ni voir, ni modifier, ni annuler ce qui appartient à un autre, même en fabriquant lui-même ses requêtes.
- lignes 64-79 : Mon espace : le libellé du patient, et ses rendez-vous à venir, mis en forme ([RendezVousVue]), chacun avec un lien vers l'agenda du médecin ce jour-là ;
- lignes 84-100 : l'annulation de son rendez-vous. Un rendez-vous qui n'existe pas, et celui d'un autre patient, reçoivent la même réponse : on ne révèle rien sur les rendez-vous des autres (pas même leur existence) ;
- lignes 106-117 : le formulaire « Gérer mon compte », pré-rempli avec les valeurs du patient, et sa validation ;
- lignes 119-142 : la modification. Toute modification exige le mot de passe actuel : quelqu'un qui trouverait une session restée ouverte ne pourrait ni changer le mot de passe, ni rien d'autre. Un mot de passe actuel faux : le formulaire est réaffiché, avec le message sous le champ et en bas de la page. [addError(new FormError(...))] ajoute une erreur à un champ, comme si la validation l'avait trouvée (exemple 18) ; son message est un texte déjà traduit, d'où le traducteur reçu par le constructeur ([TranslatorInterface], exemple 22). Après la modification, un nouveau jeton : le login et le nom affiché sont dans le jeton, et la barre de navigation garderait sinon l'ancien nom jusqu'à la prochaine connexion ;
- lignes 149-163 : la suppression du compte, du client et de tous ses rendez-vous, par la même méthode de la couche métier que pour l'administrateur. Ce second formulaire de la page est écrit à la main (un seul champ, le mot de passe actuel), avec le jeton anti-CSRF de l'application. Le patient est ensuite déconnecté (cookie du jeton effacé) ;
- lignes 177-190 : la vérification du mot de passe actuel. Elle est soumise au limiteur de tentatives, comme une connexion : sans cela, quelqu'un qui disposerait d'une session ouverte pourrait essayer des milliers de mots de passe, par le formulaire « Gérer mon compte », et découvrir le bon.
11.20. Les vues
Les vues [Twig] du dossier [templates] : un gabarit, des fragments, des macros, et les pages.
11.20.1. Le gabarit et les fragments
11.20.1.1. templates/layout.html.twig
| <!doctype html>
{# =========================================================================
templates/layout.html.twig
-------------------------------------------------------------------------
Le gabarit COMMUN à toutes les pages (cf. exemple 15) : chaque page
(agenda.html.twig, login.html.twig...) l'ÉTEND ({% extends %}) et remplit son
bloc [contenu].
Twig ÉCHAPPE tout ce qu'il affiche avec {{ ... }} (les caractères < > & " '
deviennent < >...) : protection contre l'injection de HTML via un nom
saisi, par exemple. Pour insérer du HTML tel quel, il faut le demander
explicitement (filtre raw, cf. fragments/captcha.html.twig).
Messages : un succès s'affiche en HAUT de la page, une erreur en BAS, sur fond
rose léger (fragment erreurs).
Le gabarit partage les variables de la page : [titrePage] (la clé du titre de
la page, transmise par l'action). La langue de la page est celle de la requête
(app.request.locale, cf. src/EventListener/FixerLangue.php).
========================================================================= #}
<html lang="{{ app.request.locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# titrePage|trans : la traduction d'une clé ; ici, la clé est une VARIABLE #}
<title>{{ titrePage|trans }} - {{ 'APP.TITLE'|trans }}</title>
{# asset() : l'URL d'un fichier du dossier public #}
<link rel="stylesheet" href="{{ asset('lib/bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
</head>
<body>
<div class="container py-4">
{% include 'fragments/navbar.html.twig' %}
{% include 'fragments/succes.html.twig' %}
<main>
{% block contenu %}{% endblock %}
</main>
{% include 'fragments/erreurs.html.twig' %}
</div>
{# la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin #}
{% include 'fragments/confirmation.html.twig' %}
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
|
Le gabarit commun à toutes les pages (exemple 15) : chaque page l'étend ([{% extends 'layout.html.twig' %}]) et remplit son bloc [contenu] (ligne 36).
- ligne 21 : la langue de la page, [fr] ou [en] : celle de la requête, fixée par l'écouteur [FixerLangue]. [app] est la variable globale que [Symfony] fournit à toutes les vues (exemple 15) ;
- ligne 26 : le titre de la page, traduit : [titrePage|trans] traduit la clé contenue dans la variable [titrePage], que chaque action transmet à sa vue ;
- lignes 28-29 : la fonction [asset] rend l'URL d'un fichier du dossier [public] (exemple 15) ;
- lignes 33-38 : la barre de navigation, les messages de succès en haut de la page, le contenu de la page, et la zone d'erreur en bas de la page. Les fragments sont insérés par [{% include %}] (exemple 15) : ils voient les variables de la page ;
- lignes 41-42 : la fenêtre « Êtes-vous sûr ? », cachée tant qu'on n'en a pas besoin, et le script qui l'affiche.
[Twig] échappe tout ce qu'il affiche avec [{{ ... }}] (exemple 13) : un nom saisi qui contiendrait du HTML ([<script>]) est affiché tel quel, sans être interprété. C'est la protection contre l'injection de HTML (XSS), sans effort, dans toutes les pages.
11.20.1.2. templates/fragments/navbar.html.twig
| {# =========================================================================
templates/fragments/navbar.html.twig - la barre de navigation.
Titre, FR/EN, rôle, nom, déconnexion - et un menu dont les entrées dépendent
du rôle : "Médecins" et "Clients" n'apparaissent que pour l'ADMIN. Ce n'est
qu'un confort d'affichage : la vraie protection, ce sont les règles d'accès
(un USER qui tape /medecins à la main obtient une page 403). Un patient (USER)
a en plus son menu "Mon espace".
utilisateur() : l'utilisateur connecté, ou null (cf. src/Twig/VuesExtension.php).
Le menu de la page courante est mis en évidence (classe "active") : il l'est si
le chemin de l'URL (app.request.pathInfo) commence par ([starts with]) l'un des
préfixes de l'entrée. Les entrées du menu : une liste de tableaux [nom de la
route, clé du libellé, préfixes des chemins pour lesquels l'entrée est active].
========================================================================= #}
{% set utilisateur = utilisateur() %}
{% set menu = [] %}
{% if utilisateur %}
{% if utilisateur.estPatient %}
{# active pour /mon-espace ET /mon-compte #}
{% set menu = menu|merge([['espace', 'APP.MENU_MY_SPACE', ['/mon-']]]) %}
{% endif %}
{% set menu = menu|merge([['agenda', 'APP.MENU_AGENDA', ['/agenda']]]) %}
{% if utilisateur.estAdmin %}
{% set menu = menu|merge([['medecins_liste', 'APP.MENU_DOCTORS', ['/medecins']],
['clients_liste', 'APP.MENU_PATIENTS', ['/clients']]]) %}
{% endif %}
{% endif %}
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
<span class="navbar-brand mb-0">{{ 'APP.TITLE'|trans }}</span>
<ul class="navbar-nav me-auto">
{# entree[0] : le nom de la route ; filter garde les préfixes qui conviennent #}
{% for entree in menu %}
{% set active = entree[2]|filter(p => app.request.pathInfo starts with p) is not empty %}
<li class="nav-item">
<a class="nav-link{{ active ? ' active' }}" href="{{ path(entree[0]) }}">{{ entree[1]|trans }}</a>
</li>
{% endfor %}
</ul>
<div class="d-flex align-items-center gap-2">
{# FR / EN : de simples liens (GET /langue/fr), la langue active en surbrillance #}
<div class="btn-group btn-group-sm" role="group" aria-label="FR / EN">
{% for code in ['fr', 'en'] %}
<a class="btn {{ app.request.locale == code ? 'btn-light' : 'btn-outline-light' }}"
href="{{ path('langue', {code: code}) }}">{{ code|upper }}</a>
{% endfor %}
</div>
{% if utilisateur %}
<span class="badge text-bg-light text-primary">{{ utilisateur.role.value }}</span>
<span class="text-white">{{ utilisateur.nom }}</span>
{# la déconnexion est un POST : un formulaire réduit à son bouton (et au jeton anti-CSRF) #}
<form method="post" action="{{ path('logout') }}" class="m-0">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<button type="submit" class="btn btn-sm btn-outline-light">{{ 'APP.LOGOUT'|trans }}</button>
</form>
{% endif %}
</div>
</nav>
|
La barre de navigation. Les entrées du menu dépendent du rôle : « Mon espace » pour un patient, « Médecins » et « Clients » pour l'administrateur. Ce n'est qu'un confort : la vraie protection, ce sont les règles [access_control] (un patient qui tape [/medecins] à la main obtient une page 403).
- ligne 16 : l'utilisateur connecté, ou [null], rendu par la fonction [utilisateur()] de notre extension ;
- lignes 17-28 : le menu est préparé par des instructions [{% set %}], une liste d'entrées : le nom de la route, la clé du libellé, et les préfixes des chemins pour lesquels l'entrée est active. Le filtre [merge] rend une nouvelle liste, faite des éléments de la première suivis de ceux de la seconde (pour deux tableaux associatifs, il réunit leurs clés). [utilisateur.estPatient] : [Twig] appelle la méthode [estPatient()]. L'entrée « Mon espace » est active pour [/mon-espace] et [/mon-compte] (préfixe [/mon-]). C'est à la limite de la règle « pas de logique dans les vues » : mais cette logique ne concerne que l'affichage, et ne sert qu'ici ;
- lignes 34-39 : pour chaque entrée, [active] est vrai si le chemin de l'URL demandée ([app.request.pathInfo]) commence par ([starts with]) l'un des préfixes de l'entrée. Le filtre [filter] ne garde que les éléments d'une liste qui vérifient une condition, écrite sous la forme d'une fonction fléchée ([p => ...]) ;
- lignes 44-49 : les boutons FR / EN, de simples liens ([/langue/fr]), la langue active en surbrillance ;
- lignes 51-59 : pour un utilisateur connecté, son rôle ([utilisateur.role.value] : la chaîne du cas de l'énumération), son nom, et le bouton de déconnexion. La déconnexion est une requête POST : un formulaire réduit à son bouton, avec son jeton anti-CSRF ([csrf_token('rdvmedecins')], exemple 21).
11.20.1.3. templates/fragments/succes.html.twig
| {# =========================================================================
templates/fragments/succes.html.twig - les messages de SUCCÈS déposés par
l'action précédente (rendez-vous enregistré, médecin ajouté...), affichés une
seule fois, en haut de la page (cf. src/Web/Flash.php). Un message flash
d'ERREUR, lui, est affiché en bas de la page, par le fragment erreurs.
app.flashes('succes') : les messages flash de ce type (cf. exemple 20). Chaque
message est un objet Message : message.cle|trans(message.parametres) est le texte
de la clé, où {nom}, {login}... sont remplacés par leurs valeurs.
========================================================================= #}
{% for message in app.flashes('succes') %}
<div class="alert alert-success" role="status">{{ message.cle|trans(message.parametres) }}</div>
{% endfor %}
|
Les messages flash de succès, déposés par l'action précédente ([Flash::succes]), lus et retirés de la session par [app.flashes('succes')] (exemple 20). Chacun est un objet [Message], traduit ici, dans la langue de la page : [message.cle|trans(message.parametres)]. [role="status"] : un lecteur d'écran annonce le message.
11.20.1.4. templates/fragments/erreurs.html.twig
| {# =========================================================================
templates/fragments/erreurs.html.twig - la zone d'erreur, en BAS de chaque
page, sur fond rose léger (classe .zone-erreur, cf. public/css/styles.css).
Elle regroupe les trois sources d'erreur possibles :
- [erreur] : l'erreur de la page affichée (date invalide, captcha mal
recopié, login déjà pris...), transmise par l'action ;
- les messages flash d'erreur, déposés par l'action précédente avant une
redirection (créneau déjà réservé, suppression refusée...) ;
- les erreurs de saisie d'un formulaire invalide (cf. exemple 19) : leurs
messages sont affichés SOUS les champs ; ici, s'il n'y a pas d'autre
message, une simple phrase « Le formulaire contient des erreurs. ».
erreur ?? null : la variable n'existe pas dans toutes les pages.
role="alert" : un lecteur d'écran annonce le message dès l'affichage de la
page, même s'il se trouve en bas.
========================================================================= #}
{% set messages = app.flashes('erreurs') %}
{% if erreur ?? null %}
{% set messages = [erreur]|merge(messages) %}
{% endif %}
{% if messages is empty and formulaire is defined and formulaire.vars.submitted and not formulaire.vars.valid %}
{% set messages = [{cle: 'ERRORS.INVALID_FORM', parametres: []}] %}
{% endif %}
{% if messages is not empty %}
<div class="zone-erreur" role="alert">
{% for message in messages %}
<div>{{ message.cle|trans(message.parametres) }}</div>
{% endfor %}
</div>
{% endif %}
|
La zone d'erreur, en bas de chaque page, sur fond rose léger. Elle réunit les trois sources d'erreur possibles :
- les messages flash d'erreur, déposés par l'action précédente (un créneau déjà réservé, une session expirée...) ;
- l'erreur de la page affichée ([erreur]), transmise par l'action : une date invalide, un captcha mal recopié, un login déjà pris... placée en tête de la liste. [erreur ?? null] : la variable n'existe pas dans toutes les pages ; l'opérateur [??] ne signale pas une variable inexistante, il la remplace par [null] ;
- les erreurs de saisie d'un formulaire invalide (exemple 19) : leurs messages sont affichés sous les champs ; ici, s'il n'y a pas d'autre message, une simple phrase : « Le formulaire contient des erreurs. ». Sur un long formulaire, l'utilisateur sait ainsi qu'il doit remonter. [formulaire.vars] : les propriétés de la vue du formulaire, dont [submitted] (envoyé ?) et [valid] (valide ?). Le message est écrit comme un tableau associatif qui a les mêmes clés que les propriétés d'un [Message] : pour [Twig], [message.cle] désigne aussi bien la clé d'un tableau que la propriété d'un objet.
[role="alert"] : un lecteur d'écran annonce le message dès l'affichage de la page, même s'il se trouve en bas. La zone reste collée au bas de la fenêtre quand la page est longue (feuille de style, plus loin).
11.20.1.5. templates/fragments/confirmation.html.twig
| {# =========================================================================
templates/fragments/confirmation.html.twig - la fenêtre modale
"Êtes-vous sûr ?" (cf. exemple 21).
Elle remplace la boîte window.confirm() du navigateur, qu'on ne peut ni mettre
en forme, ni traduire (ses boutons restent dans la langue du navigateur, pas
dans celle de la page).
Une SEULE fenêtre pour toute la page, cachée (d-none) au départ. Ses libellés
sont traduits ici, par le serveur, comme le reste de la page ; le petit script
public/js/app.js se contente de l'afficher, d'y placer le message propre au
formulaire (attribut data-confirmation), et d'envoyer le formulaire si
l'utilisateur confirme.
========================================================================= #}
<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"
aria-labelledby="modale-confirmation-titre" aria-describedby="modale-confirmation-message">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modale-confirmation-titre">{{ 'CONFIRMATION.TITLE'|trans }}</h5>
<button type="button" class="btn-close" data-action="non" aria-label="{{ 'BOOKING_DIALOG.CLOSE_ARIA'|trans }}"></button>
</div>
<div class="modal-body">
<p class="mb-0" id="modale-confirmation-message"></p>
</div>
<div class="modal-footer">
{# "Non, revenir" plutôt qu'"Annuler" : la question est parfois
« Annuler ce rendez-vous ? », et un bouton "Annuler" y serait ambigu #}
<button type="button" class="btn btn-secondary" data-action="non">{{ 'CONFIRMATION.NO'|trans }}</button>
<button type="button" class="btn btn-danger" data-action="oui">{{ 'CONFIRMATION.YES'|trans }}</button>
</div>
</div>
</div>
</div>
</div>
|
La fenêtre modale « Êtes-vous sûr ? » de l'exemple 21, qui remplace la boîte [window.confirm()] du navigateur : celle-ci ne se met pas en forme, et ses boutons restent dans la langue du navigateur, pas dans celle de la page. Une seule fenêtre pour toute la page, cachée au départ (classe [d-none] de [Bootstrap]) ; ses libellés sont traduits par le serveur, comme le reste de la page. Le script [public/js/app.js] l'affiche, y place le message du formulaire à confirmer (son attribut [data-confirmation]), et envoie le formulaire si l'utilisateur confirme. Le bouton de refus s'appelle « Non, revenir » plutôt qu'« Annuler » : la question est parfois « Annuler ce rendez-vous ? », et un bouton Annuler y serait ambigu.
11.20.1.6. templates/fragments/reservation.html.twig
| {# =========================================================================
templates/fragments/reservation.html.twig - la fenêtre de réservation de
l'agenda.
Les VRAIES classes Bootstrap d'une fenêtre modale (.modal, .modal-backdrop...),
rendues visibles "en dur" (show d-block) plutôt que par le JavaScript de
Bootstrap. C'est le SERVEUR qui décide de l'afficher (paramètre &reserver=...
de l'URL), et la fermer revient à suivre un lien vers la même page sans ce
paramètre.
Le fragment lit directement les variables de la page qui l'insère
(reservation, idMedecin, jour, urlRetour).
========================================================================= #}
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="titre-reservation">
<div class="modal-dialog modal-dialog-centered">
<form method="post" action="{{ path('agenda_reserver') }}" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="titre-reservation">{{ 'BOOKING_DIALOG.TITLE'|trans }}</h5>
<a class="btn-close" href="{{ urlRetour }}" aria-label="{{ 'BOOKING_DIALOG.CLOSE_ARIA'|trans }}"></a>
</div>
<div class="modal-body">
<p>{{ 'BOOKING_DIALOG.SLOT'|trans({horaire: reservation.horaire}) }}</p>
{# ce que le serveur doit savoir en plus du patient choisi #}
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<input type="hidden" name="idMedecin" value="{{ idMedecin }}">
<input type="hidden" name="jour" value="{{ jour }}">
<input type="hidden" name="idCreneau" value="{{ reservation.idCreneau }}">
{# un patient réserve pour lui-même : pas de liste à choisir (et le
serveur ignore de toute façon un éventuel idClient) #}
{% if reservation.patient %}
<p class="mb-0">
<span>{{ 'BOOKING_DIALOG.PATIENT_LABEL'|trans }}</span> :
<strong>{{ reservation.patient }}</strong>
</p>
{% else %}
<label class="form-label" for="idClient">{{ 'BOOKING_DIALOG.PATIENT_LABEL'|trans }}</label>
<select id="idClient" name="idClient" class="form-select" required autofocus>
<option value="">{{ 'BOOKING_DIALOG.CHOOSE_PATIENT'|trans }}</option>
{% for client in reservation.clients %}
<option value="{{ client.id }}">{{ client.libelle }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div class="modal-footer">
<a class="btn btn-secondary" href="{{ urlRetour }}">{{ 'BOOKING_DIALOG.CANCEL'|trans }}</a>
<button type="submit" class="btn btn-primary">{{ 'BOOKING_DIALOG.CONFIRM'|trans }}</button>
</div>
</form>
</div>
</div>
|
La fenêtre de réservation de l'agenda. Elle utilise les vraies classes d'une fenêtre modale de [Bootstrap] ([modal], [modal-backdrop]...), mais rendues visibles « en dur » ([show d-block]), plutôt que par le [JavaScript] de [Bootstrap] : c'est le serveur qui décide de l'afficher, quand l'URL contient [&reserver=...]. La fermer (la croix, ou le bouton Annuler) revient à suivre un lien vers la même page sans ce paramètre ([urlRetour]).
- lignes 27-30 : le jeton anti-CSRF, et ce que le serveur doit savoir : le médecin, le jour, le créneau ;
- lignes 34-47 : un patient réserve pour lui-même : son nom, sans liste à choisir (et le serveur ignore de toute façon un éventuel champ [idClient]). L'administrateur et les médecins choisissent le patient dans une liste ; [required] et [autofocus] : le navigateur exige un choix, et place le curseur dans la liste.
11.20.1.7. templates/fragments/captcha.html.twig
| {# =========================================================================
templates/fragments/captcha.html.twig - le captcha de la page
d'authentification (cf. src/Securite/CaptchaService.php). Il protège à la fois
la connexion et l'accès à la création de compte (bouton "Créer un compte
client"). Il lit la variable [captcha] de la page.
captcha.svg|raw : l'image SVG est insérée TELLE QUELLE dans la page, sans
échappement. C'est sans danger ici parce que ce SVG est fabriqué par le serveur
lui-même et ne contient rien qui vienne de l'utilisateur - on ne ferait JAMAIS
cela avec une saisie.
Le champ caché captchaId dit au serveur QUEL captcha vérifier ; la bonne
réponse, elle, n'est jamais envoyée au navigateur. "Autre image" recharge
simplement la page (path('login') : l'URL de la page, sans ses paramètres).
========================================================================= #}
<div class="mb-3">
<label class="form-label" for="captcha">{{ 'CAPTCHA.LABEL'|trans }}</label>
<div class="d-flex align-items-center gap-3 mb-2">
<div class="captcha-image" role="img" aria-label="{{ 'CAPTCHA.IMAGE_ALT'|trans }}">{{ captcha.svg|raw }}</div>
<a class="small" href="{{ path('login') }}">{{ 'CAPTCHA.NEW_IMAGE'|trans }}</a>
</div>
<input type="hidden" name="captchaId" value="{{ captcha.id }}">
<input id="captcha" name="captcha" type="text" maxlength="10" autocomplete="off" autocapitalize="off"
spellcheck="false" required class="form-control">
<div class="form-text">{{ 'CAPTCHA.HELP'|trans }}</div>
</div>
|
Le captcha (exemple 27). [captcha.svg|raw] insère l'image SVG telle quelle, sans échappement : sinon, le navigateur afficherait son code XML. C'est sans danger ici parce que ce SVG est fabriqué par le serveur, et ne contient rien qui vienne de l'utilisateur : on ne ferait jamais cela avec une saisie. Le champ caché [captchaId] dit au serveur quel captcha vérifier ; la réponse, elle, n'est jamais envoyée au navigateur. Le lien « Autre image » recharge la page de connexion, et donc le captcha.
11.20.2. Les macros : templates/macros.html.twig
| {# =========================================================================
templates/macros.html.twig - les macros des formulaires (cf. exemple 15).
champ(champ, champsInvalides) : un champ d'un formulaire du composant Form,
avec son libellé, son message d'erreur ou son aide (form_row, dessiné par le
thème Bootstrap 5, cf. exemple 18). Le champ est encadré en rouge (classe
is-invalid) s'il a une erreur de saisie (le thème s'en charge), ou si une erreur
MÉTIER le désigne : son nom est dans la liste [champsInvalides] transmise par
l'action (un login déjà pris...), le message étant en bas de page.
Les attributs du champ (maxlength...) sont complétés, pas remplacés : merge.
========================================================================= #}
{% macro champ(champ, champsInvalides = []) %}
{% set attr = champ.vars.attr %}
{% if champ.vars.name in champsInvalides %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' is-invalid')|trim}) %}
{% endif %}
{{ form_row(champ, {attr: attr}) }}
{% endmacro %}
{# les champs d'identité (titre, prénom, nom), communs à trois formulaires :
médecin/client de l'ADMIN, "Créer un compte client" et "Gérer mon compte" #}
{% macro identite(formulaire, champsInvalides = []) %}
{# import _self : les macros de CE fichier, pour appeler la macro champ #}
{% import _self as m %}
{{ m.champ(formulaire.titre, champsInvalides) }}
<div class="row g-3">
<div class="col-sm-6">{{ m.champ(formulaire.prenom, champsInvalides) }}</div>
<div class="col-sm-6">{{ m.champ(formulaire.nom, champsInvalides) }}</div>
</div>
{% endmacro %}
|
Deux macros (exemple 15), utilisées par les trois pages qui affichent un formulaire « personne ».
- lignes 12-18 : un champ d'un formulaire du composant Form, écrit par [form_row] (exemple 18) : son libellé, sa zone de saisie, son message d'erreur ou son aide, avec les classes du thème [Bootstrap] 5 déclaré dans [config/packages/twig.yaml]. Le thème encadre en rouge un champ qui a une erreur de saisie (classe [is-invalid]). La macro l'ajoute aussi à un champ désigné par une erreur de la couche métier : la liste [champsInvalides] transmise par l'action ([PersonnesController::CHAMPS_ERREURS_METIER]). Un login déjà pris n'est pas une erreur de saisie (il a la bonne forme), mais l'utilisateur doit voir quel champ corriger. [champ.vars] : les propriétés de la vue du champ, dont son nom ([name]) et ses attributs HTML ([attr]) ; ceux-ci sont complétés ([merge]), pas remplacés : le champ garde son [maxlength]. Le second paramètre de [form_row] remplace des propriétés de la vue du champ ;
- lignes 22-30 : les champs d'identité, communs à trois formulaires : le titre, puis le prénom et le nom côte à côte (la grille de [Bootstrap] : [row], [col-sm-6]). Une macro ne voit pas les autres macros de son fichier : [{% import _self as m %}] (ligne 24) les lui importe.
11.20.3. Les pages
11.20.3.1. templates/login.html.twig
| {# =========================================================================
templates/login.html.twig - le formulaire de connexion (cf. AuthController).
Un <form> HTML ordinaire, écrit à la main : le navigateur envoie lui-même login,
mot de passe et réponse au captcha en POST /login, sans JavaScript. En cas
d'échec, le serveur renvoie CETTE page, avec le login déjà saisi, un NOUVEAU
captcha et l'erreur, affichée en bas de page par le gabarit.
========================================================================= #}
{% extends 'layout.html.twig' %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-sm-8 col-md-6 col-lg-5">
<div class="card p-4">
<h5 class="card-title mb-3">{{ 'LOGIN.TITLE'|trans }}</h5>
<form method="post" action="{{ path('connexion') }}">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<div class="mb-3">
<label class="form-label" for="login">{{ 'LOGIN.LOGIN_LABEL'|trans }}</label>
{# autofocus : un attribut booléen, écrit si la condition est vraie, omis
sinon. Le curseur va au premier champ à remplir #}
<input id="login" name="login" type="text" class="form-control" autocomplete="username"
value="{{ login }}" required{{ login == '' ? ' autofocus' }}>
</div>
<div class="mb-3">
<label class="form-label" for="password">{{ 'LOGIN.PASSWORD_LABEL'|trans }}</label>
<input id="password" name="password" type="password" class="form-control"
autocomplete="current-password" required{{ login != '' ? ' autofocus' }}>
</div>
{% include 'fragments/captcha.html.twig' %}
<button type="submit" class="btn btn-primary w-100">{{ 'LOGIN.SUBMIT'|trans }}</button>
{# pas encore de compte : un patient peut en créer un lui-même. Ce n'est
PAS un simple lien : c'est un second bouton d'envoi de CE formulaire,
qui part vers une autre adresse (formaction). Le serveur y vérifie le
captcha ci-dessus avant d'ouvrir la page de création de compte (cf.
InscriptionController). formnovalidate : le navigateur n'exige pas
login et mot de passe (inutiles pour créer un compte). Entrée dans un
champ déclenche toujours le PREMIER bouton du formulaire, "Se connecter". #}
<button type="submit" class="btn btn-outline-primary w-100 mt-3"
formaction="{{ path('inscription_acces') }}" formnovalidate>{{ 'LOGIN.CREATE_ACCOUNT'|trans }}</button>
<div class="form-text text-center">{{ 'LOGIN.CREATE_ACCOUNT_HELP'|trans }}</div>
</form>
<div class="text-muted small mt-3">
<span>{{ 'LOGIN.DEMO_ACCOUNTS'|trans }}</span>
<ul class="mb-0 mt-1">
<li><code>admin</code> - <span>{{ 'LOGIN.DEMO_ADMIN'|trans }}</span></li>
<li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{{ 'LOGIN.DEMO_DOCTOR'|trans }}</span></li>
<li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{{ 'LOGIN.DEMO_USER'|trans }}</span></li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
|
Le formulaire de connexion, un [<form>] écrit à la main, envoyé en POST à [/login] (la route [connexion]).
- lignes 22-28 : le login saisi est réaffiché après un échec ([login]). L'attribut [autofocus] place le curseur dans le premier champ à remplir : le login s'il est vide, le mot de passe sinon (après un échec, ou après la création d'un compte). [{{ login == '' ? ' autofocus' }}] : l'opérateur ternaire sans « sinon » rend une chaîne vide si la condition est fausse ;
- ligne 31 : le captcha ;
- lignes 42-43 : le bouton Créer un compte client n'est pas un lien : c'est un second bouton d'envoi du même formulaire, qui l'envoie à une autre adresse (attribut [formaction] du bouton). Le serveur y vérifie le captcha avant d'ouvrir la page de création de compte ([InscriptionController::acces]). [formnovalidate] : le navigateur n'exige pas le login et le mot de passe (attributs [required]), inutiles pour créer un compte. La touche Entrée, dans un champ, déclenche toujours le premier bouton d'envoi du formulaire : Se connecter.
11.20.3.2. templates/agenda.html.twig
| {# =========================================================================
templates/agenda.html.twig - la page principale (cf. AgendaController).
Trois blocs :
1. le choix du médecin et du jour ;
2. l'agenda ;
3. la fenêtre de réservation (fragment reservation).
Une éventuelle erreur (date invalide, médecin inconnu) est affichée en bas de
la page par le gabarit (fragment erreurs).
========================================================================= #}
{% extends 'layout.html.twig' %}
{% block contenu %}
{# 1. choix du médecin et du jour : un formulaire GET, qui recharge simplement
cette page avec ?idMedecin=...&jour=... dans l'URL #}
<form method="get" action="{{ path('agenda') }}" class="card p-3 mb-3">
<div class="row g-2 align-items-end">
<div class="col-sm-5">
<label class="form-label" for="idMedecin">{{ 'DOCTOR_DAY_PICKER.DOCTOR_LABEL'|trans }}</label>
<select id="idMedecin" name="idMedecin" class="form-select" required>
<option value="">{{ 'DOCTOR_DAY_PICKER.CHOOSE_DOCTOR'|trans }}</option>
{% for medecin in medecins %}
<option value="{{ medecin.id }}"{{ medecin.selectionne ? ' selected' }}>{{ medecin.libelle }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-4">
<label class="form-label" for="jour">{{ 'DOCTOR_DAY_PICKER.DAY_LABEL'|trans }}</label>
<input id="jour" name="jour" type="date" class="form-control" value="{{ jour }}" required>
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-primary w-100">{{ 'DOCTOR_DAY_PICKER.VIEW_AGENDA'|trans }}</button>
</div>
</div>
</form>
{# patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" #}
{% if info %}
<div class="alert alert-info">
<span>{{ info|trans }}</span>
<a href="{{ path('espace') }}" class="alert-link">{{ 'APP.MENU_MY_SPACE'|trans }}</a>
</div>
{% endif %}
{# 2. l'agenda #}
{% if agenda %}
<div class="card p-3">
<h5>{{ 'AGENDA.TITLE_PREFIX'|trans }} {{ agenda.jour }} - {{ agenda.medecin }}</h5>
{% if agenda.lignes is not empty %}
<table class="table table-hover align-middle">
<thead>
<tr>
<th>{{ 'AGENDA.COLUMN_SLOT'|trans }}</th>
<th>{{ 'AGENDA.COLUMN_STATUS'|trans }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ligne in agenda.lignes %}
<tr{{ ligne.reservable ? ' class="creneau-libre"' }}>
<td>{{ ligne.horaire }}</td>
<td>
{# [patient] n'est renseigné que si l'utilisateur a le droit de voir ce
nom (ADMIN, DOCTOR, ou le patient pour SON rendez-vous) - sinon,
simplement « Réservé » #}
{% if ligne.libre %}
<span class="badge text-bg-success">{{ 'AGENDA.FREE'|trans }}</span>
{% elseif ligne.patient %}
<span class="badge {{ ligne.moi ? 'text-bg-primary' : 'text-bg-secondary' }}">{{ ligne.patient }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ 'AGENDA.BOOKED_BY_OTHER'|trans }}</span>
{% endif %}
</td>
<td>
{# les boutons affichés ont été décidés par le contrôleur (reservable,
idRv) selon le rôle de l'utilisateur. "Réserver" est un simple
lien : il rouvre cette page avec la fenêtre de réservation de ce créneau #}
{% if ligne.reservable %}
<a class="btn btn-sm btn-outline-success" href="{{ ligne.urlReserver }}">{{ 'AGENDA.BOOK'|trans }}</a>
{% endif %}
{# "Annuler" MODIFIE des données : c'est donc un POST #}
{% if ligne.idRv %}
<form method="post" action="{{ path('agenda_annuler') }}" class="m-0"
data-confirmation="{{ 'AGENDA.CANCEL_CONFIRM'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<input type="hidden" name="idRv" value="{{ ligne.idRv }}">
<input type="hidden" name="idMedecin" value="{{ idMedecin }}">
<input type="hidden" name="jour" value="{{ jour }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'AGENDA.CANCEL_APPOINTMENT'|trans }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mb-0">{{ 'AGENDA.NO_SLOTS'|trans }}</p>
{% endif %}
</div>
{% else %}
<p class="text-muted">{{ 'AGENDA.EMPTY_STATE'|trans }}</p>
{% endif %}
{# 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... #}
{% if reservation %}
{% include 'fragments/reservation.html.twig' %}
{% endif %}
{% endblock %}
|
La page principale. Trois blocs : le choix du médecin et du jour, l'agenda, la fenêtre de réservation.
- lignes 15-34 : le choix du médecin et du jour : un formulaire GET (exemple 17), qui recharge la page avec [?idMedecin=...&jour=...] dans l'URL. Le jour est un champ [type="date"] : le navigateur affiche un calendrier, et envoie la date au format [aaaa-mm-jj], quelle que soit la façon dont il l'affiche ([26/09/2026] en français) ;
- lignes 37-42 : pour un patient qui a déjà un rendez-vous à venir, pourquoi il n'y a pas de bouton Réserver ;
- lignes 66-72 : l'état du créneau : Libre (en vert), le nom du patient (en bleu pour le rendez-vous du patient connecté lui-même, en gris sinon), ou « Réservé ». La vue ne décide de rien : [ligne.patient] n'est renseigné que si l'utilisateur a le droit de voir ce nom ;
- lignes 78-80 : Réserver est un simple lien : il rouvre la page, avec la fenêtre de réservation de ce créneau ([ligne.urlReserver]). Il ne modifie rien : un GET suffit ;
- lignes 82-91 : Annuler modifie des données : c'est un formulaire POST (exemple 21), avec son jeton anti-CSRF, le rendez-vous à annuler, et de quoi revenir à la même page (le médecin, le jour). L'attribut [data-confirmation] fait demander une confirmation par le script de la page ;
- lignes 106-108 : la fenêtre de réservation, si l'URL la demande (et si le créneau est réservable).
11.20.3.3. templates/personnes/liste.html.twig
| {# =========================================================================
templates/personnes/liste.html.twig - la liste des médecins OU des clients
(ADMIN). Une seule vue pour les deux : les clés des libellés propres à chacun
arrivent dans [cles], et [section] ("medecins" ou "clients") donne le début des
noms des routes (cf. PersonnesController) : section ~ '_ajout' -> "medecins_ajout".
========================================================================= #}
{% extends 'layout.html.twig' %}
{% block contenu %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">{{ cles.titre|trans }}</h4>
<a class="btn btn-primary" href="{{ path(section ~ '_ajout') }}">{{ cles.ajouter|trans }}</a>
</div>
<div class="card p-3">
{% if personnes is not empty %}
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>{{ 'PERSONNES.COLUMN_TITLE'|trans }}</th>
<th>{{ 'PERSONNES.COLUMN_FIRSTNAME'|trans }}</th>
<th>{{ 'PERSONNES.COLUMN_LASTNAME'|trans }}</th>
<th>{{ 'PERSONNES.COLUMN_LOGIN'|trans }}</th>
<th></th>
</tr>
</thead>
<tbody>
{# personne.titre : Twig appelle l'accesseur getTitre() (cf. exemple 13) #}
{% for personne in personnes %}
<tr>
<td>{{ personne.titre }}</td>
<td>{{ personne.prenom }}</td>
<td>{{ personne.nom }}</td>
<td><code>{{ personne.compte.login }}</code></td>
<td class="text-end">
<div class="d-inline-flex gap-2">
<a class="btn btn-sm btn-outline-primary"
href="{{ path(section ~ '_modification', {id: personne.id}) }}">{{ 'PERSONNES.EDIT'|trans }}</a>
{# data-confirmation : une fenêtre de confirmation s'ouvrira avant
l'envoi du formulaire (cf. public/js/app.js) #}
<form method="post" action="{{ path(section ~ '_supprimer', {id: personne.id}) }}" class="m-0"
data-confirmation="{{ cles.confirmerSuppression|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'PERSONNES.DELETE'|trans }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted mb-0">{{ cles.vide|trans }}</p>
{% endif %}
</div>
{% endblock %}
|
La liste des médecins ou des clients : les libellés propres à chacun sont des clés de traduction ([cles]), et les noms des routes sont fabriqués à partir du nom de la section ([path(section ~ '_modification', {id: personne.id})] : l'opérateur [~] concatène, et donne [medecins_modification] ou [clients_modification]). [personne.compte.login] : le login du compte, chargé avec la liste (couche DAO). Le bouton Supprimer est un formulaire POST, avec confirmation.
| {# =========================================================================
templates/personnes/formulaire.html.twig - création OU modification d'un
médecin ou d'un client (ADMIN), avec le formulaire PersonneType.
Quand la saisie est refusée (champ vide, login déjà pris...), l'action
réaffiche CETTE page, et la vue montre :
- les valeurs déjà saisies (sauf le mot de passe) ;
- les champs fautifs encadrés en rouge (classe Bootstrap "is-invalid") ;
- sous un champ refusé par la validation, son message ;
- le message d'erreur de la page, EN BAS de la page (fragment erreurs).
form_start écrit la balise <form> (méthode POST, URL de la page) ; form_end, la
fin du formulaire, avec les champs qui n'ont pas encore été écrits : le jeton
anti-CSRF, et, en modification, le champ caché de la version (le verrou optimiste).
========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-3">{{ titrePage|trans }}</h5>
{{ form_start(formulaire) }}
{# form_errors : les erreurs du formulaire lui-même (jeton anti-CSRF faux) #}
{{ form_errors(formulaire) }}
{# titre, prénom, nom : une macro commune à trois formulaires #}
{{ m.identite(formulaire, champsInvalides) }}
<h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION'|trans }}</h6>
{{ m.champ(formulaire.login, champsInvalides) }}
{# jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur #}
{{ m.champ(formulaire.password) }}
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{{ 'PERSONNES.SAVE'|trans }}</button>
<a class="btn btn-secondary" href="{{ urlListe }}">{{ 'PERSONNES.BACK'|trans }}</a>
</div>
{{ form_end(formulaire) }}
</div>
</div>
</div>
{% endblock %}
|
La création ou la modification d'un médecin ou d'un client, avec le formulaire [PersonneType].
- ligne 16 : l'importation des macros, sous le nom [m] ;
- ligne 24 : [form_start] écrit la balise [<form>] : la méthode POST, l'URL de la page, et l'attribut [novalidate] (option [attr] du type) ;
- ligne 26 : [form_errors(formulaire)] affiche les erreurs du formulaire lui-même, qui ne concernent aucun champ : un jeton anti-CSRF faux (« La page a expiré... ») ;
- ligne 28 : titre, prénom et nom, par la macro [identite] ;
- lignes 31-33 : le login, et le mot de passe, jamais pré-rempli. Leurs aides diffèrent en création et en modification (options du type) ;
- ligne 39 : [form_end] ferme le formulaire, après avoir écrit les champs qui ne l'ont pas encore été : le jeton anti-CSRF, et, en modification, le champ caché de la version (le verrou optimiste).
11.20.3.5. templates/inscription.html.twig
| {# =========================================================================
templates/inscription.html.twig - "Créer un compte client" (page publique, cf.
InscriptionController), avec le formulaire InscriptionType. Mêmes champs
d'identité que le formulaire de l'ADMIN (macro identite), mais le login et le
mot de passe sont obligatoires, avec confirmation. Pas de captcha ICI : on
n'arrive sur cette page qu'après avoir recopié celui de la page
d'authentification (bouton "Créer un compte client").
========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4">
<h5 class="card-title mb-1">{{ 'INSCRIPTION.TITLE'|trans }}</h5>
<p class="text-muted small mb-3">{{ 'INSCRIPTION.INTRO'|trans }}</p>
{{ form_start(formulaire) }}
{{ form_errors(formulaire) }}
{{ m.identite(formulaire, champsInvalides) }}
<h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION'|trans }}</h6>
{{ m.champ(formulaire.login, champsInvalides) }}
{# le champ répété : ses deux saisies, côte à côte (first, second) #}
<div class="row g-3">
<div class="col-sm-6">{{ m.champ(formulaire.password.first) }}</div>
<div class="col-sm-6">{{ m.champ(formulaire.password.second) }}</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">{{ 'INSCRIPTION.SUBMIT'|trans }}</button>
<a class="btn btn-secondary" href="{{ path('login') }}">{{ 'INSCRIPTION.BACK_TO_LOGIN'|trans }}</a>
</div>
{{ form_end(formulaire) }}
</div>
</div>
</div>
{% endblock %}
|
La création d'un compte client : les champs d'identité, puis le login, le mot de passe et sa confirmation, obligatoires. Les deux champs du champ répété sont [formulaire.password.first] et [formulaire.password.second] (lignes 27-28), placés côte à côte. L'aide du mot de passe annonce la longueur minimale : « Au moins 6 caractères. ». Pas de captcha ici : on n'arrive sur cette page qu'après avoir recopié celui de la page de connexion.
11.20.3.6. templates/espace.html.twig
| {# =========================================================================
templates/espace.html.twig - "Mon espace" : la page d'accueil du patient (rôle
USER), affichée juste après sa connexion.
- deux options : [Gérer mon compte] et [Voir un agenda] ;
- son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
========================================================================= #}
{% extends 'layout.html.twig' %}
{% block contenu %}
<h4 class="mb-3">{{ 'ESPACE.HELLO'|trans({nom: patient}) }}</h4>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="{{ path('compte') }}">
<h5 class="mb-1">{{ 'ESPACE.MANAGE_ACCOUNT'|trans }}</h5>
<span class="text-muted">{{ 'ESPACE.MANAGE_ACCOUNT_HELP'|trans }}</span>
</a>
</div>
<div class="col-sm-6">
<a class="card p-4 h-100 text-decoration-none carte-option" href="{{ path('agenda') }}">
<h5 class="mb-1">{{ 'ESPACE.VIEW_AGENDA'|trans }}</h5>
<span class="text-muted">{{ 'ESPACE.VIEW_AGENDA_HELP'|trans }}</span>
</a>
</div>
</div>
<div class="card p-3">
<h5>{{ 'ESPACE.MY_APPOINTMENT'|trans }}</h5>
{# normalement un seul (règle "un rendez-vous à venir à la fois"), mais la
page sait en afficher plusieurs. {% for %} ... {% else %} : une boucle, et
ce qu'on affiche si la liste est vide ; loop.last : dernier élément ? #}
{% for rv in rendezVous %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 py-2{{ not loop.last ? ' border-bottom' }}">
<div>
<div class="fw-semibold">{{ rv.jour }}, {{ rv.horaire }}</div>
<div class="text-muted">
<span>{{ rv.medecin }}</span> -
<a href="{{ rv.urlAgenda }}">{{ 'ESPACE.SEE_IN_AGENDA'|trans }}</a>
</div>
</div>
<form method="post" action="{{ path('espace_annuler') }}" class="m-0"
data-confirmation="{{ 'AGENDA.CANCEL_CONFIRM'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<input type="hidden" name="idRv" value="{{ rv.id }}">
<button type="submit" class="btn btn-outline-danger">{{ 'ESPACE.CANCEL_APPOINTMENT'|trans }}</button>
</form>
</div>
{% else %}
<p class="text-muted mb-0">{{ 'ESPACE.NO_APPOINTMENT'|trans }}</p>
{% endfor %}
</div>
{% endblock %}
|
« Mon espace », la page d'accueil du patient : deux grandes cartes (Gérer mon compte, Voir un agenda), et son rendez-vous à venir.
- lignes 32-50 : [{% for %}] ... [{% else %}] (exemple 14) est une boucle, suivie de ce qu'il faut afficher si la liste est vide. Normalement, un patient a au plus un rendez-vous à venir ; mais la page sait en afficher plusieurs. [loop.last] (exemple 14) : le dernier élément n'a pas de trait de séparation ;
- chaque rendez-vous a un formulaire d'annulation (POST, avec confirmation), qui n'envoie que son id : le serveur vérifiera qu'il appartient bien au patient connecté.
11.20.3.7. templates/compte.html.twig
| {# =========================================================================
templates/compte.html.twig - "Gérer mon compte" (rôle USER).
Deux formulaires indépendants sur la même page :
1. la modification (identité, login, nouveau mot de passe facultatif), avec
le formulaire MonCompteType ;
2. la suppression du compte - qui annule aussi ses rendez-vous : un
formulaire écrit à la main.
L'un comme l'autre exigent le mot de passe ACTUEL (champ current_password).
========================================================================= #}
{% extends 'layout.html.twig' %}
{% import 'macros.html.twig' as m %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
{# 1. modification #}
<div class="card p-4 mb-4">
<h5 class="card-title mb-3">{{ 'MON_COMPTE.TITLE'|trans }}</h5>
{{ form_start(formulaire) }}
{{ form_errors(formulaire) }}
{{ m.identite(formulaire, champsInvalides) }}
<h6 class="mt-4 text-muted">{{ 'PERSONNES.ACCOUNT_SECTION'|trans }}</h6>
{{ m.champ(formulaire.login, champsInvalides) }}
<div class="row g-3">
<div class="col-sm-6">{{ m.champ(formulaire.password.first) }}</div>
<div class="col-sm-6">{{ m.champ(formulaire.password.second) }}</div>
</div>
{{ m.champ(formulaire.current_password) }}
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">{{ 'PERSONNES.SAVE'|trans }}</button>
<a class="btn btn-secondary" href="{{ path('espace') }}">{{ 'MON_COMPTE.BACK'|trans }}</a>
</div>
{{ form_end(formulaire) }}
</div>
{# 2. suppression. Le champ du mot de passe a le même NOM que celui du premier
formulaire (current_password), mais un autre id : deux éléments d'une même
page ne peuvent pas avoir le même id #}
<div class="card p-4 border-danger">
<h5 class="card-title text-danger mb-1">{{ 'MON_COMPTE.DELETE_TITLE'|trans }}</h5>
<p class="text-muted small">{{ 'MON_COMPTE.DELETE_HELP'|trans }}</p>
<form method="post" action="{{ path('compte_supprimer') }}" class="d-flex flex-wrap gap-2 align-items-end"
data-confirmation="{{ 'MON_COMPTE.DELETE_CONFIRM'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('rdvmedecins') }}">
<div class="flex-grow-1">
<label class="form-label" for="mot_de_passe_suppression">{{ 'MON_COMPTE.CURRENT_PASSWORD_LABEL'|trans }}</label>
<input id="mot_de_passe_suppression" name="current_password" type="password" maxlength="72"
autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-danger">{{ 'MON_COMPTE.DELETE_SUBMIT'|trans }}</button>
</form>
</div>
</div>
</div>
{% endblock %}
|
« Gérer mon compte » : deux formulaires indépendants sur une même page.
- lignes 18-38 : la modification, avec le formulaire [MonCompteType] : les champs d'identité, le login, le nouveau mot de passe et sa confirmation (facultatifs), et le mot de passe actuel, obligatoire. La version (verrou optimiste) est écrite par [form_end] ;
- lignes 43-56 : la suppression du compte, un formulaire écrit à la main, avec son propre champ de mot de passe actuel, et une confirmation. Le champ a le nom [current_password] (le champ du premier formulaire s'appelle, lui, [mon_compte[current_password]] : le composant Form préfixe les noms de ses champs par le nom du formulaire), et un id qui lui est propre : deux éléments d'une page ne peuvent pas avoir le même id, que l'attribut [for] d'un libellé désigne.
11.20.3.8. templates/erreur.html.twig
| {# =========================================================================
templates/erreur.html.twig - la page d'erreur (400, 403, 404, 405, 419,
500...), fabriquée par src/EventListener/PageErreur.php.
========================================================================= #}
{% extends 'layout.html.twig' %}
{% block contenu %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4 text-center">
<h1 class="display-4 text-danger">{{ code }}</h1>
<p class="lead">{{ message|trans }}</p>
<div><a class="btn btn-primary" href="{{ path('accueil') }}">{{ 'ERROR_PAGE.BACK'|trans }}</a></div>
</div>
</div>
</div>
{% endblock %}
|
La page d'erreur, fabriquée par l'écouteur [PageErreur] : le code HTTP, le message, et un lien vers l'accueil ([/], qui redirige vers la page d'accueil du rôle, ou vers la page de connexion).
11.21. Les fichiers statiques
Le dossier [public] est la racine du site : le serveur envoie tels quels les fichiers qui s'y trouvent (exemple 15). Outre [index.php], il contient [Bootstrap] ([lib/bootstrap]), une feuille de style et un script.
11.21.1. public/css/styles.css
| /* =========================================================================
public/css/styles.css - quelques réglages en plus de Bootstrap
(les mêmes que dans les autres versions de cette application).
========================================================================= */
body {
background-color: #f5f7fa;
}
.creneau-libre:hover {
background-color: #e9f7ef;
}
/* -------------------------------------------------------------------------
.zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
(cf. templates/fragments/erreurs.html.twig).
position: sticky + bottom : si la page est plus haute que la fenêtre (un
agenda de 12 créneaux, par exemple), la zone reste collée au bas de la
fenêtre au lieu de disparaître sous la ligne de flottaison - sans jamais
recouvrir le contenu quand la page est courte.
z-index : au-dessus du fond grisé de la fenêtre de réservation (.modal-backdrop).
------------------------------------------------------------------------- */
.zone-erreur {
position: sticky;
bottom: 1rem;
z-index: 1060;
margin-top: 1.5rem;
padding: 0.75rem 1rem;
background-color: #fdecef; /* rose léger */
border: 1px solid #f5c2c7;
border-radius: 0.375rem;
color: #842029;
}
/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
transition: border-color 0.15s, box-shadow 0.15s;
}
.carte-option:hover {
border-color: #0d6efd;
box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}
/* l'image du captcha (SVG de 150 x 50 pixels) */
.captcha-image svg {
display: block;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
|
Quelques réglages en plus de [Bootstrap], les mêmes que dans les autres versions de l'application.
- lignes 10-12 : une ligne réservable de l'agenda se colore en vert au passage de la souris ;
- lignes 23-33 : la zone d'erreur, en bas de la page, sur fond rose léger. [position: sticky] avec [bottom: 1rem] : si la page est plus haute que la fenêtre (un agenda de 12 créneaux), la zone reste collée au bas de la fenêtre, au lieu de disparaître sous la ligne de flottaison ; si la page est courte, elle reste à sa place, sans rien recouvrir. [z-index] : elle passe au-dessus du fond grisé de la fenêtre de réservation ;
- lignes 36-43 : les deux grandes cartes de « Mon espace », qui s'éclairent au passage de la souris ;
- lignes 46-50 : l'image du captcha, avec une bordure.
11.21.2. public/js/app.js
| // =========================================================================
// public/js/app.js - le SEUL JavaScript de l'application.
// -------------------------------------------------------------------------
// Toute l'application fonctionne sans JavaScript. Ce script ajoute un
// confort : avant d'envoyer un formulaire qui porte l'attribut
// data-confirmation="...", une fenêtre modale demande "Êtes-vous sûr ?"
// (la fenêtre elle-même est dans la page : templates/fragments/confirmation.html.twig).
// Si le JavaScript est désactivé, le formulaire part simplement sans
// confirmation - rien ne casse (principe d'"amélioration progressive").
//
// Pourquoi pas window.confirm() ? Sa boîte ne se met pas en forme, et ses
// boutons restent dans la langue du navigateur, pas dans celle de la page.
// =========================================================================
(() => {
const modale = document.getElementById('modale-confirmation');
if (!modale) {
return;
}
const message = document.getElementById('modale-confirmation-message');
const boutonNon = modale.querySelector('.modal-footer [data-action="non"]');
let formulaireEnAttente = null; // le formulaire à envoyer si l'on confirme
let focusPrecedent = null; // l'élément à qui rendre le focus à la fermeture
function ouvrir(formulaire) {
formulaireEnAttente = formulaire;
focusPrecedent = document.activeElement;
// textContent (et non innerHTML) : le message est affiché comme du TEXTE,
// jamais interprété comme du HTML
message.textContent = formulaire.dataset.confirmation;
modale.classList.remove('d-none');
// le focus va sur "Non" : un appui distrait sur Entrée ne supprime rien
boutonNon.focus();
}
function fermer() {
modale.classList.add('d-none');
formulaireEnAttente = null;
if (focusPrecedent) {
focusPrecedent.focus();
}
}
// 1. on intercepte l'envoi des formulaires à confirmer
document.querySelectorAll('form[data-confirmation]').forEach((formulaire) => {
formulaire.addEventListener('submit', (evenement) => {
evenement.preventDefault();
ouvrir(formulaire);
});
});
// 2. les boutons de la fenêtre (Oui / Non / croix de fermeture)
modale.addEventListener('click', (evenement) => {
const action = evenement.target.closest('[data-action]')?.dataset.action;
if (action === 'oui') {
const formulaire = formulaireEnAttente;
fermer();
// formulaire.submit() envoie le formulaire SANS redéclencher
// l'événement 'submit' : pas de nouvelle demande de confirmation
formulaire.submit();
} else if (action === 'non') {
fermer();
} else if (evenement.target.classList.contains('modal')) {
fermer(); // clic sur le fond grisé, en dehors de la fenêtre
}
});
// 3. clavier : Échap ferme la fenêtre ; Tab reste DANS la fenêtre
// (sinon le focus partirait sur la page grisée, derrière)
modale.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') {
fermer();
} else if (evenement.key === 'Tab') {
const boutons = [...modale.querySelectorAll('button')];
const premier = boutons[0];
const dernier = boutons[boutons.length - 1];
if (evenement.shiftKey && document.activeElement === premier) {
evenement.preventDefault();
dernier.focus();
} else if (!evenement.shiftKey && document.activeElement === dernier) {
evenement.preventDefault();
premier.focus();
}
}
});
})();
|
Le seul [JavaScript] de l'application, celui de l'exemple 21, un peu complété. Toute l'application fonctionne sans [JavaScript] : ce script ajoute un confort, une confirmation avant l'envoi d'un formulaire qui porte l'attribut [data-confirmation]. Si le [JavaScript] est désactivé, le formulaire part sans confirmation : rien ne casse (principe d'amélioration progressive).
- lignes 15-19 : le script est une fonction appelée aussitôt définie ([(() => { ... })()]) : ses variables ne sont pas visibles du reste de la page. Sans fenêtre de confirmation dans la page, il ne fait rien ;
- lignes 26-35 : l'ouverture de la fenêtre : le formulaire à envoyer si l'on confirme est mémorisé, et le message du formulaire est placé dans la fenêtre. [textContent], et non [innerHTML] : le message est affiché comme du texte, jamais interprété comme du HTML. Le focus va sur le bouton « Non » : un appui distrait sur Entrée ne supprime rien ;
- lignes 37-43 : la fermeture ; le focus revient à l'élément qui l'avait avant ;
- lignes 46-51 : l'envoi de chaque formulaire à confirmer est intercepté ([preventDefault] l'empêche), et la fenêtre s'ouvre ;
- lignes 54-67 : les boutons de la fenêtre. Un seul gestionnaire pour toute la fenêtre : [closest('[data-action]')] trouve le bouton cliqué, ou l'élément qui le contient. « Oui » envoie le formulaire par [formulaire.submit()], qui ne déclenche pas l'événement [submit] : pas de nouvelle demande de confirmation. Un clic sur le fond grisé, en dehors de la fenêtre, la ferme ;
- lignes 71-86 : le clavier. Échap ferme la fenêtre ; la touche Tab fait passer le focus d'un bouton à l'autre sans sortir de la fenêtre (sinon, il partirait sur la page grisée, derrière elle) : c'est ce qu'attendent les utilisateurs qui naviguent au clavier, et les lecteurs d'écran.