Skip to content

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 [Laravel], de l'application [Flask] du cours précédent (elle-même transposée des versions [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 [Blade] ; l'accès à la base passe par [Eloquent].

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

Image

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 :

Image

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

Image

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 :

Image

Image

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 :

Image

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

Image

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

Image

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 :

Image

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 :

Image

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

Image

Image

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

Image

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 :

Image

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 :

Image

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 :

Image

11.2.4. La gestion des médecins et des clients

L'administrateur a deux menus supplémentaires, Médecins et Clients :

Image

Image

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

Image

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

Image

La suppression d'un client demande une confirmation :

Image

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

Image

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

Image

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 :

Image

11.3. Installation et exécution

L'application se trouve dans le dossier [rdvmedecins2-laravel], à 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 l'extension [pdo_mysql] (l'application utilise des fonctions apparues avec la version 8.4, comme [array_find]). On vérifie l'installation dans un terminal :
php -v
php -m

La seconde commande liste les extensions : [pdo_mysql] doit 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'application a été testée avec lui.

L'éditeur conseillé est [Visual Studio Code], avec les extensions [PHP Intelephense] et [Laravel] (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-laravel] (il faut être connecté à Internet) :

composer install

[Composer] lit le fichier [composer.json] (commenté plus loin), télécharge [Laravel], [firebase/php-jwt] et les bibliothèques dont ils dépendent, et fabrique le chargeur de classes [vendor/autoload.php].

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.

11.3.4. Configurer l'accès à la base

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, à la ligne [DB_PASSWORD=]. Une variable d'environnement du système l'emporte sur le fichier (exemple 25) : on peut ainsi donner le mot de passe sans l'écrire dans un fichier du projet.

11.3.5. Lancer l'application

Dans un terminal ouvert sur le dossier [rdvmedecins2-laravel] :

php artisan serve --port=8081
1
2
3
   INFO  Server running on [http://127.0.0.1:8081].

  Press Ctrl+C to stop the server

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

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 :

  • [DB_LOG=true] : la console du serveur affiche chaque ordre SQL envoyé à la base, et chaque étape des transactions (exemple 28). Par exemple, pour l'affichage d'un agenda :
1
2
3
4
5
6
7
8
  select * from `medecins` order by `nom` asc, `prenom` asc []
  select * from `users` where `users`.`id` in (2, 3, 4) []
  select * from `medecins` where `medecins`.`id` = ? limit 1 [1]
  select * from `users` where `users`.`id` in (2) []
  select * from `rv` where `jour` = ? and exists (select * from `creneaux` where `rv`.`id_creneau` = `creneaux`.`id` and `id_medecin` = ?) ["2026-09-26",1]
  select * from `clients` where `clients`.`id` in (3) []
  select * from `creneaux` where `id_medecin` = ? order by `hdebut` asc, `mdebut` asc [1]
  2026-09-26 18:50:14 /agenda ...................................... ~ 0.10ms
  • [APP_DEBUG=true] : une exception inattendue affiche la page de débogage de [Laravel] (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 une vue modifiée est recompilée à la requête suivante.

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

11.3.8. Un serveur de production

Le serveur de [php artisan serve] 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 ; les dossiers [storage] et [bootstrap/cache] doivent être accessibles en écriture au serveur. On prépare l'application par quelques commandes :

1
2
3
4
5
composer install --no-dev --optimize-autoloader
php artisan key:generate
php artisan config:cache
php artisan route:cache
php artisan view:cache

[key:generate] écrit une nouvelle clé de chiffrement dans le fichier [.env] ; les trois commandes suivantes réunissent la configuration, les routes et les vues compilées en quelques fichiers, lus plus vite que les originaux (à refaire après chaque modification). Dans le fichier [.env], il faut aussi indiquer le mode production, changer la clé secrète des jetons, et déclarer que le site est servi en HTTPS :

1
2
3
4
APP_ENV=production
APP_DEBUG=false
JWT_SECRET=<une longue chaîne aléatoire>
COOKIE_SECURE=true

Une chaîne aléatoire convenable s'obtient avec [PHP] lui-même : [php -r "echo bin2hex(random_bytes(32));"].

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). Avec [APP_DEBUG=true], elle est affichée par la page de débogage de [Laravel] ; dans tous les cas, elle est inscrite dans le journal [storage/logs/laravel.log]. Par exemple, avec une base qui n'existe pas :

[2026-09-26 18:55:12] local.ERROR: SQLSTATE[HY000] [1049] Unknown database 'dbinconnue' (Connection: mysql, Host: 127.0.0.1, Port: 3306, Database: dbinconnue, SQL: select * from `users` where `login` = admin limit 1) {"exception":"[object] (Illuminate\\Database\\QueryException(code: 1049): ...

Les messages les plus fréquents :

[Failed opening required '.../vendor/autoload.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 [.env] (cf. « Configurer l'accès à la base »)
[SQLSTATE[HY000] [1049] Unknown database 'dbrdvmedecins2']
le script [database/dbrdvmedecins2.sql] n'a pas été exécuté
[No application encryption key has been specified.]
la variable [APP_KEY] du fichier [.env] est vide : [php artisan key:generate]
[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 127.0.0.1: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

Image

  • le navigateur envoie ses requêtes au serveur ([php artisan serve], ou [nginx] + [PHP-FPM]), qui les confie à [Laravel] par le fichier [public/index.php]. Chacune traverse d'abord les middlewares (chapitre sur le cycle de vie) : la langue de la requête ([FixerLangue]), les cookies, la session, la protection anti-CSRF, puis l'authentification : l'utilisateur est établi à partir du jeton JWT de son cookie, et les pages protégées sont refusées aux visiteurs qui n'y ont pas droit ([Authenticate], [ExigerRoles]) ;
  • [Http/Controllers] : 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 ; [Http/Requests] : la validation des formulaires ;
  • [Auth] : les jetons JWT, 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], les messages flash, quelques utilitaires ; [Vues] : les modèles des vues ;
  • [Metier] : les règles de gestion et les transactions ;
  • [Dao] : la couche DAO, une classe par table ;
  • [Models] : les modèles [Eloquent], que la couche DAO lit et écrit dans la base ; [Domain] : les objets de l'agenda, calculés par la couche métier ;
  • [resources/views] : les vues [Blade], qui fabriquent les pages HTML ; [lang] : les dictionnaires ; [public] : la feuille de style, le script de la page et [Bootstrap] ;
  • [bootstrap/app.php] et [app/Providers/AppServiceProvider.php] assemblent le tout ; [.env] et le dossier [config] : la configuration.

Le dossier de l'application (sans [vendor], ni le contenu de [storage]) :

rdvmedecins2-laravel/
├── app/
│   ├── Auth/
│   │   ├── AuthService.php
│   │   ├── Captcha.php
│   │   ├── CaptchaService.php
│   │   ├── DessinCaptcha.php
│   │   ├── LimiteurTentatives.php
│   │   ├── PasseInscription.php
│   │   ├── README.md
│   │   ├── SessionInvalide.php
│   │   └── UtilisateurConnecte.php
│   ├── Dao/
│   │   ├── ClientDao.php
│   │   ├── CreneauDao.php
│   │   ├── MedecinDao.php
│   │   ├── PersonneDao.php
│   │   ├── README.md
│   │   ├── RvDao.php
│   │   └── UserDao.php
│   ├── Domain/
│   │   ├── AgendaMedecinJour.php
│   │   ├── CreneauMedecinJour.php
│   │   └── README.md
│   ├── Http/
│   │   ├── Controllers/
│   │   │   ├── AgendaController.php
│   │   │   ├── AuthController.php
│   │   │   ├── ClientsController.php
│   │   │   ├── EspaceController.php
│   │   │   ├── InscriptionController.php
│   │   │   ├── LangueController.php
│   │   │   ├── MedecinsController.php
│   │   │   └── PersonnesController.php
│   │   ├── Middleware/
│   │   │   ├── ExigerRoles.php
│   │   │   ├── FixerLangue.php
│   │   │   └── VerifierPasseInscription.php
│   │   ├── README.md
│   │   └── Requests/
│   │       ├── ChampsPersonneRequest.php
│   │       ├── InscriptionRequest.php
│   │       ├── MonCompteRequest.php
│   │       └── PersonneRequest.php
│   ├── Metier/
│   │   ├── Dates.php
│   │   ├── Descripteur.php
│   │   ├── DonneesPersonne.php
│   │   ├── Metier.php
│   │   ├── MetierException.php
│   │   ├── MetierService.php
│   │   └── README.md
│   ├── Models/
│   │   ├── Client.php
│   │   ├── Creneau.php
│   │   ├── Medecin.php
│   │   ├── Personne.php
│   │   ├── README.md
│   │   ├── Role.php
│   │   ├── Rv.php
│   │   └── User.php
│   ├── Providers/
│   │   └── AppServiceProvider.php
│   ├── Vues/
│   │   ├── AgendaVue.php
│   │   ├── ClesPersonnes.php
│   │   ├── LigneAgenda.php
│   │   ├── OptionVue.php
│   │   ├── README.md
│   │   ├── RendezVousVue.php
│   │   └── ReservationVue.php
│   └── Web/
│       ├── ApplicationModel.php
│       ├── Flash.php
│       ├── Message.php
│       ├── Outils.php
│       └── README.md
├── artisan
├── bootstrap/
│   ├── app.php
│   ├── cache/
│   └── providers.php
├── composer.json
├── config/
│   ├── app.php
│   ├── auth.php
│   ├── hashing.php
│   ├── rdvmedecins.php
│   └── session.php
├── database/
│   ├── dbrdvmedecins2.sql
│   └── README.md
├── .env
├── .env.example
├── lang/
│   ├── en.json
│   └── fr.json
├── public/
│   ├── css/
│   │   └── styles.css
│   ├── index.php
│   ├── js/
│   │   └── app.js
│   └── lib/
│       └── bootstrap/
├── README.md
├── resources/
│   └── views/
│       ├── agenda.blade.php
│       ├── compte.blade.php
│       ├── components/
│       │   ├── captcha.blade.php
│       │   ├── champ.blade.php
│       │   └── champs-personne.blade.php
│       ├── erreur.blade.php
│       ├── espace.blade.php
│       ├── fragments/
│       │   ├── confirmation.blade.php
│       │   ├── erreurs.blade.php
│       │   ├── navbar.blade.php
│       │   ├── reservation.blade.php
│       │   └── succes.blade.php
│       ├── inscription.blade.php
│       ├── layout.blade.php
│       ├── login.blade.php
│       ├── personnes/
│       │   ├── formulaire.blade.php
│       │   └── liste.blade.php
│       └── README.md
├── routes/
│   └── web.php
└── storage/

C'est la structure d'une application [Laravel] ordinaire, celle que fabrique la commande [composer create-project laravel/laravel] (exemple 01), allégée de ce qui ne sert pas ici (tests, migrations, outils de construction du [JavaScript]...). Deux différences avec les exemples : l'application a son propre dossier [vendor] (les exemples partageaient celui du dossier [exemples]), et un fournisseur de services ([app/Providers/AppServiceProvider.php]), commenté plus loin. Les classes de l'application sont rangées dans le dossier [app], selon la règle PSR-4 (exemple 01) : la classe [App\Metier\MetierService] est dans le fichier [app/Metier/MetierService.php]. [Laravel] n'impose aucun de ces dossiers (exemple 06) : [Http], [Models] et [Providers] sont ceux de l'usage, les autres ([Auth], [Dao], [Metier]...) 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 réservé de 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 (cf. [config/hashing.php], plus loin) ;
  • 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 [Laravel], la base n'est pas créée par des migrations (des classes [PHP] qui décrivent la création et l'évolution des tables, exécutées par [php artisan migrate]) : elle est partagée avec les autres versions de l'application, et le script SQL est commun à toutes.

11.6. La configuration du projet

11.6.1. composer.json

{
    "name": "rdvmedecins/rdvmedecins2-laravel",
    "description": "RdvMedecins : prise de rendez-vous médicaux, pages HTML fabriquées par le serveur (Laravel + Blade)",
    "type": "project",
    "license": "MIT",
    "require": {
        "php": "^8.4",
        "ext-pdo_mysql": "*",
        "laravel/framework": "^13.33",
        "firebase/php-jwt": "^7.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    },
    "scripts": {
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover --ansi"
        ]
    },
    "config": {
        "optimize-autoloader": true,
        "sort-packages": true
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}
  • lignes 6-11 : les bibliothèques dont l'application a besoin, et les contraintes qu'elles doivent respecter. [^13.33] se lit « la version 13.33 ou une version plus récente de la même version majeure 13 » : [Composer] accepte les versions qui corrigent des erreurs ou ajoutent des fonctionnalités, mais pas celles qui pourraient casser l'application (la version 14). Les lignes [php] 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 l'extension ne conviennent pas, plutôt que de laisser l'application échouer plus tard ;
  • lignes 12-16 : la règle PSR-4 de l'application : les classes de l'espace de noms [App] sont dans le dossier [app] (exemple 01 : dans les exemples, [bootstrap/app.php] déclarait lui-même cette règle au chargeur de classes, par [addPsr4]) ;
  • lignes 18-21 : des commandes que [Composer] exécute après avoir fabriqué le chargeur de classes : [Laravel] y recense les fournisseurs de services des bibliothèques installées ([package:discover], dans [bootstrap/cache]).

[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].

11.6.2. .env

# =========================================================================
# .env : les réglages propres à CETTE installation (lus par env('NOM') dans les
# fichiers du dossier config). Ce fichier n'est jamais publié : il contient des
# secrets. Une variable d'environnement du système l'emporte sur lui.
# =========================================================================
APP_NAME=RdvMedecins
# local : développement ; production : en production
APP_ENV=local
# la clé de chiffrement de Laravel (cookies, session). En production :
# php artisan key:generate (écrit une nouvelle clé dans ce fichier)
APP_KEY=base64:+UmYSB3+TOKSr/f/fiqPCyo5IXFgXOLrvO1II52r+q4=
# true : une exception inattendue affiche la page de débogage de Laravel, avec sa
# pile d'appels (JAMAIS en production) ; false : la page d'erreur 500 de l'application
APP_DEBUG=true
APP_URL=http://localhost:8081

# ---- la base MySQL [dbrdvmedecins2] (script : database/dbrdvmedecins2.sql) :
# le serveur, son port, la base, l'utilisateur et son mot de passe (ceux de
# Laragon : root, sans mot de passe)
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dbrdvmedecins2
DB_USERNAME=root
DB_PASSWORD=
# true : les ordres SQL envoyés à la base sont affichés dans la console
DB_LOG=true

# ---- la session de Laravel (messages flash, jeton anti-CSRF, saisie à
# réafficher) : rangée dans un cookie CHIFFRÉ, le serveur ne garde rien
SESSION_DRIVER=cookie
SESSION_LIFETIME=120
# ---- le cache (listes des médecins et des clients, captchas, compteurs de
# tentatives, laissez-passer) : des fichiers, dans storage/framework/cache
CACHE_STORE=file
# le journal : storage/logs/laravel.log
LOG_CHANNEL=single

# ---- l'authentification (cf. config/rdvmedecins.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
# true en production (site servi en HTTPS) : les cookies ne circulent jamais en clair
COOKIE_SECURE=false

# ---- la protection contre les robots (cf. app/Auth/LimiteurTentatives.php)
LOGIN_MAX_ECHECS=5
LOGIN_BLOCAGE_MINUTES=15
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). Il n'est jamais publié ; le fichier [.env.example], sans secrets, lui sert de modèle.

  • ligne 11 : la clé de chiffrement de [Laravel] : elle chiffre les cookies (exemple 20), dont celui de la session. La commande [php artisan key:generate] en écrit une nouvelle ;
  • ligne 14 : en développement, une exception inattendue affiche la page de débogage de [Laravel] ; en production ([false]), la page d'erreur 500 de l'application ;
  • lignes 20-25 : la connexion à la base (exemple 28) ;
  • ligne 31 : la session de [Laravel] (exemple 23) est rangée dans un cookie chiffré, plutôt que dans des fichiers sur le serveur. L'application n'y met que peu de choses (les messages flash, le jeton anti-CSRF, la saisie d'un formulaire à réafficher) : un cookie suffit, et le serveur n'a plus rien à mémoriser entre deux requêtes. [Laravel] utilise alors deux cookies : [rdvmedecins_session] contient l'identifiant de la session, et un second cookie, qui porte cet identifiant comme nom, contient ses données ;
  • ligne 35 : le cache (exemple 20) est rangé dans des fichiers, dans le dossier [storage/framework/cache]. 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] ;
  • lignes 41-48 : les réglages propres à l'application : les jetons JWT (exemple 25) et la protection contre les robots (exemple 27).

11.6.3. config/app.php, config/session.php, config/hashing.php, config/auth.php

Les fichiers du dossier [config] complètent la configuration par défaut de [Laravel] (exemple 25) : seules les clés qu'ils écrivent changent.

<?php
// config/app.php : ce fichier COMPLÈTE la configuration par défaut de Laravel
// (vendor/laravel/framework/config/app.php) : seules les clés écrites ici changent
return [
    // la langue par défaut de l'interface (cf. lang/fr.json, lang/en.json), et la
    // langue de secours (elle ne sert qu'aux fichiers PHP du dossier lang : une clé
    // absente d'un dictionnaire JSON est affichée telle quelle)
    'locale' => 'fr',
    'fallback_locale' => 'fr',

    // le fuseau horaire : celui du cabinet médical. Il fixe la date du jour
    // (pas de rendez-vous dans le passé, cf. app/Metier/Dates.php)
    'timezone' => 'Europe/Paris',
];
  • lignes 8-9 : la langue par défaut de l'interface (exemple 22), et la langue de secours (qui ne sert qu'aux fichiers [PHP] du dossier [lang], cf. exemple 22) ;
  • ligne 13 : le fuseau horaire. Par défaut, [Laravel] travaille en 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.
1
2
3
4
5
6
7
8
9
<?php
// config/session.php : la session de Laravel (complète la configuration par défaut).
// Pilote [cookie] (cf. .env, SESSION_DRIVER) : les données de session (messages
// flash, jeton anti-CSRF, saisie à réafficher) voyagent dans un cookie CHIFFRÉ
return [
    'cookie' => 'rdvmedecins_session',
    // les cookies de l'application ne circulent qu'en HTTPS en production
    'secure' => (bool) env('COOKIE_SECURE', false),
];

Le nom du cookie de session, et l'option [secure] des cookies de session, lue dans le fichier [.env] ([(bool)] convertit la valeur en booléen ; [env] comprend déjà [false] et [true]).

<?php
// config/hashing.php : le hachage des mots de passe (complète la configuration par défaut)
return [
    'driver' => 'bcrypt',
    'bcrypt' => [
        // 10 tours, comme les hachages du script SQL
        'rounds' => 10,
        // Laravel vérifie par défaut qu'un hachage enregistré est bien un hachage
        // bcrypt... tel que PHP l'écrit ($2y$). Or la base dbrdvmedecins2 est
        // partagée avec les versions Python ($2b$) et Java ($2a$) de l'application,
        // dont PHP vérifie très bien les hachages, sans les reconnaître comme
        // "bcrypt". On désactive donc ce contrôle
        'verify' => false,
    ],
];

Le hachage des mots de passe : [bcrypt], 10 tours, comme les hachages du script SQL. La ligne 13 désactive une vérification que [Laravel] fait par défaut : avant de comparer un mot de passe à un hachage, il contrôle que ce hachage a bien été produit par l'algorithme configuré, tel que [PHP] l'écrit ([$2y$]). Les hachages de la base, écrits par [Python] ([$2b$]) ou par [Java] ([$2a$]), échoueraient à ce contrôle, bien que [PHP] sache parfaitement les vérifier. C'est le prix à payer pour partager une base avec d'autres applications.

<?php
// config/auth.php : l'authentification (complète la configuration par défaut)
return [
    // la "garde" (guard) utilisée par défaut : celle qui lit le jeton JWT du cookie
    'defaults' => [
        'guard' => 'jwt',
    ],
    'guards' => [
        // une garde de pilote [jwt], défini par AppServiceProvider (Auth::viaRequest)
        'jwt' => [
            'driver' => 'jwt',
        ],
    ],
];

La garde [jwt], garde par défaut, de pilote [jwt] : exactement l'exemple 25.

11.6.4. config/rdvmedecins.php

<?php
// config/rdvmedecins.php : les réglages propres à l'application, lus par
// config('rdvmedecins.xxx'). Les valeurs viennent du fichier .env ; le second
// paramètre de env() est la valeur par défaut
return [
    'jwt' => [
        // clé secrète des jetons JWT : 32 caractères au moins (HMAC-SHA256)
        'secret' => env('JWT_SECRET', ''),
        // durée de validité d'un jeton, en minutes ; le cookie qui le transporte
        // a la même durée de vie
        'duree' => (int) env('JWT_DUREE_MINUTES', 120),
    ],

    // true : les ordres SQL envoyés à la base sont affichés dans la console
    'db_log' => (bool) env('DB_LOG', false),

    // true en production (HTTPS) : les cookies ne sont jamais envoyés en clair
    'cookie_secure' => (bool) env('COOKIE_SECURE', false),

    // la protection contre les robots (cf. app/Auth/LimiteurTentatives.php) :
    // connexion : échecs autorisés pour un même login depuis une même adresse IP,
    // puis blocage pendant cette durée (l'adresse IP entière est aussi bloquée
    // si elle cumule 5 fois plus d'échecs, tous logins confondus)
    'login_max_echecs' => (int) env('LOGIN_MAX_ECHECS', 5),
    'login_blocage_minutes' => (int) env('LOGIN_BLOCAGE_MINUTES', 15),
    // création de compte client : comptes 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' => (int) env('INSCRIPTION_MAX_PAR_HEURE', 10),
];

Les réglages propres à l'application, lus par [config('rdvmedecins.jwt.secret')], [config('rdvmedecins.login_max_echecs')]... Chaque valeur vient du fichier [.env], avec une valeur par défaut. Rappelons la règle (exemple 25) : on n'appelle [env] que dans les fichiers du dossier [config] ; le reste du code lit [config(...)]. Une fois la configuration mise en cache ([php artisan config:cache]), le fichier [.env] n'est plus lu du tout.

11.7. Le démarrage de l'application

11.7.1. public/index.php et bootstrap/providers.php

Le fichier [public/index.php] est celui des exemples (exemple 01), avec le chemin du dossier [vendor] de l'application :

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

use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

// le chargeur de classes de Composer
require __DIR__.'/../vendor/autoload.php';

// l'application, construite par bootstrap/app.php, traite la requête en cours
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

Le fichier [bootstrap/providers.php] est nouveau :

1
2
3
4
5
6
7
8
9
<?php
// bootstrap/providers.php : les fournisseurs de services (service providers) de
// l'application, chargés au démarrage de chaque requête (cf. app/Providers)

use App\Providers\AppServiceProvider;

return [
    AppServiceProvider::class,
];

Il rend la liste des fournisseurs de services (*service providers*) de l'application. Un fournisseur de services est une classe dont [Laravel] appelle deux méthodes au démarrage de chaque requête : [register], qui déclare des services au conteneur (exemple 06), puis, une fois tous les fournisseurs enregistrés, [boot], qui peut utiliser n'importe quel service. [Laravel] lui-même est fait de fournisseurs de services (un pour la session, un pour les vues, un pour la base de données...), comme la plupart des bibliothèques écrites pour lui. Dans les exemples, nous écrivions ces déclarations dans [bootstrap/app.php] ([withSingletons], [booted]) : c'est pratique pour quelques lignes ; une application de taille réelle les range dans un fournisseur de services, [AppServiceProvider] par convention.

11.7.2. app/Providers/AppServiceProvider.php

<?php
// =========================================================================
// app/Providers/AppServiceProvider.php : le FOURNISSEUR DE SERVICES de l'application
// (déclaré dans bootstrap/providers.php). C'est la place normale, dans une
// application Laravel, de ce que les exemples écrivaient dans bootstrap/app.php :
//   - $singletons : les services partagés (cf. exemple 06) ;
//   - boot() : ce qui doit être fait au démarrage de chaque requête, une fois tous
//     les services enregistrés : le pilote d'authentification JWT, les variables
//     communes à toutes les vues, l'affichage des ordres SQL.
// =========================================================================

namespace App\Providers;

use App\Auth\AuthService;
use App\Auth\CaptchaService;
use App\Auth\LimiteurTentatives;
use App\Auth\PasseInscription;
use App\Dao\ClientDao;
use App\Dao\CreneauDao;
use App\Dao\MedecinDao;
use App\Dao\RvDao;
use App\Dao\UserDao;
use App\Metier\MetierService;
use App\Web\ApplicationModel;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\Events\TransactionBeginning;
use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionRolledBack;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as LaVue;

class AppServiceProvider extends ServiceProvider
{
    // -----------------------------------------------------------------------
    // les services partagés : pour chacune de ces classes, le conteneur de Laravel
    // ne crée qu'UN objet (par requête), fourni à tous ceux qui le demandent - les
    // contrôleurs, par leur constructeur. Le conteneur fabrique lui-même les
    // dépendances de chacun (MetierService reçoit ainsi ses cinq DAO) : c'est
    // l'injection de dépendances.
    // -----------------------------------------------------------------------
    public array $singletons = [
        MedecinDao::class => MedecinDao::class,
        ClientDao::class => ClientDao::class,
        CreneauDao::class => CreneauDao::class,
        RvDao::class => RvDao::class,
        UserDao::class => UserDao::class,
        MetierService::class => MetierService::class,
        ApplicationModel::class => ApplicationModel::class,
        AuthService::class => AuthService::class,
        CaptchaService::class => CaptchaService::class,
        LimiteurTentatives::class => LimiteurTentatives::class,
        PasseInscription::class => PasseInscription::class,
    ];

    public function boot(): void
    {
        // ---- le pilote de garde [jwt] (cf. config/auth.php, exemple 25) : une
        // fonction qui reçoit la requête, et rend l'utilisateur authentifié, ou null
        // pour un visiteur anonyme. Laravel l'appelle au premier Auth::user() de la
        // requête ; s'il a trouvé un utilisateur, il le garde pour la suite de la
        // requête (sinon, il rappelle la fonction à chaque Auth::user())
        Auth::viaRequest('jwt', function (Request $request) {
            $jeton = $request->cookie(AuthService::COOKIE);
            if (! is_string($jeton) || $jeton === '') {
                return null;
            }
            $utilisateur = app(AuthService::class)->utilisateur($jeton);
            if ($utilisateur === null) {
                // un cookie présent mais invalide (jeton expiré, falsifié) : on l'efface
                Cookie::queue(AuthService::cookieEffacement());
            }
            return $utilisateur;
        });

        // ---- les variables communes à TOUTES les vues (cf. exemple 15) : un
        // "compositeur de vue" est exécuté avant la fabrication de chaque vue ('*')
        //   - utilisateur : l'utilisateur connecté (ou null), pour la barre de navigation ;
        //   - langue      : "fr" ou "en".
        View::composer('*', function (LaVue $vue) {
            $vue->with(['utilisateur' => Auth::user(), 'langue' => App::getLocale()]);
        });

        // ---- DB_LOG=true (fichier .env) : chaque ordre SQL, et chaque étape d'une
        // transaction, est affiché dans la console du serveur (cf. exemple 28)
        if (config('rdvmedecins.db_log')) {
            DB::listen(fn (QueryExecuted $q) => error_log($q->sql.' '.json_encode($q->bindings, JSON_UNESCAPED_UNICODE)));
            Event::listen(TransactionBeginning::class, fn () => error_log('BEGIN'));
            Event::listen(TransactionCommitted::class, fn () => error_log('COMMIT'));
            Event::listen(TransactionRolledBack::class, fn () => error_log('ROLLBACK'));
        }
    }
}
  • lignes 48-60 : les services partagés. La propriété [$singletons] d'un fournisseur de services est lue par [Laravel], qui déclare chacun de ses couples [interface => classe] comme un singleton (exemple 06) : c'est l'équivalent de [withSingletons]. Ici, chaque classe est déclarée pour elle-même. Rappelons qu'un singleton ne vit que le temps d'une requête (exemple 06) : l'intérêt est ici que tous les contrôleurs, middlewares et services d'une même requête partagent le même [ApplicationModel], le même [MetierService]... Les DAO pourraient même ne pas être déclarés : le conteneur sait fabriquer une classe qui n'a pas été déclarée (exemple 06), mais il en fabriquerait un nouvel objet à chaque demande ;
  • lignes 69-80 : le pilote de garde [jwt] (exemple 25). Le jeton est lu dans le cookie [rdvmedecins_jwt] (le nom est une constante de [AuthService]) et vérifié par [AuthService::utilisateur]. Un cookie présent, mais dont le jeton n'est plus valide, est effacé. [Laravel] garde l'utilisateur trouvé pour toute la requête ; tant qu'il n'en a pas trouvé (un visiteur anonyme), il rappelle la fonction à chaque demande, ce qui est sans conséquence : lire un cookie, et au besoin vérifier une signature, est très rapide ;
  • lignes 86-88 : les variables communes à toutes les vues (exemple 15) : l'utilisateur connecté (ou [null]), pour la barre de navigation, et la langue de la requête. Le motif ['*'] désigne toutes les vues : le compositeur est appelé pour chacune, y compris les fragments et les composants ;
  • lignes 92-99 : l'affichage des ordres SQL et des transactions dans la console (exemple 28), si [DB_LOG=true]. [error_log] écrit dans le journal d'erreurs de [PHP], c'est-à-dire, avec [php artisan serve], dans la console du serveur.

11.7.3. bootstrap/app.php

<?php
// =========================================================================
// bootstrap/app.php : la construction de l'application (cf. exemple 01) :
//   1. les routes (routes/web.php) ;
//   2. les middlewares : langue, cookies, authentification, rôles ;
//   3. les exceptions : comment les erreurs deviennent des pages HTML.
// Les services partagés, eux, sont déclarés par app/Providers/AppServiceProvider.php.
// =========================================================================

use App\Auth\AuthService;
use App\Auth\SessionInvalide;
use App\Http\Middleware\ExigerRoles;
use App\Http\Middleware\FixerLangue;
use App\Http\Middleware\VerifierPasseInscription;
use App\Web\Flash;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\ViewErrorBag;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(web: __DIR__.'/../routes/web.php')

    ->withMiddleware(function (Middleware $middleware): void {
        // la langue de la requête : un middleware GLOBAL, exécuté pour toutes les
        // requêtes, avant ceux du groupe [web] (cf. app/Http/Middleware/FixerLangue.php)
        $middleware->append(FixerLangue::class);

        // Laravel CHIFFRE tous les cookies qu'il dépose. Deux exceptions : le jeton
        // JWT, déjà signé ; la langue, lue avant le déchiffrement des cookies
        $middleware->encryptCookies(except: [AuthService::COOKIE, FixerLangue::COOKIE]);

        // -------------------------------------------------------------------
        // LA RÈGLE D'ACCÈS : toutes les routes du groupe [web] exigent un
        // utilisateur authentifié (middleware Authenticate, alias [auth]) - sauf
        // celles qui le retirent explicitement (withoutMiddleware, cf.
        // routes/web.php : connexion, création de compte, choix de la langue).
        // C'est la règle "tout est interdit sauf ce qui est permis" : une route
        // ajoutée plus tard, et qu'on aurait oublié de protéger, l'est d'office.
        // -------------------------------------------------------------------
        $middleware->web(append: [Authenticate::class]);

        // un visiteur non authentifié qui 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
        $middleware->redirectGuestsTo(function (Request $request) {
            if ($request->cookie(AuthService::COOKIE)) {
                Flash::erreur('ERRORS.SESSION_EXPIRED');
            }
            return route('login');
        });

        // des noms courts (alias) pour les middlewares utilisés dans routes/web.php
        $middleware->alias([
            'role' => ExigerRoles::class,
            'passe.inscription' => VerifierPasseInscription::class,
        ]);
    })

    // -----------------------------------------------------------------------
    // 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 : abort(400), abort(403) (rôle insuffisant), 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) : Laravel l'inscrit dans le
    //      journal (storage/logs/laravel.log), avec sa pile d'appels ; en
    //      production (APP_DEBUG=false), l'utilisateur voit la page d'erreur 500 -
    //      jamais la pile d'appels ; en développement, la page de débogage de Laravel.
    // Une fonction de rendu qui rend null laisse Laravel traiter l'exception à sa
    // façon.
    // -----------------------------------------------------------------------
    ->withExceptions(function (Exceptions $exceptions): void {
        // la page d'erreur. $errors : la variable des erreurs de saisie, que toutes
        // les vues peuvent lire, n'existe pas encore si l'erreur s'est produite
        // avant le middleware qui la crée (une page 405, par exemple)
        $pageErreur = function (int $code) {
            $message = match ($code) {
                400 => 'ERRORS.BAD_REQUEST',
                403 => 'ERRORS.FORBIDDEN',
                404 => 'ERRORS.NOT_FOUND',
                419 => 'ERRORS.PAGE_EXPIRED',
                default => 'ERRORS.SERVER_ERROR',
            };
            return response()->view('erreur', ['titrePage' => 'ERROR_PAGE.TITLE', 'code' => $code,
                'message' => $message, 'errors' => new ViewErrorBag], $code);
        };

        $exceptions->render(function (SessionInvalide $e) {
            Flash::erreur('ERRORS.SESSION_EXPIRED');
            return redirect()->route('login')->withCookie(AuthService::cookieEffacement());
        });

        $exceptions->render(fn (HttpExceptionInterface $e) => $pageErreur($e->getStatusCode()));

        $exceptions->render(function (Throwable $e) use ($pageErreur) {
            // ces exceptions-là ont leur propre traitement : redirection vers la page
            // de connexion (AuthenticationException), retour au formulaire avec les
            // erreurs de saisie (ValidationException)...
            if (config('app.debug') || $e instanceof AuthenticationException
                || $e instanceof ValidationException || $e instanceof HttpResponseException) {
                return null;
            }
            return $pageErreur(500);
        });
    })
    ->create();

Le fichier qui construit l'application (exemple 01). Il réunit tout ce qui concerne le traitement des requêtes : les routes, les middlewares, les exceptions.

  • ligne 28 : les routes ([routes/web.php], plus loin) ;
  • ligne 33 : la langue de la requête est fixée par un middleware global (exemple 24), exécuté pour toutes les requêtes, avant même le routage ;
  • ligne 37 : [Laravel] chiffre tous les cookies (exemple 20), sauf ceux que l'on exclut : le jeton JWT, déjà signé (exemple 25), et la langue. Le cookie de la langue est lu par un middleware global, qui s'exécute avant le middleware [EncryptCookies] du groupe [web] : il doit donc pouvoir être lu sans déchiffrement. Pourquoi un middleware global plutôt qu'un middleware du groupe [web] ? Pour que même les requêtes qui n'aboutissent à aucune route (une page 405) aient leur page d'erreur dans la bonne langue ;
  • ligne 47 : la règle d'accès de l'application. Le middleware [Authenticate] (l'alias [auth] de l'exemple 25) est ajouté au groupe [web], c'est-à-dire à toutes les routes. Une route publique (la page de connexion, la création de compte, le choix de la langue) doit le retirer explicitement, par [withoutMiddleware] (dans [routes/web.php]). C'est la règle « tout est interdit sauf ce qui est permis » : une route ajoutée plus tard, et qu'on aurait oublié de protéger, l'est d'office. L'erreur inverse (une route publique qu'on aurait oublié d'ouvrir) se voit tout de suite ; l'erreur d'une page protégée oubliée, beaucoup moins ;
  • lignes 52-57 : un visiteur non authentifié qui demande une page protégée est renvoyé vers la page de connexion. Si son navigateur a envoyé un cookie de jeton, c'est que ce jeton n'est plus valide (expiré ou falsifié) : on le prévient par un message flash ([Flash::erreur], plus loin). [redirectGuestsTo] accepte une URL, ou une fonction qui reçoit la requête et rend l'URL ;
  • lignes 60-63 : des alias (des noms courts) pour les middlewares de route : [role] et [passe.inscription], utilisés dans [routes/web.php].

Les lignes 80-113 transforment les erreurs en pages HTML (exemple 24) :

  • lignes 84-91 : la fonction qui fabrique la page d'erreur d'un code HTTP : la vue [erreur], le code, la clé du message, et le code HTTP de la réponse. Une subtilité : les vues de l'application lisent la variable [$errors] (les erreurs de saisie, exemple 19), que le middleware [ShareErrorsFromSession] du groupe [web] partage avec toutes les vues. Une erreur qui se produit avant ce middleware (une méthode HTTP non prise en charge, 405, est détectée par le routage) arriverait à une vue sans [$errors] : on lui en fournit une, vide ([new ViewErrorBag]) ;
  • lignes 96-99 : l'exception [SessionInvalide] (le compte du jeton n'existe plus, plus loin) : retour à la page de connexion, cookie du jeton effacé, avec le message « session expirée » ;
  • ligne 101 : toutes les erreurs HTTP : [abort(403)], [abort(404)], une route inconnue, une méthode non prise en charge, un jeton anti-CSRF absent ou faux (419, exemple 18)... deviennent la page d'erreur, avec leur code ;
  • lignes 103-112 : toute autre exception est une vraie anomalie (la base est inaccessible, une erreur de programmation...). [Laravel] l'inscrit toujours dans le journal [storage/logs/laravel.log], avec sa pile d'appels. En production ([APP_DEBUG=false]), l'utilisateur voit la page d'erreur 500 - jamais la pile d'appels, qui renseignerait un pirate sur le code de l'application ; en développement, la fonction rend [null] : [Laravel] affiche sa page de débogage. Attention : [Throwable] désigne toutes les exceptions, y compris celles que [Laravel] traite lui-même, et qu'il faut lui laisser : [AuthenticationException] (un visiteur non authentifié, renvoyé vers [/login]), [ValidationException] (une saisie invalide, renvoyée au formulaire, exemple 19), [HttpResponseException] (une exception qui transporte sa propre réponse). Les fonctions de rendu sont essayées dans l'ordre de leur déclaration : les erreurs HTTP ont déjà été traitées par la précédente.

11.8. Les modèles

Les modèles [Eloquent] (exemple 28) : une classe par table.

11.8.1. app/Models/Role.php

<?php
// app/Models/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\Models;

enum Role: string
{
    case ADMIN = 'ADMIN';
    case DOCTOR = 'DOCTOR';
    case USER = 'USER';
}

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.

11.8.2. app/Models/User.php

<?php
// app/Models/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. app/Metier/MetierService.php).

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $table = 'users';

    // la table n'a pas les colonnes created_at / updated_at qu'Eloquent gère par défaut
    public $timestamps = false;

    // les colonnes qu'on peut remplir en bloc (User::create([...]), new User([...]))
    protected $fillable = ['login', 'password', 'nom', 'role'];

    // le mot de passe HACHÉ (bcrypt) ne sort jamais de l'objet s'il est converti en
    // tableau ou en JSON
    protected $hidden = ['password'];

    // les conversions : la colonne [role] (un texte) devient un Role, et inversement
    protected function casts(): array
    {
        return ['role' => Role::class, 'version' => 'integer'];
    }
}
  • ligne 16 : la table. [Eloquent] l'aurait devinée (le nom de la classe, en minuscules, au pluriel anglais : [users]) ; nous l'écrivons partout, par clarté ;
  • ligne 19 : aucune table de la base n'a les colonnes [created_at] et [updated_at] qu'[Eloquent] remplit par défaut (exemple 28) ;
  • ligne 22 : les colonnes qu'on peut remplir en bloc, par [User::create([...])] (exemple 28) ;
  • ligne 26 : le hachage du mot de passe n'apparaîtra pas si l'objet est converti en tableau ou en JSON ([$user->toArray()]) : une précaution ;
  • lignes 29-32 : les conversions (exemple 28) : la colonne [role] (un texte) devient un [Role] à la lecture, et inversement à l'écriture : [$user->role] est un [Role], jamais une chaîne. La colonne [version] est lue comme un entier : le pilote [PDO] de [MySQL] peut rendre les nombres sous forme de chaînes, et la comparaison stricte [$version !== $personne->version] de la couche métier échouerait.

11.8.3. app/Models/Personne.php

<?php
// app/Models/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.
//
// 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\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

abstract class Personne extends Model
{
    public $timestamps = false;

    // [user] est le nom de la colonne clé étrangère -> users(id) : un simple
    // nombre, l'id du compte ($medecin->user)
    protected $fillable = ['titre', 'nom', 'prenom', 'user'];

    protected function casts(): array
    {
        return ['user' => 'integer', 'version' => 'integer'];
    }

    // -----------------------------------------------------------------------
    // La RELATION vers le compte utilisateur : une personne a UN compte, désigné
    // par la clé étrangère [user]. belongsTo : "appartient à" - la clé étrangère
    // est dans CETTE table. $medecin->compte est alors un objet User, lu en base
    // au premier accès, ou dès la requête avec with('compte') (cf. app/Dao).
    // La relation ne peut pas s'appeler [user] : ce nom est déjà celui de la
    // colonne, et $medecin->user désigne la colonne.
    // -----------------------------------------------------------------------
    public function compte(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user');
    }

    // "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, et ne précisent que le nom de leur table.

  • ligne 24 : [user] est la colonne clé étrangère : [$medecin->user] est un simple nombre, l'id du compte ;
  • lignes 39-42 : la relation vers le compte. Une relation [Eloquent] est une méthode qui décrit le lien entre deux tables ; [belongsTo] (« appartient à ») décrit une clé étrangère de cette table : [belongsTo(User::class, 'user')] dit que la colonne [user] contient l'id d'un [User]. On l'utilise comme une propriété : [$medecin->compte] est l'objet [User]. [Eloquent] le lit en base au premier accès (on dit que la relation est chargée paresseusement, *lazy loading*), ou dès la requête qui lit le médecin, si elle le demande (chargement anticipé, *eager loading*, cf. [with('compte')] dans la couche DAO). La relation ne peut pas s'appeler [user] : [$medecin->user] désigne déjà la colonne, et [Eloquent] donne la priorité aux colonnes ;
  • lignes 45-48 : 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. app/Models/Medecin.php et Client.php

<?php
// app/Models/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\Models;

class Medecin extends Personne
{
    protected $table = 'medecins';
}
<?php
// app/Models/Client.php : un client, c'est-à-dire un PATIENT (table [clients]).
// Contrainte d'unicité SQL : unq_clients_prenom_nom.

namespace App\Models;

class Client extends Personne
{
    protected $table = 'clients';
}

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. app/Models/Creneau.php

<?php
// app/Models/Creneau.php : un créneau horaire d'un médecin (table [creneaux]) :
// 8h00 - 8h20, etc.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Creneau extends Model
{
    protected $table = 'creneaux';

    public $timestamps = false;

    protected function casts(): array
    {
        return ['id_medecin' => 'integer', 'hdebut' => 'integer', 'mdebut' => 'integer',
            'hfin' => 'integer', 'mfin' => 'integer', 'version' => 'integer'];
    }

    // plusieurs créneaux pour un même médecin : la clé étrangère [id_medecin]
    public function medecin(): BelongsTo
    {
        return $this->belongsTo(Medecin::class, 'id_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 23-26 : la relation vers le médecin du créneau (clé étrangère [id_medecin]) ;
  • lignes 29-32 : 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. app/Models/Rv.php

<?php
// app/Models/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\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Rv extends Model
{
    protected $table = 'rv';

    public $timestamps = false;

    protected $fillable = ['jour', 'id_creneau', 'id_client'];

    // immutable_date : la colonne [jour] (type SQL DATE) devient une date
    // CarbonImmutable, à minuit ; elle est écrite en base au format aaaa-mm-jj
    protected function casts(): array
    {
        return ['jour' => 'immutable_date', 'id_creneau' => 'integer', 'id_client' => 'integer',
            'version' => 'integer'];
    }

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class, 'id_client');
    }

    public function creneau(): BelongsTo
    {
        return $this->belongsTo(Creneau::class, 'id_creneau');
    }
}
  • lignes 23-27 : la colonne [jour] (type SQL [DATE]) est convertie en [CarbonImmutable], la classe de dates de [Laravel] (exemple 12), à minuit. Immuable : ses méthodes ne modifient jamais l'objet, elles en rendent un nouveau ([$jour->addDay()] 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 29-34 : deux relations, vers le client et vers le créneau du rendez-vous.

11.9. Le domaine : l'agenda

11.9.1. app/Domain/CreneauMedecinJour.php et AgendaMedecinJour.php

<?php
// app/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\Models\Creneau;
use App\Models\Rv;

readonly class CreneauMedecinJour
{
    public function __construct(
        public Creneau $creneau,
        public ?Rv $rv,
    ) {
    }
}
<?php
// app/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\Models\Medecin;
use Carbon\CarbonImmutable;

readonly class AgendaMedecinJour
{
    /** @param list<CreneauMedecinJour> $creneauxMedecinJour */
    public function __construct(
        public Medecin $medecin,
        public CarbonImmutable $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 utilisent le constructeur de requêtes d'[Eloquent] : [where], [orderBy], [first], [get]... ; les valeurs y deviennent des paramètres de la requête SQL : pas d'injection SQL possible.

11.10.1. app/Dao/PersonneDao.php

<?php
// app/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->modele est la classe Eloquent concernée
// (Medecin::class ou Client::class) : $this->modele::query() commence une requête
// sur SA table.
//
// Les requêtes sont écrites avec le constructeur de requêtes d'Eloquent (where,
// orderBy...) ; Eloquent les traduit en SQL. Les valeurs (prénom, nom...)
// deviennent des PARAMÈTRES de la requête : 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\Models\Personne;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

abstract class PersonneDao
{
    /** @param class-string<Personne> $modele */
    public function __construct(protected string $modele)
    {
    }

    // une requête sur la table de la classe, compte utilisateur compris
    protected function requete(): Builder
    {
        // with('compte') : charge AUSSI les comptes, par une seconde requête
        // (SELECT * FROM users WHERE id IN (...)). Sans lui, chaque accès à
        // $personne->compte déclencherait sa propre requête
        return $this->modele::query()->with('compte');
    }

    // liste triée par nom puis prénom
    public function lister(): Collection
    {
        return $this->requete()->orderBy('nom')->orderBy('prenom')->get();
    }

    // une personne et son compte ; null si elle n'existe pas
    public function lire(int $id): ?Personne
    {
        return $this->requete()->find($id);
    }

    // la personne dont le compte utilisateur est [$idUser]
    public function lireParUser(int $idUser): ?Personne
    {
        return $this->requete()->where('user', $idUser)->first();
    }

    // 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->modele::query()->where('prenom', $prenom)->where('nom', $nom)->first();
    }

    // INSERT : la base génère l'id, qu'Eloquent range dans l'objet
    public function ajouter(array $colonnes): Personne
    {
        return $this->modele::query()->create($colonnes);
    }

    // -----------------------------------------------------------------------
    // le VERROU OPTIMISTE (cf. exemple 28) :
    //   UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?
    // rend le nombre de lignes modifiées : 0 si la ligne a été modifiée (ou
    // supprimée) depuis sa lecture
    // -----------------------------------------------------------------------
    public function modifier(int $id, int $version, array $colonnes): int
    {
        return $this->modele::query()->whereKey($id)->where('version', $version)
            ->update([...$colonnes, 'version' => DB::raw('version + 1')]);
    }

    // un DELETE envoyé directement à la base, sans lire la personne d'abord
    public function supprimer(int $id): void
    {
        $this->modele::query()->whereKey($id)->delete();
    }
}

La couche DAO commune aux médecins et aux clients, écrite une fois dans une classe abstraite.

  • ligne 25 : la classe [Eloquent] 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. [$this->modele::query()] appelle la méthode statique [query] de cette classe : [PHP] accepte une variable à la place d'un nom de classe. [query()] commence une requête sur la table du modèle ; [Medecin::where(...)] en est le raccourci ;
  • lignes 30-36 : une requête qui charge aussi les comptes des personnes lues : [with('compte')] demande le chargement anticipé de la relation [compte]. [Eloquent] exécute alors deux requêtes : celle des personnes, puis celle de tous leurs comptes à la fois ([select * from users where id in (2, 3, 4)], cf. la sortie de la console, plus haut). Sans elle, la liste des médecins affichée par l'administrateur déclencherait une requête par médecin, au premier accès à [$medecin->compte->login] : 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 en sortent doivent être complets ;
  • lignes 39-42 : la liste, triée par nom puis prénom ; [get()] rend une [Collection] [Eloquent] (exemple 28) ;
  • lignes 45-48 : [find] lit une ligne par sa clé primaire ; [null] si elle n'existe pas ;
  • lignes 51-54 : la personne dont le compte est [$idUser] : [first()] rend la première ligne, ou [null] ;
  • lignes 60-63 : la personne de mêmes prénom et nom. 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 66-69 : [create] insère une ligne (colonnes de [$fillable]) et rend l'objet, avec l'id généré par la base ;
  • lignes 77-81 : le verrou optimiste de l'exemple 28 : [UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = ?]. La méthode rend le nombre de lignes modifiées : 0 si la ligne a été modifiée, ou supprimée, depuis sa lecture. [[...$colonnes, 'version' => ...]] fabrique un tableau qui contient les éléments de [$colonnes], plus la version (l'opérateur [...] étale un tableau). [whereKey($id)] : la condition sur la clé primaire ;
  • lignes 84-87 : un [DELETE] envoyé directement à la base, sans lire la ligne d'abord.

11.10.2. app/Dao/MedecinDao.php et ClientDao.php

<?php
// app/Dao/MedecinDao.php : le DAO des médecins - tout est hérité de PersonneDao.

namespace App\Dao;

use App\Models\Medecin;

class MedecinDao extends PersonneDao
{
    public function __construct()
    {
        parent::__construct(Medecin::class);
    }
}

Tout est hérité : le constructeur indique seulement la classe [Eloquent] à [PersonneDao] ([parent::__construct] appelle le constructeur de la classe mère).

<?php
// app/Dao/ClientDao.php : le DAO des clients - celui des personnes, plus une
// méthode de verrouillage.

namespace App\Dao;

use App\Models\Client;

class ClientDao extends PersonneDao
{
    public function __construct()
    {
        parent::__construct(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.
    // 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
    {
        return Client::query()->whereKey($id)->lockForUpdate()->value('id');
    }
}
  • lignes 25-28 : un verrou pessimiste, l'autre famille de verrous. [lockForUpdate()] ajoute [FOR UPDATE] à la requête : [SELECT id FROM clients WHERE 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. [value('id')] rend la valeur d'une colonne de la première ligne ([null] s'il n'y en a pas). Nous verrons, avec la couche métier, à quoi sert ce verrou.

11.10.3. app/Dao/CreneauDao.php

<?php
// app/Dao/CreneauDao.php : couche DAO des créneaux horaires.

namespace App\Dao;

use App\Models\Creneau;
use Illuminate\Database\Eloquent\Collection;

class CreneauDao
{
    // un créneau et son médecin
    public function lire(int $id): ?Creneau
    {
        return Creneau::with('medecin')->find($id);
    }

    // les créneaux d'un médecin, dans l'ordre chronologique
    public function listerParMedecin(int $idMedecin): Collection
    {
        return Creneau::where('id_medecin', $idMedecin)->orderBy('hdebut')->orderBy('mdebut')->get();
    }

    // supprime tous les créneaux d'un médecin (suppression d'un médecin) :
    //   DELETE FROM creneaux WHERE id_medecin = ?
    public function supprimerParMedecin(int $idMedecin): void
    {
        Creneau::where('id_medecin', $idMedecin)->delete();
    }
}

[Creneau::with('medecin')->find($id)] : un créneau, et son médecin (chargement anticipé) ; [Creneau::where(...)->delete()] : un [DELETE] de toutes les lignes qui vérifient la condition.

11.10.4. app/Dao/RvDao.php

<?php
// app/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.
// whereHas('creneau', ...) : les rendez-vous dont le créneau vérifie une condition
// (Eloquent l'écrit en SQL avec une sous-requête EXISTS).

namespace App\Dao;

use App\Models\Rv;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

class RvDao
{
    // un rendez-vous, son client et son créneau
    public function lire(int $id): ?Rv
    {
        return Rv::with(['client', 'creneau'])->find($id);
    }

    // les rendez-vous d'un médecin un jour donné, avec leur client
    public function rvMedecinJour(int $idMedecin, CarbonImmutable $jour): Collection
    {
        return Rv::with('client')
            ->where('jour', $jour->toDateString())
            ->whereHas('creneau', fn (Builder $q) => $q->where('id_medecin', $idMedecin))
            ->get();
    }

    // -----------------------------------------------------------------------
    // 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 ('creneau.medecin' :
    // une relation de la relation). Le tri porte sur des colonnes de la table
    // [creneaux] : il faut une JOINTURE SQL ; select('rv.*') : on ne garde que les
    // colonnes de [rv] pour remplir les objets Rv
    // -----------------------------------------------------------------------
    public function rvAVenirClient(int $idClient, CarbonImmutable $depuis): Collection
    {
        return Rv::with('creneau.medecin')
            ->select('rv.*')
            ->join('creneaux', 'creneaux.id', '=', 'rv.id_creneau')
            ->where('rv.id_client', $idClient)
            ->where('rv.jour', '>=', $depuis->toDateString())
            ->orderBy('rv.jour')->orderBy('creneaux.hdebut')->orderBy('creneaux.mdebut')
            ->get();
    }

    // nombre de rendez-vous d'un client à partir du jour [$depuis] (inclus)
    public function compterAVenirClient(int $idClient, CarbonImmutable $depuis): int
    {
        return Rv::where('id_client', $idClient)->where('jour', '>=', $depuis->toDateString())->count();
    }

    // nombre de rendez-vous (passés ou futurs) d'un médecin
    public function compterParMedecin(int $idMedecin): int
    {
        return Rv::whereHas('creneau', fn (Builder $q) => $q->where('id_medecin', $idMedecin))->count();
    }

    public function ajouter(CarbonImmutable $jour, int $idCreneau, int $idClient): Rv
    {
        return Rv::create(['jour' => $jour, 'id_creneau' => $idCreneau, 'id_client' => $idClient]);
    }

    // DELETE FROM rv WHERE id = ?
    public function supprimer(int $id): void
    {
        Rv::whereKey($id)->delete();
    }

    // supprime TOUS les rendez-vous (passés et futurs) d'un client
    public function supprimerParClient(int $idClient): void
    {
        Rv::where('id_client', $idClient)->delete();
    }
}

La table [rv] ne référence pas directement le médecin : on passe par le créneau.

  • lignes 24-30 : les rendez-vous d'un médecin un jour donné. [whereHas('creneau', fonction)] garde les rendez-vous dont la relation [creneau] vérifie la condition écrite par la fonction : [Eloquent] la traduit par une sous-requête [EXISTS] (cf. la sortie de la console, plus haut). [fn (Builder $q) => ...] est une fonction fléchée (exemple 03) ; elle reçoit la requête sur la table [creneaux]. La date est comparée sous sa forme [aaaa-mm-jj] ([toDateString()]), celle des colonnes [DATE] ;
  • lignes 39-48 : les rendez-vous à venir d'un client, avec leur créneau et le médecin de ce créneau : [with('creneau.medecin')] charge une relation de la relation. Le tri se fait sur l'heure du créneau, une colonne de la table [creneaux] : il faut une jointure ([join]). [select('rv.*')] ne garde que les colonnes de la table [rv] pour remplir les objets [Rv] (sinon, l'id du créneau écraserait celui du rendez-vous : les deux colonnes s'appellent [id]) ;
  • lignes 51-54 : [count()] exécute un [SELECT COUNT(*)] ;
  • lignes 62-65 : l'objet [CarbonImmutable] est écrit en base au format de la colonne, grâce à la conversion déclarée par le modèle.

11.10.5. app/Dao/UserDao.php

<?php
// app/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. app/Metier.

namespace App\Dao;

use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class UserDao
{
    // null si aucun utilisateur ne porte ce login
    public function lireParLogin(string $login): ?User
    {
        return User::where('login', $login)->first();
    }

    public function ajouter(string $login, string $hachage, string $nom, Role $role): User
    {
        return User::create(['login' => $login, 'password' => $hachage, 'nom' => $nom, 'role' => $role]);
    }

    // verrou optimiste, comme PersonneDao::modifier
    public function modifier(int $id, int $version, array $colonnes): int
    {
        return User::whereKey($id)->where('version', $version)
            ->update([...$colonnes, 'version' => DB::raw('version + 1')]);
    }

    public function supprimer(int $id): void
    {
        User::whereKey($id)->delete();
    }
}

Les comptes : lecture par login, ajout, modification avec verrou optimiste, 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. app/Metier/Dates.php

<?php
// app/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 Carbon\CarbonImmutable;

class Dates
{
    // aujourd'hui à minuit, dans le fuseau horaire de l'application (config/app.php)
    public static function aujourdhui(): CarbonImmutable
    {
        return CarbonImmutable::today();
    }
}

La date du jour, à minuit, dans le fuseau horaire de l'application ([config/app.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. app/Metier/MetierException.php

<?php
// app/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. lang/*.json).
//
// 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. app/Metier/DonneesPersonne.php

<?php
// app/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. app/Metier/Metier.php

<?php
// app/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...).

namespace App\Metier;

use App\Domain\AgendaMedecinJour;
use App\Models\Client;
use App\Models\Creneau;
use App\Models\Medecin;
use App\Models\Rv;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;

interface Metier
{
    // ------------------------------------------------ lectures
    public function getAllClients(): Collection;

    public function getAllMedecins(): Collection;

    public function getAllCreneaux(int $idMedecin): Collection;

    public function getRvMedecinJour(int $idMedecin, CarbonImmutable $jour): Collection;

    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, CarbonImmutable $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
    public function getRvAVenirClient(int $idClient): Collection;

    // ------------------------------------------------ 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(CarbonImmutable $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 objets [Eloquent] ou des [Collection] ; [?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. app/Metier/Descripteur.php

<?php
// app/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\Models\Role;
use Closure;

readonly class Descripteur
{
    public function __construct(
        public PersonneDao $dao,
        public Role $role,              // DOCTOR ou USER
        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, 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. app/Metier/MetierService.php

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
<?php
// =========================================================================
// app/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\Models\Client;
use App\Models\Creneau;
use App\Models\Medecin;
use App\Models\Personne;
use App\Models\Role;
use App\Models\Rv;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class MetierService implements Metier
{
    // -----------------------------------------------------------------------
    // Erreurs de la base -> MetierException
    //
    // Quand une contrainte UNIQUE refuse une écriture, Laravel lève une
    // QueryException, 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;

    // toutes les dépendances sont fournies par le conteneur de services de Laravel
    // (injection de dépendances, cf. exemple 06)
    public function __construct(
        private MedecinDao $medecins,
        private ClientDao $clients,
        private CreneauDao $creneaux,
        private RvDao $rvs,
        private UserDao $users,
    ) {
        $this->medecin = new Descripteur(
            dao: $medecins,
            role: Role::DOCTOR,
            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,
            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(): Collection
    {
        return $this->clients->lister();
    }

    public function getAllMedecins(): Collection
    {
        return $this->medecins->lister();
    }

    public function getAllCreneaux(int $idMedecin): Collection
    {
        return $this->creneaux->listerParMedecin($idMedecin);
    }

    public function getRvMedecinJour(int $idMedecin, CarbonImmutable $jour): Collection
    {
        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): Collection
    {
        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, CarbonImmutable $jour): ?AgendaMedecinJour
    {
        $medecin = $this->getMedecinById($idMedecin);
        if ($medecin === null) {
            return null;
        }
        // n° de créneau -> rendez-vous pris sur ce créneau (keyBy : une collection
        // indexée par la valeur d'une colonne)
        $reservations = $this->getRvMedecinJour($idMedecin, $jour)->keyBy('id_creneau');
        $creneauxMedecinJour = $this->getAllCreneaux($idMedecin)
            ->map(fn (Creneau $creneau) => new CreneauMedecinJour($creneau, $reservations->get($creneau->id)))
            ->all();
        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(CarbonImmutable $jour, Creneau $creneau, Client $client): Rv
    {
        return $this->transaction(function () use ($jour, $creneau, $client) {
            $aujourdhui = Dates::aujourdhui();
            if ($jour->lt($aujourdhui)) {
                throw new MetierException('ERRORS.PAST_DATE');
            }
            $idPatient = $this->clients->verrouiller($client->id)
                ?? throw new MetierException('ERRORS.PATIENT_NOT_FOUND');
            if ($this->rvs->compterAVenirClient($idPatient, $aujourdhui) > 0) {
                throw new MetierException('ERRORS.PATIENT_HAS_UPCOMING_APPOINTMENT');
            }
            return $this->rvs->ajouter($jour, $creneau->id, $idPatient);
        });
    }

    public function supprimerRv(Rv $rv): void
    {
        $this->transaction(fn () => $this->rvs->supprimer($rv->id));
    }

    // =======================================================================
    // 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, PUIS la personne (qui le référence), dans
    // UNE transaction.
    // -----------------------------------------------------------------------
    private function ajouterPersonne(Descripteur $d, DonneesPersonne $donnees): Personne
    {
        $nom = Str::upper(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 = Str::lower($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::make : le hachage bcrypt du mot de passe (cf. config/hashing.php)
            $user = $this->users->ajouter($login, Hash::make($password), "$prenom $nom", $d->role);
            $personne = $d->dao->ajouter(['titre' => $donnees->titre, 'nom' => $nom,
                'prenom' => $prenom, 'user' => $user->id]);
            // setRelation : le compte, déjà en mémoire, est rangé dans la relation
            // [compte] de la personne (inutile de le relire en base)
            return $personne->setRelation('compte', $user);
        });
    }

    // -----------------------------------------------------------------------
    // 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
            $version = $donnees->version ?? $personne->version;
            if ($version !== $personne->version) {
                throw new MetierException('ERRORS.CONCURRENT_UPDATE');
            }

            $nom = Str::upper(trim($donnees->nom));
            $prenom = trim($donnees->prenom);
            $this->verifierPersonneUnique($d, $prenom, $nom, $id);

            // la personne : UPDATE ... WHERE id = ? AND version = ? (si la fiche a
            // été modifiée APRÈS la lecture ci-dessus, aucune ligne n'est modifiée)
            if ($d->dao->modifier($id, $version, ['titre' => $donnees->titre, 'nom' => $nom,
                'prenom' => $prenom]) === 0) {
                throw new MetierException('ERRORS.CONCURRENT_UPDATE');
            }

            // son compte : le nom affiché, et, s'ils ont été saisis, le login et le
            // mot de passe
            $compte = $personne->compte;
            $colonnes = ['nom' => "$prenom $nom"];
            $login = $donnees->login ? Str::lower($donnees->login) : null;
            if ($login !== null && $login !== $compte->login) {
                $this->verifierLoginLibre($login);
                $colonnes['login'] = $login;
            }
            if ($donnees->password) {
                $colonnes['password'] = Hash::make($donnees->password);
            }
            if ($this->users->modifier($compte->id, $compte->version, $colonnes) === 0) {
                throw new MetierException('ERRORS.CONCURRENT_UPDATE');
            }

            // la personne relue : ses nouvelles valeurs, ses nouvelles versions
            return $d->dao->lire($id);
        });
    }

    // -----------------------------------------------------------------------
    // 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) ;
    //   3. la personne (elle référence son compte) ;
    //   4. son compte.
    // -----------------------------------------------------------------------
    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($id);
            $this->users->supprimer($personne->user);
        });
    }

    // 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->id !== $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 (DB::transaction, cf. exemple 28) :
    // COMMIT si la fonction se termine normalement ; 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. On
    // intercepte donc les erreurs de la base AUTOUR de la transaction, pour les
    // traduire en la même MetierException. Toute autre erreur est une vraie
    // anomalie : on la laisse remonter.
    // -----------------------------------------------------------------------
    private function transaction(Closure $travail): mixed
    {
        try {
            return DB::transaction($travail);
        } catch (QueryException $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;
        }
    }
}

Le fichier le plus important de l'application. Nous le parcourons par thème.

11.11.6.1. Les descripteurs

Lignes 89-113 : les deux descripteurs, construits une fois par le constructeur. 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 349). 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 120-165) délèguent à la couche DAO. L'agenda d'un médecin (lignes 172-185) est calculé : le médecin, ses créneaux, et les rendez-vous de ce jour-là. [keyBy('id_creneau')] transforme la collection des rendez-vous en une collection indexée par le numéro de créneau : [$reservations->get($creneau->id)] rend le rendez-vous d'un créneau, ou [null]. [map] transforme chaque créneau en [CreneauMedecinJour] ; [all()] rend le tableau [PHP] contenu dans la collection.

11.11.6.3. La prise de rendez-vous

Lignes 203-217 : quatre étapes, dans une transaction.

  • pas de rendez-vous dans le passé : [$jour->lt($aujourdhui)] (*less than*) compare deux dates ;
  • 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. 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 229-256) appellent les méthodes privées communes, avec le bon descripteur.

  • lignes 265-285 : la création. Le nom est mis en majuscules ([Str::upper]), 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, haché par [Hash::make] (exemple 25), puis la personne, qui référence le compte. [setRelation('compte', $user)] range le compte, déjà en mémoire, dans la relation de la personne : [$personne->compte] ne déclenchera pas de requête. [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 291-334 : la modification, avec deux verrous optimistes. Le premier est une comparaison : 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 la clause [WHERE version = ?] de l'[UPDATE] ([PersonneDao::modifier]), qui détecte une modification survenue entre la lecture et l'écriture. 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]. La personne est enfin relue : l'appelant reçoit ses nouvelles valeurs et ses nouvelles versions ;
  • lignes 345-358 : 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), puis la personne, puis son compte. 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 363-369 : 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 contraintes d'unicité

Lignes 391-402 : toutes les écritures passent par cette méthode. [DB::transaction] exécute la fonction dans une transaction (exemple 28) : [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]. [Laravel] lève alors une [QueryException], 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 'unq_users_login'

L'expression régulière de la ligne 74 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 67-72 donne la clé du message. Toute autre erreur de la base est une vraie anomalie : elle est relancée ([throw $e]), et aboutira à la page d'erreur 500.

11.12. L'authentification

Le dossier [app/Auth] reprend les exemples 25 à 27, avec les utilisateurs de la base de données, et quelques protections de plus.

11.12.1. app/Auth/UtilisateurConnecte.php

<?php
// =========================================================================
// app/Auth/UtilisateurConnecte.php : l'utilisateur connecté, tel que le décrit son
// jeton JWT (cf. exemple 25). Authenticatable : l'interface que Laravel attend des
// utilisateurs authentifiés (Auth::user(), auth()->user(), @auth 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\Auth;

use App\Models\Role;
use Illuminate\Contracts\Auth\Authenticatable;

readonly class UtilisateurConnecte implements Authenticatable
{
    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 Authenticatable : l'identifiant de l'utilisateur est l'id
    // de son compte ; le mot de passe et le jeton "se souvenir de moi" des sessions
    // de Laravel sont sans objet ici
    public function getAuthIdentifierName(): string
    {
        return 'userId';
    }

    public function getAuthIdentifier(): int
    {
        return $this->userId;
    }

    public function getAuthPasswordName(): string
    {
        return '';
    }

    public function getAuthPassword(): string
    {
        return '';
    }

    public function getRememberToken(): ?string
    {
        return null;
    }

    public function setRememberToken($valeur): void
    {
    }

    public function getRememberTokenName(): string
    {
        return '';
    }
}

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 [Authenticatable], que [Laravel] 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.

  • lignes 35-43 : l'utilisateur décrit par les affirmations 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 45-50 : deux questions que les vues et les contrôleurs posent souvent ;
  • lignes 58-61 et suivantes : l'interface [Authenticatable]. L'identifiant de l'utilisateur est l'id de son compte ; le mot de passe et le jeton « se souvenir de moi » sont sans objet.

11.12.2. app/Auth/AuthService.php

<?php
// =========================================================================
// app/Auth/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, cf. @csrf) ;
//   - 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\Auth;

use App\Dao\UserDao;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Hash;
use RuntimeException;
use Symfony\Component\HttpFoundation\Cookie;
use Throwable;

class AuthService
{
    // le nom du cookie qui transporte le jeton
    public const COOKIE = 'rdvmedecins_jwt';

    private string $secret;

    private int $duree;

    public function __construct(private UserDao $users)
    {
        $this->secret = config('rdvmedecins.jwt.secret');
        $this->duree = config('rdvmedecins.jwt.duree');
        // 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. Hash::check
    // 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 || ! Hash::check($password, $user->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->id,
            'login' => $user->login,
            'nom' => $user->nom,
            'role' => $user->role->value,
            'iat' => $maintenant,
            'exp' => $maintenant + $this->duree * 60,
        ];
        $jeton = JWT::encode($claims, $this->secret, 'HS256');
        // cookie(nom, valeur, durée en minutes, chemin, domaine, secure, httpOnly,
        // raw, sameSite) : la fonction de Laravel qui fabrique un cookie
        return cookie(self::COOKIE, $jeton, $this->duree, '/', null,
            config('rdvmedecins.cookie_secure'), true, false, 'lax');
    }

    // le cookie qui EFFACE le jeton : le même nom, vide, déjà expiré
    public static function cookieEffacement(): Cookie
    {
        return cookie()->forget(self::COOKIE);
    }

    // -----------------------------------------------------------------------
    // 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 35 : le nom du cookie du jeton, une constante de classe, lue par [AuthService::COOKIE]. Pourquoi pas un réglage de [config/rdvmedecins.php], comme la clé secrète ? Parce que [bootstrap/app.php] en a besoin pour exclure le cookie du chiffrement, à un moment où la configuration n'est pas encore chargée : le code de [withMiddleware] s'exécute avant que [Laravel] ait lu les fichiers du dossier [config] ;
  • lignes 41-50 : la clé et la durée de vie des jetons, lues dans la configuration. 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 55-62 : 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 70-86 : 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. La fonction [cookie] de [Laravel] fabrique un cookie : nom, valeur, durée de vie en minutes (celle du jeton), chemin, domaine ([null] : celui du site), [secure] (en production, sur HTTPS), [httpOnly], [raw] ([false] : la valeur est codée pour une URL) et [sameSite] ;
  • lignes 89-92 : le cookie qui efface le jeton : [cookie()->forget(nom)] fabrique un cookie de même nom, vide, déjà expiré ;
  • lignes 98-107 : la vérification d'un jeton (exemple 25).

11.12.3. app/Auth/SessionInvalide.php

<?php
// app/Auth/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 : bootstrap/app.php efface le cookie et renvoie vers la page de connexion.

namespace App\Auth;

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 [bootstrap/app.php] traite comme une session expirée. La classe est vide : seul son type compte, qui permet de lui associer sa propre fonction de rendu.

11.12.4. app/Auth/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
// =========================================================================
// app/Auth/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, cf. CACHE_STORE dans .env), partagé par
// toutes les requêtes, et même par plusieurs serveurs avec un cache comme Redis.
// =========================================================================

namespace App\Auth;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class CaptchaService
{
    // durée de validité d'une image, en secondes : 10 minutes pour remplir le formulaire
    private const DUREE_VIE = 600;

    public function generer(): Captcha
    {
        $texte = DessinCaptcha::texteAleatoire(5);    // 5 caractères
        $id = Str::random(24);                        // un identifiant impossible à deviner
        // le cache oubliera de lui-même la réponse au bout de 10 minutes
        Cache::put("captcha:$id", $texte, 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
    {
        if (! $id || $saisie === null) {
            return false;
        }
        $attendu = Cache::get("captcha:$id");
        // forget rend true seulement pour la requête qui a RÉELLEMENT retiré l'entrée :
        // si deux requêtes présentent le même captcha au même instant, une seule le
        // retire - l'autre échoue
        if ($attendu === null || ! Cache::forget("captcha:$id")) {
            return false;
        }
        return strcasecmp($attendu, trim($saisie)) === 0;
    }
}
  • lignes 31-38 : un texte de 5 caractères tirés au hasard, un identifiant aléatoire ([Str::random], qui utilise un générateur cryptographique), et la bonne réponse rangée dans le cache, pour 10 minutes (exemple 27) ;
  • lignes 42-55 : une différence avec l'exemple 27, qui utilisait [Cache::pull] (lire, puis retirer). Entre la lecture et le retrait, une seconde requête qui présenterait le même captcha pourrait le lire elle aussi : deux tentatives pour une seule image résolue. Ici, la réponse est lue, puis retirée par [Cache::forget], qui rend [true] seulement s'il a effectivement retiré l'entrée : avec le cache de fichiers, c'est la suppression d'un fichier, que le système d'exploitation n'accorde qu'à un seul des deux processus. La requête qui perd la course échoue. C'est l'équivalent du verrou de la version [Flask], dont les captchas vivaient en mémoire.

11.12.5. app/Auth/LimiteurTentatives.php

<?php
// =========================================================================
// app/Auth/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
//     5 fois plus d'é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 du limiteur de Laravel (RateLimiter), rangés dans le cache.
// =========================================================================

namespace App\Auth;

use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;

class LimiteurTentatives
{
    private int $maxEchecs;

    private int $blocage;    // en secondes

    private int $maxInscriptions;

    public function __construct()
    {
        $this->maxEchecs = config('rdvmedecins.login_max_echecs');
        $this->blocage = config('rdvmedecins.login_blocage_minutes') * 60;
        $this->maxInscriptions = config('rdvmedecins.inscription_max_par_heure');
    }

    // ---------------------------------------------------------------- connexion

    // nombre de minutes de blocage restantes (0 = la tentative est autorisée)
    public function minutesBlocageLogin(string $ip, string $login): int
    {
        return max($this->minutesRestantes(self::cleLogin($ip, $login), $this->maxEchecs),
            $this->minutesRestantes(self::cleIp($ip), $this->maxEchecs * 5));
    }

    // RateLimiter::hit : +1 au compteur ; le premier hit ouvre une fenêtre de
    // [$this->blocage] secondes, à la fin de laquelle le compteur repart de zéro
    public function echecLogin(string $ip, string $login): void
    {
        RateLimiter::hit(self::cleLogin($ip, $login), $this->blocage);
        RateLimiter::hit(self::cleIp($ip), $this->blocage);
    }

    // une connexion réussie remet à zéro le compteur de CE login
    public function reussiteLogin(string $ip, string $login): void
    {
        RateLimiter::clear(self::cleLogin($ip, $login));
    }

    // ---------------------------------------------------------------- inscription

    public function minutesBlocageInscription(string $ip): int
    {
        return $this->minutesRestantes("inscription|$ip", $this->maxInscriptions);
    }

    // fenêtre d'une heure
    public function inscriptionReussie(string $ip): void
    {
        RateLimiter::hit("inscription|$ip", 3600);
    }

    // ---------------------------------------------------------------- mécanique commune

    // tooManyAttempts : le compteur a-t-il atteint [$maximum] dans la fenêtre en
    // cours ? availableIn : secondes restantes avant la fin de cette fenêtre,
    // arrondies à la minute SUPÉRIEURE : 30 s restantes -> "1 minute"
    private function minutesRestantes(string $cle, int $maximum): int
    {
        if (! RateLimiter::tooManyAttempts($cle, $maximum)) {
            return 0;
        }
        return max(1, (int) ceil(RateLimiter::availableIn($cle) / 60));
    }

    private static function cleLogin(string $ip, string $login): string
    {
        return "login|$ip|".Str::lower(trim($login));
    }

    private static function cleIp(string $ip): string
    {
        return "ip|$ip";
    }
}

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 limiteur de [Laravel], la façade [RateLimiter]. Cette classe l'utilise pour deux règles :

  • la connexion (lignes 47-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, 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. [RateLimiter::hit(clé, durée)] ajoute 1 au compteur ; le premier [hit] 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 ([RateLimiter::clear]) ;
  • la création de compte (lignes 69-75) : au plus 10 comptes par heure et par adresse IP (réglage [INSCRIPTION_MAX_PAR_HEURE]) ;
  • lignes 85-91 : [tooManyAttempts(clé, maximum)] dit si le compteur a atteint le maximum dans la fenêtre en cours ; [availableIn(clé)] donne le nombre de secondes avant la fin de cette fenêtre, arrondi ici à 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 minute(s). » ;
  • lignes 93-96 : les clés des compteurs. Le login est mis en minuscules : [Admin] et [admin] ne font qu'un (comme pour la base, cf. sa collation).

Les compteurs du [RateLimiter] 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 artisan cache:clear] vide le cache, et remet donc tous les compteurs à zéro (utile en développement, quand on s'est bloqué soi-même).

11.12.6. app/Auth/PasseInscription.php

<?php
// =========================================================================
// app/Auth/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 (middleware 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\Auth;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie;

class PasseInscription
{
    public const COOKIE = 'rdvmedecins_inscription';

    // durée de vie d'un laissez-passer, en minutes
    private const DUREE_VIE = 15;

    // délivre un nouveau laissez-passer ; rend son identifiant
    public function emettre(): string
    {
        $id = Str::random(24);
        Cache::put("passe:$id", true, self::DUREE_VIE * 60);
        return $id;
    }

    // vrai si [$id] est un laissez-passer existant et non expiré
    public function estValide(?string $id): bool
    {
        return $id !== null && $id !== '' && Cache::has("passe:$id");
    }

    // le laissez-passer a servi : on l'oublie
    public function consommer(?string $id): void
    {
        if ($id) {
            Cache::forget("passe:$id");
        }
    }

    // le cookie n'est envoyé par le navigateur qu'aux adresses /inscription...
    public static function cookie(string $id): Cookie
    {
        return cookie(self::COOKIE, $id, self::DUREE_VIE, '/inscription', null,
            config('rdvmedecins.cookie_secure'), true, false, 'lax');
    }

    // ... et il est effacé par un cookie de même nom et de même chemin, déjà expiré
    public static function cookieEffacement(): Cookie
    {
        return cookie()->forget(self::COOKIE, '/inscription');
    }
}

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 57-61 : le cookie a pour chemin [/inscription] : le navigateur ne l'envoie qu'aux adresses qui commencent par ce chemin. Il est chiffré par [Laravel], comme tous les cookies de l'application sauf deux (cf. [bootstrap/app.php]) : sans la clé de l'application, personne ne peut fabriquer un cookie valide, ni même lire l'identifiant ;
  • lignes 64-67 : 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.

11.12.7. Les middlewares : app/Http/Middleware

Trois middlewares, rangés dans le dossier [app/Http/Middleware], comme le veut l'usage.

<?php
// app/Http/Middleware/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.
//
// Un middleware GLOBAL (cf. bootstrap/app.php) : il s'exécute pour TOUTES les
// requêtes, même celles qui n'aboutissent à aucune route (page 405...). Il passe
// donc AVANT le middleware EncryptCookies du groupe [web], qui déchiffre les cookies :
// c'est pourquoi le cookie de la langue est exclu du chiffrement.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;

class FixerLangue
{
    // le cookie qui mémorise la langue choisie (cf. LangueController)
    public const COOKIE = 'rdvmedecins_langue';

    // les langues gérées (lang/fr.json, lang/en.json) ; par défaut, le français
    // (config/app.php, clé locale)
    public const LANGUES = ['fr', 'en'];

    public function handle(Request $request, Closure $next): Response
    {
        $langue = $request->cookie(self::COOKIE);
        if (in_array($langue, self::LANGUES, true)) {
            App::setLocale($langue);
        }
        return $next($request);
    }
}

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 de la configuration (le français). Les langues et le nom du cookie sont des constantes, pour la même raison que le nom du cookie du jeton. Ce middleware est global (cf. [bootstrap/app.php]).

<?php
// app/Http/Middleware/ExigerRoles.php : le rôle de l'utilisateur doit être l'un de
// ceux que cite la route ; sinon, réponse 403 (une page d'erreur, cf.
// bootstrap/app.php). Un middleware À PARAMÈTRES : dans routes/web.php,
//   ->middleware('role:ADMIN,DOCTOR')
// appelle handle($request, $next, 'ADMIN', 'DOCTOR') ; ...$roles les reçoit tous.
//
// Il s'exécute APRÈS le middleware d'authentification (cf. bootstrap/app.php) :
// l'utilisateur est donc connu.

namespace App\Http\Middleware;

use App\Web\Outils;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ExigerRoles
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (! in_array(Outils::utilisateur()->role->value, $roles, true)) {
            abort(403);
        }
        return $next($request);
    }
}

Un middleware à paramètres. Dans [routes/web.php], [->middleware('role:ADMIN,DOCTOR')] désigne le middleware d'alias [role] (cf. [bootstrap/app.php]), avec deux paramètres : [Laravel] appelle [handle($request, $next, 'ADMIN', 'DOCTOR')]. Le paramètre variadique [string ...$roles] reçoit tous les paramètres restants dans un tableau (ligne 20). Un rôle absent de la liste : réponse 403 ([abort(403)], exemple 24). C'est l'équivalent du middleware [can:admin] de l'exemple 26, sans définir de [Gate] : l'application n'a que des règles de rôle. Ce middleware est toujours placé sur des routes protégées par [Authenticate], qui s'exécute avant lui : [Authenticate] fait partie du groupe [web], dont les middlewares passent avant ceux de la route. L'utilisateur est donc connu.

<?php
// app/Http/Middleware/VerifierPasseInscription.php : GET et POST /inscription
// exigent un laissez-passer valide (cf. app/Auth/PasseInscription.php) ; sinon,
// retour à la page d'authentification, avec un message. Taper directement l'URL
// /inscription ne permet donc pas de contourner le captcha.

namespace App\Http\Middleware;

use App\Auth\PasseInscription;
use App\Web\Flash;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifierPasseInscription
{
    public function __construct(private PasseInscription $passes)
    {
    }

    public function handle(Request $request, Closure $next): Response
    {
        if (! $this->passes->estValide($request->cookie(PasseInscription::COOKIE))) {
            Flash::erreur('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
            return redirect()->route('login');
        }
        return $next($request);
    }
}

Pas de laissez-passer valide : retour à la page de connexion, avec un message. Le middleware reçoit le service des laissez-passer par son constructeur : [Laravel] crée les middlewares avec le conteneur, qui leur fournit leurs dépendances, comme aux contrôleurs.

11.12.8. app/Http/Controllers/AuthController.php

<?php
// =========================================================================
// app/Http/Controllers/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\Http\Controllers;

use App\Auth\AuthService;
use App\Auth\CaptchaService;
use App\Auth\LimiteurTentatives;
use App\Models\Role;
use App\Web\Message;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\View\View;

class AuthController
{
    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
    public static function pageAccueil(Role $role): string
    {
        return $role === Role::USER ? route('espace') : route('agenda');
    }

    // -----------------------------------------------------------------------
    // GET /
    // La route est publique : l'utilisateur, s'il y en a un, est connu (Auth::user()),
    // mais rien n'est exigé.
    // -----------------------------------------------------------------------
    public function accueil(): RedirectResponse
    {
        $utilisateur = Auth::user();
        return redirect($utilisateur ? self::pageAccueil($utilisateur->role) : route('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)
    // -----------------------------------------------------------------------
    public function formulaire(Request $request): View|RedirectResponse
    {
        // déjà connecté (jeton encore valide) : directement à la page d'accueil
        if ($utilisateur = Auth::user()) {
            return redirect(self::pageAccueil($utilisateur->role));
        }
        return $this->page(Str::limit((string) $request->query('login', ''), 30, ''), null);
    }

    // -----------------------------------------------------------------------
    // POST /login
    // -----------------------------------------------------------------------
    public function connexion(Request $request): View|RedirectResponse
    {
        $login = (string) $request->input('login', '');
        $password = (string) $request->input('password', '');
        $ip = (string) $request->ip();

        // 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->input('captchaId'), $request->input('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);
        return redirect(self::pageAccueil($user->role))->withCookie($this->auth->cookieJeton($user));
    }

    // -----------------------------------------------------------------------
    // 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.
    // -----------------------------------------------------------------------
    public function deconnexion(): RedirectResponse
    {
        return redirect()->route('login')->withCookie(AuthService::cookieEffacement());
    }

    // 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
    private function page(string $login, ?Message $erreur): View
    {
        return view('login', ['titrePage' => 'LOGIN.TITLE', 'erreur' => $erreur, 'login' => $login,
            'captcha' => $this->captchas->generer()]);
    }
}

Le contrôleur de la connexion, qui reprend l'exemple 27.

  • lignes 41-44 : la page d'accueil de chaque rôle : « Mon espace » pour un patient, l'agenda pour les autres. [route('espace')] fabrique l'URL de la route nommée [espace] (exemple 04) ;
  • lignes 51-55 : l'adresse [/] renvoie vers la page d'accueil du rôle, ou vers la page de connexion. La route est publique : [Auth::user()] rend l'utilisateur, s'il y en a un, sans rien exiger ;
  • lignes 61-68 : 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) ; [Str::limit] le coupe à 30 caractères, sans rien ajouter (le troisième paramètre, [''], remplace les points de suspension habituels) ;
  • lignes 73-100 : la connexion, dans l'ordre de l'exemple 27 : 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. 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 ([withCookie]) ;
  • lignes 107-110 : la déconnexion efface le cookie du jeton (exemple 25).

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.13. La couche web : les services communs

Le dossier [app/Web] rassemble ce que les contrôleurs ont en commun.

11.13.1. app/Web/ApplicationModel.php

<?php
// =========================================================================
// app/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 Laravel, 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 singleton (cf. AppServiceProvider), mais un singleton 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\Metier\DonneesPersonne;
use App\Metier\Metier;
use App\Metier\MetierService;
use App\Models\Client;
use App\Models\Creneau;
use App\Models\Medecin;
use App\Models\Rv;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;

// même contrat que la couche métier
class ApplicationModel implements Metier
{
    private const MEDECINS = 'rdvmedecins.medecins';

    private const CLIENTS = 'rdvmedecins.clients';

    public function __construct(private MetierService $metier)
    {
    }

    // ------------------------------------------------ lectures depuis le cache

    // rememberForever : 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
    public function getAllMedecins(): Collection
    {
        return Cache::rememberForever(self::MEDECINS, fn () => $this->metier->getAllMedecins());
    }

    public function getAllClients(): Collection
    {
        return Cache::rememberForever(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. firstWhere : le premier élément
    // de la collection dont la propriété [user] vaut $idUser
    public function getMedecinByUserId(int $idUser): ?Medecin
    {
        return $this->getAllMedecins()->firstWhere('user', $idUser);
    }

    // ------------------------------------------------ simples délégations

    public function getAllCreneaux(int $idMedecin): Collection
    {
        return $this->metier->getAllCreneaux($idMedecin);
    }

    public function getRvMedecinJour(int $idMedecin, CarbonImmutable $jour): Collection
    {
        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, CarbonImmutable $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): Collection
    {
        return $this->metier->getRvAVenirClient($idClient);
    }

    public function ajouterRv(CarbonImmutable $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é

    public function ajouterMedecin(DonneesPersonne $donnees): Medecin
    {
        return tap($this->metier->ajouterMedecin($donnees), fn () => Cache::forget(self::MEDECINS));
    }

    public function modifierMedecin(int $id, DonneesPersonne $donnees): Medecin
    {
        return tap($this->metier->modifierMedecin($id, $donnees), fn () => Cache::forget(self::MEDECINS));
    }

    public function supprimerMedecin(int $id): void
    {
        $this->metier->supprimerMedecin($id);
        Cache::forget(self::MEDECINS);
    }

    public function ajouterClient(DonneesPersonne $donnees): Client
    {
        return tap($this->metier->ajouterClient($donnees), fn () => Cache::forget(self::CLIENTS));
    }

    public function modifierClient(int $id, DonneesPersonne $donnees): Client
    {
        return tap($this->metier->modifierClient($id, $donnees), fn () => Cache::forget(self::CLIENTS));
    }

    public function supprimerClient(int $id): void
    {
        $this->metier->supprimerClient($id);
        Cache::forget(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 singleton, mais il ne vit que le temps d'une requête. Les listes sont donc rangées dans le cache de [Laravel] (exemple 20), partagé par toutes les requêtes.

  • lignes 51-54 : [Cache::rememberForever(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 la collection d'objets [Eloquent] sous une forme sérialisée (exemple 20), et la reconstruit à la lecture, avec les relations chargées (le compte de chaque personne) ;
  • lignes 64-67 : le médecin d'un compte, cherché dans la liste du cache : [firstWhere('user', $idUser)] rend le premier élément de la collection dont la propriété [user] vaut [$idUser] ;
  • lignes 71-123 : 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 130-133 et suivantes : les écritures. Chaque écriture d'un médecin ou d'un client oublie la liste concernée ([Cache::forget]) : la requête suivante qui en aura besoin la relira en base. [tap(valeur, fonction)] appelle la fonction, puis rend la valeur : ici, le médecin créé, après avoir vidé le cache. 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. 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 artisan cache:clear] le viderait.

11.13.2. app/Web/Message.php et Flash.php

<?php
// app/Web/Message.php : un message à traduire : sa clé, et ses paramètres.
// Les vues l'affichent par {{ __($message->cle, $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
// =========================================================================
// app/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.
//
// Laravel les range dans la SESSION, ici dans un cookie CHIFFRÉ (pilote [cookie],
// cf. .env) : le serveur ne garde rien entre deux requêtes.
//
// Un message ne contient pas un texte, mais une CLÉ de traduction et ses paramètres :
// il est traduit au moment de l'affichage, dans la langue de la page (cf.
// resources/views/fragments). Deux listes : les succès (affichés en haut de la
// page) et les erreurs (affichées en bas).
// =========================================================================

namespace App\Web;

class Flash
{
    public static function succes(string $cle, array $parametres = []): void
    {
        self::ajouter('flash_succes', $cle, $parametres);
    }

    public static function erreur(string $cle, array $parametres = []): void
    {
        self::ajouter('flash_erreurs', $cle, $parametres);
    }

    // session()->flash(clé, valeur) : une valeur lisible pendant la requête
    // SUIVANTE seulement ; on la complète si la requête en cours en a déjà déposé
    private static function ajouter(string $liste, string $cle, array $parametres): void
    {
        $messages = session($liste, []);
        $messages[] = new Message($cle, $parametres);
        session()->flash($liste, $messages);
    }
}

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 listes, rangées dans la session sous les clés [flash_succes] et [flash_erreurs] : les succès s'affichent en haut de la page, les erreurs en bas.

  • lignes 34-39 : une action peut déposer plusieurs messages : on complète la liste déjà déposée par la requête en cours ([session($liste, [])] la lit, avec une liste vide par défaut), puis on la dépose à nouveau. [session()->flash(clé, valeur)] range une valeur qui ne vivra que jusqu'à la fin de la requête suivante (exemple 20).

Les objets [Message] sont rangés tels quels dans la session : [Laravel] sérialise les données de session (exemple 20), et les reconstruit à la requête suivante. Avec le pilote [cookie] (fichier [.env]), elles voyagent dans un cookie chiffré.

11.13.3. app/Web/Outils.php

<?php
// =========================================================================
// app/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).
// Des méthodes STATIQUES : on les appelle sans créer d'objet (Outils::iso($jour)).
// =========================================================================

namespace App\Web;

use App\Auth\SessionInvalide;
use App\Auth\UtilisateurConnecte;
use App\Models\Client;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;

class Outils
{
    // ----------------------------------------------------------------- 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(?string $texte): ?CarbonImmutable
    {
        if ($texte === null || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $texte)) {
            return null;
        }
        // "!Y-m-d" : le point d'exclamation met l'heure à minuit
        $date = CarbonImmutable::createFromFormat('!Y-m-d', $texte);
        return $date !== null && $date->format('Y-m-d') === $texte ? $date : null;
    }

    public static function iso(CarbonImmutable $jour): string
    {
        return $jour->format('Y-m-d');
    }

    // "mercredi 23 septembre 2026" / "Wednesday 23 September 2026", selon la langue
    // de la requête. isoFormat : dddd le jour de la semaine en toutes lettres, D le
    // jour, MMMM le mois en toutes lettres, YYYY l'année (cf. exemple 16)
    public static function formaterJour(CarbonImmutable $jour): string
    {
        return $jour->locale(App::getLocale())->isoFormat('dddd D MMMM YYYY');
    }

    // ----------------------------------------------------------------- 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 static function utilisateur(): UtilisateurConnecte
    {
        return Auth::user() ?? 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 static function patientConnecte(ApplicationModel $application): Client
    {
        return $application->getClientByUserId(self::utilisateur()->userId)
            ?? throw new SessionInvalide();
    }
}

Quelques fonctions utilitaires, sous forme de méthodes statiques.

  • lignes 26-34 : 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) ;
  • lignes 44-47 : « samedi 26 septembre 2026 » ou « Saturday 26 September 2026 », selon la langue de la requête. [isoFormat] (exemple 16) utilise les codes de format de la bibliothèque [JavaScript] [Moment.js] : [dddd], le jour de la semaine en toutes lettres ; [D], le jour ; [MMMM], le mois en toutes lettres ; [YYYY], l'année. [locale(...)] rend une copie de la date réglée sur une langue ;
  • lignes 52-59 : 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. [mixed] : n'importe quel type ;
  • lignes 65-68 : l'utilisateur connecté. [Auth::user()] rend [null] pour un visiteur anonyme ; 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 ;
  • lignes 80-84 : 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.14. Les dictionnaires

Les textes de l'interface sont ceux des autres versions de l'application, qui les rangeaient dans des fichiers JSON imbriqués :

1
2
3
4
5
6
{
  "LOGIN": {
    "TITLE": "Connexion",
    ...
  }
}

La version [Flask] les « aplatissait » au démarrage : [LOGIN.TITLE] désignait le texte [Connexion]. [Laravel] lit des dictionnaires JSON plats (exemple 22), où la clé est le texte à traduire ou un identifiant : nous avons aplati une fois pour toutes les dictionnaires, et remplacé les paramètres [{nom}] des autres versions par la notation de [Laravel], [:nom] :

1
2
3
4
5
6
7
{
    "APP.TITLE": "RdvMedecins",
    "APP.LOGOUT": "Se déconnecter",
    ...
    "MEDECINS.ADDED": "Le médecin :nom a été ajouté (login : :login).",
    ...
}

Les fichiers [lang/fr.json] et [lang/en.json] comptent chacun 153 textes ; ils ne sont pas reproduits ici. Une clé est traduite par [__('APP.LOGOUT')], un texte à paramètres par [__('MEDECINS.ADDED', ['nom' => ..., 'login' => ...])]. [Laravel] cherche d'abord la clé complète dans le dictionnaire JSON de la langue ; s'il ne la trouve pas, il la cherche dans les fichiers [PHP] du dossier [lang/fr] (exemple 09) ; à défaut, il affiche la clé elle-même, ce qui signale immédiatement un oubli. Un texte a été ajouté : [ERRORS.PAGE_EXPIRED], le message de la page 419, que les autres versions n'ont pas.

11.15. Les formulaires

Les formulaires « personne » sont validés par des requêtes de formulaire ([FormRequest], exemple 19) : des classes qui décrivent les règles de validation d'une requête. Rappelons le principe : une action qui reçoit une [FormRequest] en paramètre n'est appelée que si la saisie est valide ; sinon, [Laravel] renvoie le navigateur vers la page du formulaire, avec les messages d'erreur ([$errors] dans la vue) et la saisie à réafficher ([old('champ')]), sans les mots de passe.

11.15.1. app/Http/Requests/ChampsPersonneRequest.php

<?php
// =========================================================================
// app/Http/Requests/ChampsPersonneRequest.php : les champs des formulaires
// "personne" et leurs règles de validation (cf. exemple 19). Trois formulaires
// partagent les mêmes champs d'identité (titre, prénom, nom) :
//
//   ChampsPersonneRequest          titre, prénom, nom
//    ├─ PersonneRequest            + login / mot de passe FACULTATIFS, version
//    │                               (l'ADMIN ajoute ou modifie un médecin / client)
//    ├─ InscriptionRequest         + login / mot de passe / confirmation OBLIGATOIRES
//    │                               (un patient crée son compte)
//    └─ MonCompteRequest           + login, nouveau mot de passe facultatif, mot de
//                                    passe ACTUEL obligatoire, version
//                                    (un patient modifie son compte)
//
// Une classe ABSTRAITE : elle n'est jamais utilisée seule ; ses filles complètent
// ses règles (array_merge) et ses messages.
//
// Rappels (exemple 19) : une action qui reçoit une FormRequest en paramètre n'est
// appelée que si la saisie est valide ; sinon, Laravel renvoie le navigateur vers
// la page du formulaire (redirection), avec les messages d'erreur ($errors dans la
// vue) et la saisie à réafficher (old('champ')) - JAMAIS les mots de passe.
// Les messages sont les textes des dictionnaires lang/*.json : __('ERRORS...').
// =========================================================================

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

abstract class ChampsPersonneRequest extends FormRequest
{
    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;

    // avant la validation : un login est toujours en minuscules. Les espaces
    // superflus ont déjà été retirés de tous les champs par le middleware
    // TrimStrings de Laravel - sauf des mots de passe (password,
    // password_confirmation, current_password), dont ils peuvent faire partie ; un
    // champ vide est devenu null (middleware ConvertEmptyStringsToNull)
    protected function prepareForValidation(): void
    {
        if (is_string($this->input('login'))) {
            $this->merge(['login' => Str::lower($this->input('login'))]);
        }
    }

    // bail : la validation d'un champ s'arrête à sa première règle non respectée -
    // un seul message par champ. Longueurs maximales = celles des colonnes SQL
    public function rules(): array
    {
        return [
            'titre' => ['bail', 'required', Rule::in(self::TITRES)],
            'prenom' => ['bail', 'required', 'string', 'max:20'],
            'nom' => ['bail', 'required', 'string', 'max:20'],
        ];
    }

    // champ.règle -> message
    public function messages(): array
    {
        return [
            'titre' => __('ERRORS.INVALID_TITLE'),
            'prenom.required' => __('ERRORS.FIRSTNAME_REQUIRED'),
            'prenom' => __('ERRORS.FIRSTNAME_TOO_LONG'),
            'nom.required' => __('ERRORS.LASTNAME_REQUIRED'),
            'nom' => __('ERRORS.LASTNAME_TOO_LONG'),
            'login.required' => __('ERRORS.LOGIN_REQUIRED'),
            'login' => __('ERRORS.INVALID_LOGIN'),
            'password.required' => __('ERRORS.PASSWORD_REQUIRED'),
            'password.min' => __('ERRORS.PASSWORD_TOO_SHORT', ['min' => self::MOT_DE_PASSE_MIN]),
            'password' => __('ERRORS.PASSWORD_TOO_LONG'),
            'password_confirmation.required' => __('ERRORS.CONFIRMATION_REQUIRED'),
            'password_confirmation' => __('ERRORS.PASSWORDS_DIFFER'),
            'current_password' => __('ERRORS.CURRENT_PASSWORD_REQUIRED'),
            'version' => __('ERRORS.INVALID_FORM'),
        ];
    }

    // la version (verrou optimiste), si le formulaire en a une
    public function version(): ?int
    {
        return $this->filled('version') ? (int) $this->input('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 règles.

  • lignes 34-40 : les règles de saisie, sous forme de constantes, lues aussi par les vues (la liste des titres) ;
  • lignes 47-52 : [prepareForValidation] est appelée avant la validation : on peut y transformer la saisie. Un login est toujours en minuscules : [merge] remplace la valeur du champ dans la requête. Les autres nettoyages sont déjà faits par deux middlewares globaux de [Laravel] (chapitre sur le cycle de vie) : [TrimStrings] retire les espaces au début et à la fin de toutes les valeurs, sauf celles des champs [password], [password_confirmation] et [current_password] (des espaces peuvent faire partie d'un mot de passe) ; [ConvertEmptyStringsToNull] remplace une chaîne vide par [null]. C'est pourquoi les champs des mots de passe de l'application portent ces noms-là, qui sont ceux de l'usage de [Laravel] ;
  • lignes 56-63 : les règles des trois champs. [bail] arrête la validation d'un champ à sa première règle non respectée : un seul message par champ. [Rule::in(...)] : la valeur doit être l'une de la liste. [max:20] : 20 caractères au plus, la longueur des colonnes SQL ;
  • lignes 66-84 : les messages d'erreur, tirés des dictionnaires. La clé ['prenom.required'] désigne le message de la règle [required] du champ [prenom] ; la clé ['prenom'] seule, le message de toutes les autres règles de ce champ. Le message d'une règle est cherché d'abord sous la clé [champ.règle], puis sous la clé [règle] (le message de cette règle pour tous les champs), puis sous la clé [champ]. Les règles des filles (le login, les mots de passe) ont leurs messages ici : un message inutilisé ne gêne pas. La méthode [messages] est appelée pendant la validation, dans la langue de la requête ;
  • lignes 87-90 : la version (le champ caché du verrou optimiste), sous forme d'entier, ou [null] si le champ est vide ou absent. [filled] : le champ est présent et non vide.

11.15.2. app/Http/Requests/PersonneRequest.php, InscriptionRequest.php, MonCompteRequest.php

<?php
// app/Http/Requests/PersonneRequest.php : le formulaire d'un médecin ou d'un client
// (ADMIN) : login et mot de passe FACULTATIFS (cf. app/Metier/DonneesPersonne.php).

namespace App\Http\Requests;

use App\Metier\DonneesPersonne;

class PersonneRequest extends ChampsPersonneRequest
{
    // nullable : un champ vide (null) est accepté, sans vérifier les autres règles
    public function rules(): array
    {
        return array_merge(parent::rules(), [
            'login' => ['bail', 'nullable', 'string', 'regex:'.self::FORMAT_LOGIN],
            // 72 caractères au plus : au-delà, bcrypt ignore la suite
            'password' => ['bail', 'nullable', 'string', 'max:72'],
            // champ caché du formulaire de modification (verrou optimiste)
            'version' => ['nullable', 'integer'],
        ]);
    }

    // la saisie validée -> les données attendues par la couche métier
    public function donnees(): DonneesPersonne
    {
        return new DonneesPersonne($this->input('titre'), $this->input('nom'), $this->input('prenom'),
            $this->input('login'), $this->input('password'), $this->version());
    }
}

Le formulaire de l'administrateur. [array_merge] ajoute trois règles à celles de la classe mère : le login et le mot de passe sont facultatifs ([nullable] : la valeur [null] est acceptée, et les autres règles ne sont alors pas vérifiées) ; [regex:...] vérifie la forme du login ; [max:72] limite la longueur d'un mot de passe : [bcrypt] n'en prend en compte que les 72 premiers octets (exemple 25). La méthode [donnees] (lignes 24-28) transforme la saisie validée en [DonneesPersonne], pour la couche métier : la couche métier ne connaît pas les requêtes HTTP.

<?php
// app/Http/Requests/InscriptionRequest.php : "Créer un compte client" : login, mot de
// passe et confirmation OBLIGATOIRES.

namespace App\Http\Requests;

use App\Metier\DonneesPersonne;

class InscriptionRequest extends ChampsPersonneRequest
{
    public function rules(): array
    {
        return array_merge(parent::rules(), [
            'login' => ['bail', 'required', 'string', 'regex:'.self::FORMAT_LOGIN],
            'password' => ['bail', 'required', 'string', 'min:'.self::MOT_DE_PASSE_MIN, 'max:72'],
            // same:password : la confirmation doit être identique au mot de passe
            'password_confirmation' => ['bail', 'required', 'same:password'],
        ]);
    }

    public function donnees(): DonneesPersonne
    {
        return new DonneesPersonne($this->input('titre'), $this->input('nom'), $this->input('prenom'),
            $this->input('login'), $this->input('password'));
    }
}

La création d'un compte client : login et mot de passe obligatoires, et confirmation du mot de passe. La règle [same:password] exige que le champ [password_confirmation] ait la même valeur que le champ [password] ; son message (« La confirmation ne correspond pas au mot de passe. ») s'affiche sous le champ de confirmation. [Laravel] a aussi une règle [confirmed], qui vérifie la même chose, mais depuis le champ [password] : son message s'afficherait sous le mot de passe.

<?php
// app/Http/Requests/MonCompteRequest.php : "Gérer mon compte" (un patient) : login
// obligatoire, nouveau mot de passe facultatif, mot de passe ACTUEL obligatoire.

namespace App\Http\Requests;

use App\Metier\DonneesPersonne;

class MonCompteRequest extends ChampsPersonneRequest
{
    public function rules(): array
    {
        return array_merge(parent::rules(), [
            'login' => ['bail', 'required', 'string', 'regex:'.self::FORMAT_LOGIN],
            // facultatif : vide = mot de passe inchangé
            'password' => ['bail', 'nullable', 'string', 'min:'.self::MOT_DE_PASSE_MIN, 'max:72'],
            'password_confirmation' => ['same:password'],
            // le mot de passe ACTUEL, exigé pour toute modification du compte
            'current_password' => ['required'],
            'version' => ['nullable', 'integer'],
        ]);
    }

    public function donnees(): DonneesPersonne
    {
        return new DonneesPersonne($this->input('titre'), $this->input('nom'), $this->input('prenom'),
            $this->input('login'), $this->input('password'), $this->version());
    }
}

« Gérer mon compte » : le nouveau mot de passe est facultatif (vide : inchangé), le mot de passe actuel est obligatoire. La règle [same:password] s'applique même à une confirmation vide : si un nouveau mot de passe est saisi, la confirmation doit l'être aussi. Le mot de passe actuel 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. [Laravel] a une règle [current_password], 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.16. Les modèles des vues

Le dossier [app/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.

<?php
// app/Vues/OptionVue.php : une option d'une liste déroulante (médecin, patient).
//
// Le dossier app/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
// app/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
// app/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
// app/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.

<?php
// app/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
// app/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.17. La couche web : les routes et les contrôleurs

11.17.1. routes/web.php

<?php
// =========================================================================
// routes/web.php : toutes les URL de l'application.
//
// Rappel (cf. bootstrap/app.php) : toutes ces routes exigent un utilisateur
// authentifié, SAUF celles du premier groupe, qui retirent le middleware
// d'authentification (withoutMiddleware). Le middleware [role:...] exige en plus
// l'un des rôles cités ; il s'applique à une route, ou à toutes les routes d'un
// groupe.
//
// ->name(...) : le NOM de la route, avec lequel le code fabrique ses URL
// (route('agenda', [...]), redirect()->route('login')...) : changer une URL ne
// demande alors de modifier que ce fichier.
// =========================================================================

use App\Http\Controllers\AgendaController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ClientsController;
use App\Http\Controllers\EspaceController;
use App\Http\Controllers\InscriptionController;
use App\Http\Controllers\LangueController;
use App\Http\Controllers\MedecinsController;
use App\Http\Middleware\FixerLangue;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Support\Facades\Route;

// ---------------------------------------------------------------- les routes PUBLIQUES
Route::withoutMiddleware(Authenticate::class)->group(function () {
    // la connexion (cf. AuthController)
    Route::controller(AuthController::class)->group(function () {
        Route::get('/', 'accueil')->name('accueil');
        Route::get('login', 'formulaire')->name('login');
        Route::post('login', 'connexion');
        Route::post('logout', 'deconnexion')->name('logout');
    });

    // le choix de la langue : /langue/fr, /langue/en ; toute autre valeur : 404
    Route::get('langue/{code}', [LangueController::class, 'changer'])
        ->whereIn('code', FixerLangue::LANGUES)->name('langue');

    // la création d'un compte client (cf. InscriptionController) ; le formulaire
    // exige un laissez-passer (middleware passe.inscription)
    Route::controller(InscriptionController::class)->prefix('inscription')->group(function () {
        Route::post('acces', 'acces')->name('inscription.acces');
        Route::middleware('passe.inscription')->group(function () {
            Route::get('', 'formulaire')->name('inscription');
            Route::post('', 'creer');
        });
    });

    // aucune autre route ne correspond à l'URL demandée : page 404. Une route de
    // "repli" (fallback) plutôt que le 404 automatique de Laravel : la requête passe
    // ainsi par les middlewares du groupe [web] (session, utilisateur...), et la
    // page d'erreur a sa barre de navigation complète
    Route::fallback(fn () => abort(404));
});

// ---------------------------------------------------------------- l'agenda (tous les rôles)
Route::controller(AgendaController::class)->prefix('agenda')->group(function () {
    Route::get('', 'afficher')->name('agenda');
    Route::post('reserver', 'reserver')->middleware('role:ADMIN,DOCTOR,USER')->name('agenda.reserver');
    // un patient annule SON rendez-vous depuis "Mon espace", pas d'ici
    Route::post('annuler', 'annuler')->middleware('role:ADMIN,DOCTOR')->name('agenda.annuler');
});

// ---------------------------------------------------------------- médecins et clients (ADMIN)
// Les deux sections ont les MÊMES routes (cf. PersonnesController) : une boucle les
// déclare pour chacune. Les noms des routes sont préfixés par la section :
// medecins.liste, clients.liste...
foreach (['medecins' => MedecinsController::class, 'clients' => ClientsController::class] as $section => $controleur) {
    Route::controller($controleur)->prefix($section)->name("$section.")->middleware('role:ADMIN')
        ->group(function () {
            Route::get('', 'liste')->name('liste');
            Route::get('ajouter', 'formulaireAjout')->name('ajout');
            Route::post('ajouter', 'ajouter')->name('ajouter');
            // whereNumber : {id} doit être fait de chiffres
            Route::get('{id}/modifier', 'formulaireModification')->whereNumber('id')->name('modification');
            Route::post('{id}/modifier', 'modifier')->whereNumber('id')->name('modifier');
            Route::post('{id}/supprimer', 'supprimer')->whereNumber('id')->name('supprimer');
        });
}

// ---------------------------------------------------------------- l'espace du patient (USER)
Route::controller(EspaceController::class)->middleware('role:USER')->group(function () {
    Route::get('mon-espace', 'espace')->name('espace');
    Route::post('mon-espace/annuler', 'annulerRv')->name('espace.annuler');
    Route::get('mon-compte', 'formulaireCompte')->name('compte');
    Route::post('mon-compte', 'modifierCompte')->name('compte.modifier');
    Route::post('mon-compte/supprimer', 'supprimerCompte')->name('compte.supprimer');
});

Toutes les URL de l'application. Rappelons que toutes ces routes exigent un utilisateur authentifié (le middleware [Authenticate], ajouté au groupe [web] par [bootstrap/app.php]), sauf celles qui le retirent.

  • lignes 28-56 : les routes publiques. [withoutMiddleware] retire un middleware aux routes du groupe : ici, l'authentification. Ce sont la connexion ([AuthController]), le choix de la langue, et la création d'un compte client ;
  • lignes 30-35 : [Route::controller] (exemple 02) indique le contrôleur des routes du groupe : chaque route ne nomme plus que sa méthode. [->name('login')] donne un nom à la route (exemple 04) : le code fabrique ses URL par ce nom ([route('login')], [redirect()->route('login')]), et changer une URL ne demande de modifier que ce fichier. Le nom [login] a en outre un sens pour [Laravel] : c'est la page vers laquelle le middleware [Authenticate] renvoie un visiteur anonyme, si on ne lui en indique pas d'autre ;
  • lignes 38-39 : [whereIn('code', [...])] n'accepte, pour le paramètre [{code}], que les valeurs de la liste : [/langue/de] ne correspond à aucune route (page 404) ;
  • lignes 43-49 : la création de compte. [prefix('inscription')] ajoute [/inscription] devant les URL du groupe : [Route::get('', ...)] est la route [/inscription] elle-même. Le formulaire (GET et POST) exige en plus un laissez-passer : le middleware d'alias [passe.inscription] ;
  • ligne 55 : la route de repli, choisie quand aucune autre ne correspond à une requête GET. Sans elle, [Laravel] produit lui-même l'erreur 404, avant les middlewares du groupe [web] : ni session, ni cookies déchiffrés... La page d'erreur n'aurait ni l'utilisateur dans sa barre de navigation, ni de jeton anti-CSRF pour le bouton de déconnexion. Avec la route de repli, la requête traverse le groupe [web], puis l'action lève l'erreur 404. La route est publique : une adresse inconnue donne une page 404, même à un visiteur anonyme. Effet de bord : une requête GET vers une URL qui n'existe qu'en POST ([/logout]) donne elle aussi une page 404, et non 405 ;
  • lignes 59-64 : l'agenda. Sa page est ouverte à tous les utilisateurs authentifiés ; la réservation, aux trois rôles (c'est-à-dire à tout utilisateur authentifié ; la liste des rôles documente la règle) ; l'annulation, à l'administrateur et aux médecins : [->middleware('role:ADMIN,DOCTOR')] ;
  • lignes 70-81 : les routes des médecins et des clients sont les mêmes : une boucle les déclare pour chacune des deux sections. Le fichier des routes est un fichier [PHP] ordinaire : on peut y écrire des boucles, des conditions, des fonctions. Pour chaque section, un groupe : le contrôleur, le préfixe des URL ([/medecins]), le préfixe des noms ([->name("$section.")] : la route nommée [liste] s'appelle [medecins.liste]), et le middleware [role:ADMIN], qui protège toutes les routes du groupe - y compris celles qu'on lui ajouterait plus tard. [whereNumber('id')] n'accepte que des chiffres pour le paramètre [{id}] (exemple 02) ;
  • lignes 84-90 : l'espace du patient, réservé au rôle USER.

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. La commande [php artisan route:list] affiche la liste des routes, avec leurs noms et leurs actions.

11.17.2. app/Http/Controllers/LangueController.php

<?php
// =========================================================================
// app/Http/Controllers/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, le middleware
// 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\Http\Controllers;

use App\Http\Middleware\FixerLangue;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class LangueController
{
    // {code} est limité aux langues gérées par la route (whereIn) : /langue/de donne une page 404
    public function changer(Request $request, string $code): RedirectResponse
    {
        // un an : 60 * 24 * 365 minutes
        return redirect($this->pageDOrigine($request))->withCookie(cookie(
            FixerLangue::COOKIE, $code, 60 * 24 * 365, '/', null,
            config('rdvmedecins.cookie_secure'), true, false, 'lax'));
    }

    // -----------------------------------------------------------------------
    // 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 n'est pas chiffré (cf. [bootstrap/app.php]) ; il est [httpOnly] : le [JavaScript] de la page n'a pas à le lire.

  • lignes 35-47 : 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.17.3. app/Http/Controllers/AgendaController.php

<?php
// =========================================================================
// app/Http/Controllers/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é (règle
// générale, cf. bootstrap/app.php) ; le middleware [role] des routes POST exige en
// plus l'un des rôles cités (cf. routes/web.php).
// =========================================================================

namespace App\Http\Controllers;

use App\Domain\AgendaMedecinJour;
use App\Metier\Dates;
use App\Metier\MetierException;
use App\Models\Client;
use App\Models\Personne;
use App\Models\Role;
use App\Vues\AgendaVue;
use App\Vues\LigneAgenda;
use App\Vues\OptionVue;
use App\Vues\ReservationVue;
use App\Web\ApplicationModel;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Outils;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class AgendaController
{
    public function __construct(private ApplicationModel $application)
    {
    }

    // -----------------------------------------------------------------------
    // 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=).
    // -----------------------------------------------------------------------
    public function afficher(Request $request): View
    {
        $erreur = null;
        $utilisateur = Outils::utilisateur();
        $droits = $this->droits();

        // le jour : celui de l'URL s'il est valide, sinon aujourd'hui
        $jour = Dates::aujourdhui();
        $jourBrut = $request->query('jour');
        if ($jourBrut !== null) {
            $jourDemande = Outils::dateValide(is_string($jourBrut) ? $jourBrut : null);
            if ($jourDemande !== null) {
                $jour = $jourDemande;
            } else {
                $erreur = 'ERRORS.INVALID_DATE';
            }
        }
        $jourReservable = $jour->gte(Dates::aujourdhui());

        // le médecin : celui de l'URL ; à défaut, pour un médecin connecté, lui-même
        $idMedecinBrut = $request->query('idMedecin');
        $idMedecin = Outils::entierPositif($idMedecinBrut);
        if ($idMedecinBrut === null && $utilisateur->role === Role::DOCTOR) {
            $idMedecin = $this->application->getMedecinByUserId($utilisateur->userId)?->id;
        }

        // la liste déroulante des médecins
        $medecins = $this->application->getAllMedecins()
            ->map(fn (Personne $m) => self::option($m, $m->id === $idMedecin))->all();

        // 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('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']
                        ? $this->application->getAllClients()->map(fn (Client $c) => self::option($c, false))->all()
                        : [],
                    $droits['patient']?->libelle());
            }
        }

        return view('agenda', [
            '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 ? self::urlAgenda($idMedecin, $jour) : route('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).
    // -----------------------------------------------------------------------
    public function reserver(Request $request): RedirectResponse
    {
        [$idMedecin, $jour] = $this->agendaDemande($request);
        $idCreneau = Outils::entierPositif($request->input('idCreneau'));
        if ($idMedecin === null || $idCreneau === null) {
            abort(400);    // -> page "requête invalide" (cf. bootstrap/app.php)
        }
        $utilisateur = Outils::utilisateur();

        if ($utilisateur->estPatient()) {
            $client = Outils::patientConnecte($this->application);
        } else {
            $idClient = Outils::entierPositif($request->input('idClient'));
            if ($idClient === null) {
                // aucun patient choisi : on rouvre la fenêtre de réservation
                Flash::erreur('ERRORS.CHOOSE_PATIENT');
                return redirect(self::urlAgenda($idMedecin, $jour, $idCreneau));
            }
            $client = $this->application->getClientById($idClient);
        }

        $creneau = $this->application->getCreneauById($idCreneau);
        if ($client === null) {
            Flash::erreur('ERRORS.PATIENT_NOT_FOUND');
        } elseif ($creneau === null || $creneau->id_medecin !== $idMedecin) {
            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);
                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()) {
                    Flash::erreur('AGENDA.ALREADY_BOOKED_INFO');
                } else {
                    Flash::erreur($e->cle, $e->parametres);
                }
            }
        }
        return redirect(self::urlAgenda($idMedecin, $jour));
    }

    // -----------------------------------------------------------------------
    // POST /agenda/annuler   champs : idRv, idMedecin, jour
    // (un patient annule SON rendez-vous depuis "Mon espace", pas d'ici)
    // -----------------------------------------------------------------------
    public function annuler(Request $request): RedirectResponse
    {
        [$idMedecin, $jour] = $this->agendaDemande($request);
        $idRv = Outils::entierPositif($request->input('idRv'));
        if ($idMedecin === null || $idRv === null) {
            abort(400);
        }
        $rv = $this->application->getRvById($idRv);
        if ($rv === null) {
            // déjà annulé (par quelqu'un d'autre, ou double clic)
            Flash::erreur('ERRORS.APPOINTMENT_NOT_FOUND');
        } else {
            $this->application->supprimerRv($rv);
            Flash::succes('AGENDA.CANCELLED');
        }
        return redirect(self::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 (! Outils::utilisateur()->estPatient()) {
            return ['gestionnaire' => true, 'patient' => null, 'patientPeutReserver' => false];
        }
        $patient = Outils::patientConnecte($this->application);
        $rvAVenir = $this->application->getRvAVenirClient($patient->id);
        return ['gestionnaire' => false, 'patient' => $patient, 'patientPeutReserver' => $rvAVenir->isEmpty()];
    }

    // -----------------------------------------------------------------------
    // 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->id;
        $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->id_client === $droits['patient']->id;
            $lignes[] = new LigneAgenda(
                idCreneau: $creneau->id,
                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->client->libelle() : null,
                moi: $moi,
                reservable: $libre && $jourReservable && ($droits['gestionnaire'] || $droits['patientPeutReserver']),
                urlReserver: self::urlAgenda($idMedecin, $jour, $creneau->id),
                idRv: $rv !== null && $droits['gestionnaire'] ? $rv->id : null);
        }
        return new AgendaVue($agendaMedecin->medecin->libelle(), 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->input('idMedecin'));
        $jour = Outils::dateValide(is_string($request->input('jour')) ? $request->input('jour') : null);
        return $idMedecin === null || $jour === null ? [null, null] : [$idMedecin, $jour];
    }

    // /agenda?idMedecin=1&jour=2026-09-23[&reserver=7] : route() ajoute à la fin de
    // l'URL les paramètres qui ne sont pas des paramètres du chemin
    private static function urlAgenda(int $idMedecin, CarbonImmutable $jour, ?int $reserver = null): string
    {
        $parametres = ['idMedecin' => $idMedecin, 'jour' => Outils::iso($jour)];
        if ($reserver !== null) {
            $parametres['reserver'] = $reserver;
        }
        return route('agenda', $parametres);
    }

    private static function option(Personne $p, bool $selectionne): OptionVue
    {
        return new OptionVue($p->id, $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é.
    // -----------------------------------------------------------------------
    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.

11.17.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.17.3.2. GET /agenda

Lignes 76-148 :

  • les paramètres de l'URL sont lus bruts ([$request->query('jour')]), sans validation par [Laravel] : 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, sans empêcher d'afficher le reste ; 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]). [?->id] : l'opérateur nullsafe rend [null] si l'objet est [null], au lieu de lever une erreur ;
  • la liste déroulante des médecins : chaque médecin devient une [OptionVue] ; [map(...)->all()] rend un tableau ;
  • 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] ([PHP] 8.4) 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 ([view('agenda', [...])], exemple 13), dont [titrePage] et [erreur], lues par le gabarit.

11.17.3.3. POST /agenda/reserver

Lignes 161-206 :

  • les champs cachés du formulaire de réservation (le médecin, le jour, le créneau) sont vérifiés ; invalides, ce ne peut être qu'une requête fabriquée à la main : [abort(400)], la page « requête invalide » ;
  • 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. 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.17.3.4. POST /agenda/annuler

Lignes 212-228 : l'annulation d'un rendez-vous, par l'administrateur ou un médecin (le middleware [role:ADMIN,DOCTOR] de la route). 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.

11.17.3.5. Les droits, l'agenda mis en forme et les fonctions utilitaires

  • lignes 236-244 : 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 249-273 : l'agenda du médecin, transformé en lignes pour la vue. Le point de confidentialité est la ligne 266 : 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->id]) rendent lisible la construction d'un objet qui a huit propriétés ;
  • lignes 288-295 : l'URL de l'agenda d'un médecin un jour donné. [route('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 308-315 : 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.17.4. app/Http/Controllers/PersonnesController.php

<?php
// =========================================================================
// app/Http/Controllers/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. On obtient ainsi, pour chacun (cf. routes/web.php) :
//
//   GET  /medecins                   liste
//   GET  /medecins/ajouter           formulaire vide
//   POST /medecins/ajouter           création
//   GET  /medecins/{id}/modifier     formulaire pré-rempli
//   POST /medecins/{id}/modifier     modification
//   POST /medecins/{id}/supprimer    suppression
//
// (et les mêmes URL sous /clients). Les vues sont elles aussi communes :
// resources/views/personnes/liste.blade.php et personnes/formulaire.blade.php.
//
// SÉCURITÉ : le middleware [role:ADMIN] est posé sur le GROUPE des routes de chaque
// section (cf. routes/web.php) : il protège toutes ses actions - y compris celles
// qu'on lui ajouterait plus tard.
// =========================================================================

namespace App\Http\Controllers;

use App\Http\Requests\PersonneRequest;
use App\Metier\DonneesPersonne;
use App\Metier\MetierException;
use App\Models\Personne;
use App\Vues\ClesPersonnes;
use App\Web\ApplicationModel;
use App\Web\Flash;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

abstract class PersonnesController
{
    // 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)
    {
    }

    // ---- ce que chaque type de personne doit fournir

    // "medecins" ou "clients" : le début des URL et des noms des routes
    abstract protected function section(): string;

    abstract protected function cles(): ClesPersonnes;

    abstract protected function lister(): Collection;

    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)
    // -------------------------------------------------------------------
    public function liste(): View
    {
        return view('personnes.liste', ['titrePage' => $this->cles()->titre, 'cles' => $this->cles(),
            'section' => $this->section(), 'personnes' => $this->lister()]);
    }

    // -------------------------------------------------------------------
    // GET /medecins/ajouter
    // -------------------------------------------------------------------
    public function formulaireAjout(): View
    {
        return $this->formulaire(null);
    }

    // -------------------------------------------------------------------
    // POST /medecins/ajouter
    // PersonneRequest a déjà validé la saisie : une saisie invalide n'arrive pas
    // jusqu'ici (Laravel a renvoyé au formulaire, avec les erreurs)
    // -------------------------------------------------------------------
    public function ajouter(PersonneRequest $request): RedirectResponse
    {
        try {
            $personne = $this->creer($request->donnees());
        } catch (MetierException $e) {
            return $this->erreurMetier($e);
        }
        Flash::succes($this->cles()->ajoute, self::parametres($personne));
        return redirect()->route($this->section().'.liste');
    }

    // -------------------------------------------------------------------
    // GET /medecins/{id}/modifier
    // {id} doit être fait de chiffres (whereNumber, cf. routes/web.php) :
    // /medecins/abc/modifier ne correspond à aucune route (page 404)
    // -------------------------------------------------------------------
    public function formulaireModification(int $id): View
    {
        $personne = $this->trouver($id) ?? abort(404);
        return $this->formulaire($personne);
    }

    // -------------------------------------------------------------------
    // POST /medecins/{id}/modifier
    // -------------------------------------------------------------------
    public function modifier(PersonneRequest $request, int $id): RedirectResponse
    {
        try {
            $personne = $this->mettreAJour($id, $request->donnees());
        } catch (MetierException $e) {
            return $this->erreurMetier($e);
        }
        Flash::succes($this->cles()->modifie, self::parametres($personne));
        return redirect()->route($this->section().'.liste');
    }

    // -------------------------------------------------------------------
    // POST /medecins/{id}/supprimer
    // (la confirmation "Êtes-vous sûr ?" est demandée avant l'envoi du formulaire,
    // cf. public/js/app.js)
    // -------------------------------------------------------------------
    public function supprimer(int $id): RedirectResponse
    {
        $personne = $this->trouver($id);
        try {
            $this->effacer($id);
            Flash::succes($this->cles()->supprime, self::parametres($personne));
        } catch (MetierException $e) {
            Flash::erreur($e->cle, $e->parametres);
        }
        return redirect()->route($this->section().'.liste');
    }

    // ----------------------------------------------------------------- fonctions internes

    // $personne null : formulaire de création ; sinon : formulaire de modification,
    // pré-rempli avec ses valeurs (sauf si la vue a une saisie à réafficher : old())
    private function formulaire(?Personne $personne): View
    {
        $creation = $personne === null;
        $cles = $this->cles();
        return view('personnes.formulaire', [
            'titrePage' => $creation ? $cles->ajouter : $cles->modifier,
            'creation' => $creation,
            'personne' => $personne,
            'action' => $creation ? route($this->section().'.ajouter')
                : route($this->section().'.modifier', ['id' => $personne->id]),
            'urlListe' => route($this->section().'.liste'),
        ]);
    }

    // une règle de gestion a refusé l'écriture : retour au formulaire (back), avec
    // la saisie de l'utilisateur (withInput, sans les mots 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): RedirectResponse
    {
        Flash::erreur($e->cle, $e->parametres);
        if (in_array($e->cle, ['ERRORS.DOCTOR_NOT_FOUND', 'ERRORS.PATIENT_NOT_FOUND'], true)) {
            // supprimé entre-temps par un autre ADMIN : retour à la liste
            return redirect()->route($this->section().'.liste');
        }
        return back()->withInput(request()->except('password'))
            ->with('champsInvalides', 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->prenom} {$personne->nom}", 'login' => $personne->compte?->login ?? ''];
    }
}

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 56-68, 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.

  • lignes 43-47 : 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 ;
  • lignes 73-77 : 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 URL ([route("$section.ajout")]) ;
  • lignes 92-101 : l'ajout. La saisie a déjà été validée par [PersonneRequest] : une saisie invalide n'arrive pas jusqu'ici ([Laravel] a renvoyé le navigateur au formulaire, avec les erreurs). Reste 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 108-112 : le formulaire de modification, pour une personne qui existe : [?? abort(404)] ([abort] lève une exception : c'est une expression, utilisable à droite de [??]) ;
  • lignes 117-126 : la modification ; l'id vient du chemin de l'URL ([{id}]), les données du formulaire ;
  • lignes 133-143 : 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 149-161 : le formulaire, de création ([$personne] vaut [null]) ou de modification. La vue reçoit la personne, dont elle affiche les valeurs - sauf après un refus, où elle réaffiche la saisie ([old], exemple 19) ;
  • lignes 167-176 : la couche métier a refusé l'écriture. Une personne supprimée entre-temps (par un autre administrateur) : retour à la liste. Sinon, retour au formulaire ([back()], la page précédente, exemple 19), comme pour une erreur de saisie : la saisie à réafficher ([withInput], sans le mot de passe), le message de la couche métier (un message flash d'erreur, affiché en bas de la page), et les champs à encadrer en rouge ([with('champsInvalides', ...)], une donnée flash lue par la vue). [request()] : la requête en cours, accessible partout ;
  • lignes 179-183 : les paramètres [:nom] et [:login] des messages de succès.

11.17.5. app/Http/Controllers/MedecinsController.php et ClientsController.php

<?php
// app/Http/Controllers/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\Http\Controllers;

use App\Metier\DonneesPersonne;
use App\Models\Personne;
use App\Vues\ClesPersonnes;
use Illuminate\Database\Eloquent\Collection;

class MedecinsController extends PersonnesController
{
    protected function section(): string
    {
        return 'medecins';
    }

    // MEDECINS.TITLE, MEDECINS.ADD... (cf. lang/*.json)
    protected function cles(): ClesPersonnes
    {
        return ClesPersonnes::de('MEDECINS');
    }

    protected function lister(): Collection
    {
        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). Le constructeur est hérité de [PersonnesController] : l'[ApplicationModel] est dans la propriété [$this->application], déclarée [protected] (visible des classes filles). [ClientsController] est identique, avec les méthodes des clients et les clés [CLIENTS.xxx].

11.17.6. app/Http/Controllers/InscriptionController.php

<?php
// =========================================================================
// app/Http/Controllers/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. app/Auth/PasseInscription.php) ;
//   2. sans laissez-passer valide, /inscription renvoie à la page
//      d'authentification (middleware VerifierPasseInscription) ;
//   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. app/Auth/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\Http\Controllers;

use App\Auth\CaptchaService;
use App\Auth\LimiteurTentatives;
use App\Auth\PasseInscription;
use App\Http\Requests\InscriptionRequest;
use App\Metier\MetierException;
use App\Web\ApplicationModel;
use App\Web\Flash;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class InscriptionController
{
    // les champs qu'on ne renvoie JAMAIS au navigateur
    private const MOTS_DE_PASSE = ['password', 'password_confirmation'];

    public function __construct(
        private ApplicationModel $application,
        private CaptchaService $captchas,
        private LimiteurTentatives $limiteur,
        private PasseInscription $passes,
    ) {
    }

    // -----------------------------------------------------------------------
    // POST /inscription/acces   champs : captchaId, captcha (ceux de la page
    // d'authentification ; login et mot de passe, envoyés aussi, sont ignorés)
    // -----------------------------------------------------------------------
    public function acces(Request $request): RedirectResponse
    {
        $ip = (string) $request->ip();

        // trop de comptes créés récemment depuis cette adresse : inutile d'aller plus loin
        $minutes = $this->limiteur->minutesBlocageInscription($ip);
        if ($minutes > 0) {
            Flash::erreur('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
            return redirect()->route('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->input('captchaId'), $request->input('captcha'))) {
            $this->limiteur->echecLogin($ip, '');
            Flash::erreur('ERRORS.CAPTCHA_BEFORE_ACCOUNT');
            return redirect()->route('login');
        }

        // captcha correct : on délivre le laissez-passer et on ouvre le formulaire
        return redirect()->route('inscription')
            ->withCookie(PasseInscription::cookie($this->passes->emettre()));
    }

    // -----------------------------------------------------------------------
    // GET /inscription (le laissez-passer a été vérifié par le middleware)
    // -----------------------------------------------------------------------
    public function formulaire(): View
    {
        return view('inscription', ['titrePage' => 'INSCRIPTION.TITLE']);
    }

    // -----------------------------------------------------------------------
    // POST /inscription. InscriptionRequest a déjà validé TOUTE la saisie (champs
    // et confirmation du mot de passe) : l'utilisateur corrige toutes ses erreurs
    // en une fois
    // -----------------------------------------------------------------------
    public function creer(InscriptionRequest $request): RedirectResponse
    {
        $ip = (string) $request->ip();

        // 1. trop de comptes créés récemment depuis cette adresse ?
        $minutes = $this->limiteur->minutesBlocageInscription($ip);
        if ($minutes > 0) {
            Flash::erreur('ERRORS.TOO_MANY_ACCOUNTS', ['minutes' => $minutes]);
            return back()->withInput($request->except(self::MOTS_DE_PASSE));
        }

        // 2. la création (client + compte USER, dans une transaction)
        try {
            $this->application->ajouterClient($request->donnees());
        } catch (MetierException $e) {
            // login ou prénom + nom déjà pris : le laissez-passer reste valable
            Flash::erreur($e->cle, $e->parametres);
            return back()->withInput($request->except(self::MOTS_DE_PASSE))
                ->with('champsInvalides', 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->cookie(PasseInscription::COOKIE));
        $this->limiteur->inscriptionReussie($ip);

        // 4. retour à la page de connexion, login pré-rempli
        $login = $request->input('login');
        Flash::succes('INSCRIPTION.DONE', ['login' => $login]);
        return redirect()->route('login', ['login' => $login])
            ->withCookie(PasseInscription::cookieEffacement());
    }
}

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 60-82 : 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 87-90 : le formulaire. Le laissez-passer a été vérifié par le middleware [passe.inscription] de la route ;
  • lignes 97-127 : la création. [InscriptionRequest] a validé toute la saisie, confirmation du mot de passe comprise : l'utilisateur voit toutes ses erreurs d'un coup. 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 ([route('login', ['login' => $login])] ajoute le paramètre [?login=...]), avec un message de succès. Le cookie du laissez-passer est effacé.

La constante [MOTS_DE_PASSE] (ligne 46) liste les champs à ne jamais renvoyer au navigateur : [back()->withInput()], sans paramètre, conserverait toute la saisie pour le formulaire suivant, mots de passe compris ; [$request->except([...])] rend la saisie sans ces champs. Quand c'est [Laravel] qui renvoie au formulaire (une erreur de validation), il écarte de lui-même les champs [password], [password_confirmation] et [current_password].

11.17.7. app/Http/Controllers/EspaceController.php

<?php
// =========================================================================
// app/Http/Controllers/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
// (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).
// =========================================================================

namespace App\Http\Controllers;

use App\Auth\AuthService;
use App\Auth\LimiteurTentatives;
use App\Http\Requests\MonCompteRequest;
use App\Metier\MetierException;
use App\Models\Client;
use App\Models\Rv;
use App\Vues\RendezVousVue;
use App\Web\ApplicationModel;
use App\Web\Flash;
use App\Web\Message;
use App\Web\Outils;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class EspaceController
{
    public function __construct(
        private ApplicationModel $application,
        private AuthService $auth,
        private LimiteurTentatives $limiteur,
    ) {
    }

    // -----------------------------------------------------------------------
    // GET /mon-espace
    // -----------------------------------------------------------------------
    public function espace(): View
    {
        $patient = Outils::patientConnecte($this->application);
        $rendezVous = $this->application->getRvAVenirClient($patient->id)->map(fn (Rv $rv) => new RendezVousVue(
            $rv->id,
            Outils::formaterJour($rv->jour),
            $rv->creneau->horaire(),
            $rv->creneau->medecin->libelle(),
            // lien vers l'agenda du médecin ce jour-là : le patient y voit son nom
            route('agenda', ['idMedecin' => $rv->creneau->id_medecin, 'jour' => Outils::iso($rv->jour)])))
            ->all();
        return view('espace', ['titrePage' => 'ESPACE.TITLE', 'patient' => $patient->libelle(),
            'rendezVous' => $rendezVous]);
    }

    // -----------------------------------------------------------------------
    // POST /mon-espace/annuler   champ : idRv
    // -----------------------------------------------------------------------
    public function annulerRv(Request $request): RedirectResponse
    {
        $patient = Outils::patientConnecte($this->application);
        $idRv = Outils::entierPositif($request->input('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->id_client !== $patient->id) {
            Flash::erreur('ERRORS.APPOINTMENT_NOT_FOUND');
        } else {
            $this->application->supprimerRv($rv);
            Flash::succes('AGENDA.CANCELLED');
        }
        return redirect()->route('espace');
    }

    // -----------------------------------------------------------------------
    // GET /mon-compte : le formulaire, pré-rempli avec les valeurs du patient
    // -----------------------------------------------------------------------
    public function formulaireCompte(): View
    {
        $patient = Outils::patientConnecte($this->application);
        return view('compte', ['titrePage' => 'MON_COMPTE.TITLE', 'patient' => $patient]);
    }

    // -----------------------------------------------------------------------
    // POST /mon-compte (MonCompteRequest a validé la saisie)
    // -----------------------------------------------------------------------
    public function modifierCompte(MonCompteRequest $request): RedirectResponse
    {
        $patient = Outils::patientConnecte($this->application);

        // le mot de passe actuel ; faux : le message va SOUS le champ, et en bas de page
        $erreur = $this->verifierMotDePasseActuel($request, $patient, $request->input('current_password'));
        if ($erreur !== null) {
            Flash::erreur($erreur->cle, $erreur->parametres);
            return back()->withInput($request->except(['password', 'password_confirmation', 'current_password']))
                ->withErrors(['current_password' => __($erreur->cle, $erreur->parametres)]);
        }

        try {
            $client = $this->application->modifierClient($patient->id, $request->donnees());
        } catch (MetierException $e) {
            Flash::erreur($e->cle, $e->parametres);
            return back()->withInput($request->except(['password', 'password_confirmation', 'current_password']))
                ->with('champsInvalides', 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
        Flash::succes('MON_COMPTE.UPDATED');
        return redirect()->route('espace')->withCookie($this->auth->cookieJeton($client->compte));
    }

    // -----------------------------------------------------------------------
    // POST /mon-compte/supprimer   champ : current_password
    // Le compte, le client et TOUS ses rendez-vous disparaissent ensemble, puis le
    // patient est déconnecté.
    // -----------------------------------------------------------------------
    public function supprimerCompte(Request $request): RedirectResponse
    {
        $patient = Outils::patientConnecte($this->application);
        $erreur = $this->verifierMotDePasseActuel($request, $patient, $request->input('current_password'));
        if ($erreur !== null) {
            Flash::erreur($erreur->cle, $erreur->parametres);
            return redirect()->route('compte');
        }
        $this->application->supprimerClient($patient->id);
        Flash::succes('MON_COMPTE.DELETED');
        return redirect()->route('login')->withCookie(AuthService::cookieEffacement());
    }

    // ----------------------------------------------------------------- fonctions internes

    // 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->ip();
        $login = $patient->compte->login;
        $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 53-66 : 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 71-85 : 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 90-94 : le formulaire « Gérer mon compte », pré-rempli avec les valeurs du patient ;
  • lignes 99-122 : 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 : retour au formulaire, avec le message sous le champ ([withErrors] ajoute une erreur de saisie, comme si la validation l'avait trouvée) et en bas de la page (message flash). 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 129-140 : 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. Le mot de passe actuel est exigé ; le patient est ensuite déconnecté (cookie du jeton effacé) ;
  • lignes 145-158 : 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.18. Les vues

Les vues [Blade] du dossier [resources/views] : un gabarit, des fragments, des composants, et les pages.

11.18.1. Le gabarit et les fragments

11.18.1.1. resources/views/layout.blade.php

<!doctype html>
{{-- =========================================================================
     resources/views/layout.blade.php
     -------------------------------------------------------------------------
     Le gabarit COMMUN à toutes les pages (cf. exemple 15) : chaque page
     (agenda.blade.php, login.blade.php...) l'ÉTEND (@extends) et remplit sa
     section [contenu].

     Blade ÉCHAPPE tout ce qu'il affiche avec {{ ... }} (les caractères < > & " '
     deviennent &lt; &gt;...) : protection contre l'injection de HTML via un nom
     saisi, par exemple. Pour insérer du HTML tel quel, il faut le demander
     explicitement ({!! ... !!}, cf. components/captcha.blade.php).

     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) ; [langue] et [utilisateur], ajoutés à toutes
     les vues par AppServiceProvider.
     ========================================================================= --}}
<html lang="{{ $langue }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {{-- __($cle) : la traduction d'une clé ; ici, la clé est une VARIABLE --}}
  <title>{{ __($titrePage) }} - {{ __('APP.TITLE') }}</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')
    @include('fragments.succes')
    <main>
      @yield('contenu')
    </main>
    @include('fragments.erreurs')
  </div>
  {{-- la fenêtre "Êtes-vous sûr ?", cachée tant qu'on n'en a pas besoin --}}
  @include('fragments.confirmation')
  <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

Le gabarit commun à toutes les pages (exemple 15) : chaque page l'étend ([@extends('layout')]) et remplit sa section [contenu] (ligne 36).

  • ligne 21 : la langue de la page, [fr] ou [en], fournie à toutes les vues par le compositeur de vue d'[AppServiceProvider] ;
  • ligne 26 : le titre de la page, traduit : [__($titrePage)] 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 ;
  • ligne 41 : la fenêtre « Êtes-vous sûr ? », cachée tant qu'on n'en a pas besoin, et le script qui l'affiche.

[Blade] échappe tout ce qu'il affiche avec [{{ ... }}] (exemple 14) : 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.18.1.2. resources/views/fragments/navbar.blade.php

{{-- =========================================================================
     resources/views/fragments/navbar.blade.php - 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 middlewares des
     routes (un USER qui tape /medecins à la main obtient une page 403). Un patient
     (USER) a en plus son menu "Mon espace".

     Le menu de la page courante est mis en évidence (classe "active") :
     request()->is('agenda*') est vrai si le chemin de l'URL commence par "agenda".
     Les entrées du menu : un tableau [URL, clé du libellé, motif des chemins pour
     lesquels l'entrée est active], parcouru par @foreach.
     ========================================================================= --}}
@php
  $menu = [];
  if ($utilisateur) {
      if ($utilisateur->estPatient()) {
          // active pour /mon-espace ET /mon-compte
          $menu[] = [route('espace'), 'APP.MENU_MY_SPACE', 'mon-*'];
      }
      $menu[] = [route('agenda'), 'APP.MENU_AGENDA', 'agenda*'];
      if ($utilisateur->estAdmin()) {
          $menu[] = [route('medecins.liste'), 'APP.MENU_DOCTORS', 'medecins*'];
          $menu[] = [route('clients.liste'), 'APP.MENU_PATIENTS', 'clients*'];
      }
  }
@endphp
<nav class="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
  <span class="navbar-brand mb-0">{{ __('APP.TITLE') }}</span>

  <ul class="navbar-nav me-auto">
    @foreach ($menu as [$href, $cle, $motif])
    <li class="nav-item">
      {{-- @class : les classes dont la condition est vraie --}}
      <a @class(['nav-link', 'active' => request()->is($motif)]) href="{{ $href }}">{{ __($cle) }}</a>
    </li>
    @endforeach
  </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">
      @foreach (['fr', 'en'] as $code)
      <a class="btn {{ $langue === $code ? 'btn-light' : 'btn-outline-light' }}"
         href="{{ route('langue', ['code' => $code]) }}">{{ strtoupper($code) }}</a>
      @endforeach
    </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="{{ route('logout') }}" class="m-0">
      @csrf
      <button type="submit" class="btn btn-sm btn-outline-light">{{ __('APP.LOGOUT') }}</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 middlewares des routes (un patient qui tape [/medecins] à la main obtient une page 403).

  • lignes 15-28 : le menu est préparé dans un bloc [@php] (exemple 14), un tableau d'entrées : l'URL, la clé du libellé, et le motif des chemins pour lesquels l'entrée est active. [request()->is('agenda*')] est vrai si le chemin de l'URL demandée correspond au motif ([*] : n'importe quelle suite de caractères). L'entrée « Mon espace » est active pour [/mon-espace] et [/mon-compte] (motif [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 33-38 : [@foreach] déstructure chaque entrée du tableau en trois variables (exemple 25). [@class([...])] (exemple 16) écrit l'attribut [class] avec les classes dont la condition est vraie ;
  • lignes 44-47 : les boutons FR / EN, de simples liens ([/langue/fr]), la langue active en surbrillance ;
  • lignes 50-58 : 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], exemple 18).

11.18.1.3. resources/views/fragments/succes.blade.php

{{-- =========================================================================
     resources/views/fragments/succes.blade.php - 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. app/Web/Flash.php). Un message flash
     d'ERREUR, lui, est affiché en bas de la page, par le fragment erreurs.

     Chaque message est un objet Message : __($message->cle, $message->parametres)
     est le texte de la clé, où :nom, :login... sont remplacés par leurs valeurs.
     ========================================================================= --}}
@foreach (session('flash_succes', []) as $message)
<div class="alert alert-success" role="status">{{ __($message->cle, $message->parametres) }}</div>
@endforeach

Les messages flash de succès, déposés par l'action précédente ([Flash::succes]), lus dans la session par [session('flash_succes', [])] (une liste vide par défaut). Chacun est un objet [Message], traduit ici, dans la langue de la page : [__($message->cle, $message->parametres)]. [role="status"] : un lecteur d'écran annonce le message.

11.18.1.4. resources/views/fragments/erreurs.blade.php

{{-- =========================================================================
     resources/views/fragments/erreurs.blade.php - 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é...), 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é, login déjà pris, suppression refusée...) ;
       - les erreurs de saisie ($errors, 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.
     ========================================================================= --}}
@php
  $messages = session('flash_erreurs', []);
  if ($erreur ?? null) {
      array_unshift($messages, $erreur);
  }
  if (! $messages && $errors->any()) {
      $messages[] = new App\Web\Message('ERRORS.INVALID_FORM');
  }
@endphp
@if ($messages)
<div class="zone-erreur" role="alert">
  @foreach ($messages as $message)
  <div>{{ __($message->cle, $message->parametres) }}</div>
  @endforeach
</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 :

  • l'erreur de la page affichée ([$erreur]), transmise par l'action : une date invalide, un captcha mal recopié... [$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 messages flash d'erreur, déposés par l'action précédente (un créneau déjà réservé, un login déjà pris...) ;
  • les erreurs de saisie ([$errors], 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.

[array_unshift] ajoute un élément au début d'un tableau. [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.18.1.5. resources/views/fragments/confirmation.blade.php

{{-- =========================================================================
     resources/views/fragments/confirmation.blade.php - 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') }}</h5>
          <button type="button" class="btn-close" data-action="non" aria-label="{{ __('BOOKING_DIALOG.CLOSE_ARIA') }}"></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') }}</button>
          <button type="button" class="btn btn-danger" data-action="oui">{{ __('CONFIRMATION.YES') }}</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.18.1.6. resources/views/fragments/reservation.blade.php

{{-- =========================================================================
     resources/views/fragments/reservation.blade.php - 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="{{ route('agenda.reserver') }}" class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="titre-reservation">{{ __('BOOKING_DIALOG.TITLE') }}</h5>
        <a class="btn-close" href="{{ $urlRetour }}" aria-label="{{ __('BOOKING_DIALOG.CLOSE_ARIA') }}"></a>
      </div>

      <div class="modal-body">
        <p>{{ __('BOOKING_DIALOG.SLOT', ['horaire' => $reservation->horaire]) }}</p>

        {{-- ce que le serveur doit savoir en plus du patient choisi --}}
        @csrf
        <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') }}</span> :
          <strong>{{ $reservation->patient }}</strong>
        </p>
        @else
        <label class="form-label" for="idClient">{{ __('BOOKING_DIALOG.PATIENT_LABEL') }}</label>
        <select id="idClient" name="idClient" class="form-select" required autofocus>
          <option value="">{{ __('BOOKING_DIALOG.CHOOSE_PATIENT') }}</option>
          @foreach ($reservation->clients as $client)
          <option value="{{ $client->id }}">{{ $client->libelle }}</option>
          @endforeach
        </select>
        @endif
      </div>

      <div class="modal-footer">
        <a class="btn btn-secondary" href="{{ $urlRetour }}">{{ __('BOOKING_DIALOG.CANCEL') }}</a>
        <button type="submit" class="btn btn-primary">{{ __('BOOKING_DIALOG.CONFIRM') }}</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.18.2. Les composants

Trois composants anonymes (exemple 15), dans le dossier [resources/views/components], utilisés par les pages sous la forme d'une balise [<x-nom>].

11.18.2.1. resources/views/components/champ.blade.php

{{-- =========================================================================
     resources/views/components/champ.blade.php - un champ de saisie, son libellé,
     son message d'erreur ou son aide (cf. exemple 19) : <x-champ nom="login" ... />

     @props : les attributs attendus par le composant, avec leur valeur par défaut.
     La valeur affichée : old($nom, $valeur), la saisie à réafficher après un refus,
     sinon la valeur transmise. Un mot de passe n'est JAMAIS réaffiché.
     Le champ est encadré en rouge (classe Bootstrap is-invalid) s'il a une erreur
     de saisie ($errors), ou si une erreur métier le désigne (champsInvalides, cf.
     PersonnesController::erreurMetier) ; le message d'erreur remplace alors l'aide.
     ========================================================================= --}}
@props(['nom', 'libelle', 'type' => 'text', 'valeur' => null, 'aide' => null, 'max' => null,
        'autocomplete' => null, 'requis' => false])
@php
  $invalide = $errors->has($nom) || in_array($nom, session('champsInvalides', []), true);
@endphp
<label class="form-label" for="{{ $nom }}">{{ $libelle }}</label>
<input id="{{ $nom }}" name="{{ $nom }}" type="{{ $type }}" @class(['form-control', 'is-invalid' => $invalide])
       @if ($type !== 'password') value="{{ old($nom, $valeur) }}" @endif
       @if ($max) maxlength="{{ $max }}" @endif
       @if ($autocomplete) autocomplete="{{ $autocomplete }}" @endif
       @required($requis)>
@error($nom)
<div class="invalid-feedback">{{ $message }}</div>
@else
@if ($aide)
<div class="form-text">{{ $aide }}</div>
@endif
@enderror

Un champ de saisie, avec son libellé, et son message d'erreur ou son aide : le composant [x-champ] de l'exemple 19, complété.

  • ligne 12 : les attributs du composant, et leur valeur par défaut. Dans une page, [<x-champ nom="login" :libelle="__('PERSONNES.LOGIN_LABEL')" max="30" requis />] : un attribut précédé de [:] reçoit la valeur d'une expression [PHP], les autres une chaîne ; un attribut sans valeur ([requis]) vaut [true] ;
  • lignes 14-16 : le champ est invalide s'il a une erreur de saisie ([$errors->has($nom)]), ou si une erreur de la couche métier le désigne : la liste [champsInvalides], déposée dans la session par l'action ([PersonnesController::erreurMetier]). 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 ;
  • lignes 18-22 : la valeur affichée est [old($nom, $valeur)] : la saisie à réafficher après un refus, sinon la valeur transmise par la page (celle de la fiche modifiée). Un mot de passe n'est jamais réaffiché : pas d'attribut [value] pour un champ [password]. [@required($requis)] écrit l'attribut booléen [required] si la condition est vraie ;
  • lignes 23-29 : le message d'erreur du champ ([$message], exemple 19), sinon l'aide éventuelle. La classe [invalid-feedback] de [Bootstrap] n'est visible qu'après un champ de classe [is-invalid].

11.18.2.2. resources/views/components/champs-personne.blade.php

{{-- =========================================================================
     resources/views/components/champs-personne.blade.php - 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" : <x-champs-personne :personne="..." />

     :personne : la personne dont on affiche les valeurs (null pour un formulaire
     vide). $personne?->titre : null si $personne est null (opérateur "nullsafe").
     La liste des titres vient de la classe qui les valide (ChampsPersonneRequest).
     ========================================================================= --}}
@props(['personne' => null])
@php
  $titreChoisi = old('titre', $personne?->titre ?? 'Mr');
@endphp
<div class="mb-3">
  <label class="form-label" for="titre">{{ __('PERSONNES.TITLE_LABEL') }}</label>
  <select id="titre" name="titre" @class(['form-select', 'is-invalid' => $errors->has('titre')])>
    @foreach (App\Http\Requests\ChampsPersonneRequest::TITRES as $titre)
    <option value="{{ $titre }}" @selected($titre === $titreChoisi)>{{ $titre }}</option>
    @endforeach
  </select>
  @error('titre')
  <div class="invalid-feedback">{{ $message }}</div>
  @enderror
</div>

<div class="row g-3 mb-3">
  <div class="col-sm-6">
    <x-champ nom="prenom" :libelle="__('PERSONNES.FIRSTNAME_LABEL')" :valeur="$personne?->prenom" max="20" requis />
  </div>
  <div class="col-sm-6">
    <x-champ nom="nom" :libelle="__('PERSONNES.LASTNAME_LABEL')" :valeur="$personne?->nom" max="20" requis />
  </div>
</div>

Les champs d'identité, communs à trois formulaires : le titre (une liste), le prénom et le nom (deux [x-champ] : un composant peut utiliser un autre composant). L'attribut [personne] est la personne dont on affiche les valeurs, ou [null] pour un formulaire vide. [$personne?->titre] : l'opérateur nullsafe rend [null] si [$personne] l'est. La liste des titres vient de la classe qui les valide ([ChampsPersonneRequest::TITRES]) : la règle est écrite une fois. [@selected(...)] écrit l'attribut [selected] de l'option choisie.

11.18.2.3. resources/views/components/captcha.blade.php

{{-- =========================================================================
     resources/views/components/captcha.blade.php - le captcha de la page
     d'authentification (cf. app/Auth/CaptchaService.php). Il protège à la fois la
     connexion et l'accès à la création de compte (bouton "Créer un compte client").

     {!! ... !!} : 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 (url()->current() : l'URL de la page, sans ses paramètres).
     ========================================================================= --}}
@props(['captcha'])
<div class="mb-3">
  <label class="form-label" for="captcha">{{ __('CAPTCHA.LABEL') }}</label>
  <div class="d-flex align-items-center gap-3 mb-2">
    <div class="captcha-image" role="img" aria-label="{{ __('CAPTCHA.IMAGE_ALT') }}">{!! $captcha->svg !!}</div>
    <a class="small" href="{{ url()->current() }}">{{ __('CAPTCHA.NEW_IMAGE') }}</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') }}</div>
</div>

Le captcha (exemple 27). [{!! $captcha->svg !!}] 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 ([url()->current()] : l'URL de la page, sans ses paramètres), et donc le captcha.

11.18.3. Les pages

11.18.3.1. resources/views/login.blade.php

{{-- =========================================================================
     resources/views/login.blade.php - le formulaire de connexion (cf.
     AuthController). Un <form> HTML ordinaire : 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')

@section('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') }}</h5>

      <form method="post" action="{{ route('login') }}">
        @csrf
        <div class="mb-3">
          <label class="form-label" for="login">{{ __('LOGIN.LOGIN_LABEL') }}</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 @if ($login === '') autofocus @endif>
        </div>
        <div class="mb-3">
          <label class="form-label" for="password">{{ __('LOGIN.PASSWORD_LABEL') }}</label>
          <input id="password" name="password" type="password" class="form-control"
                 autocomplete="current-password" required @if ($login !== '') autofocus @endif>
        </div>

        <x-captcha :captcha="$captcha" />

        <button type="submit" class="btn btn-primary w-100">{{ __('LOGIN.SUBMIT') }}</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="{{ route('inscription.acces') }}" formnovalidate>{{ __('LOGIN.CREATE_ACCOUNT') }}</button>
        <div class="form-text text-center">{{ __('LOGIN.CREATE_ACCOUNT_HELP') }}</div>
      </form>

      <div class="text-muted small mt-3">
        <span>{{ __('LOGIN.DEMO_ACCOUNTS') }}</span>
        <ul class="mb-0 mt-1">
          <li><code>admin</code> - <span>{{ __('LOGIN.DEMO_ADMIN') }}</span></li>
          <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - <span>{{ __('LOGIN.DEMO_DOCTOR') }}</span></li>
          <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - <span>{{ __('LOGIN.DEMO_USER') }}</span></li>
        </ul>
      </div>
    </div>
  </div>
</div>
@endsection

Le formulaire de connexion, un [<form>] ordinaire, envoyé en POST à [/login].

  • lignes 22-23 : 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) ;
  • 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.18.3.2. resources/views/agenda.blade.php

{{-- =========================================================================
     resources/views/agenda.blade.php - 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')

@section('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="{{ route('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') }}</label>
        <select id="idMedecin" name="idMedecin" class="form-select" required>
          <option value="">{{ __('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR') }}</option>
          @foreach ($medecins as $medecin)
          <option value="{{ $medecin->id }}" @selected($medecin->selectionne)>{{ $medecin->libelle }}</option>
          @endforeach
        </select>
      </div>
      <div class="col-sm-4">
        <label class="form-label" for="jour">{{ __('DOCTOR_DAY_PICKER.DAY_LABEL') }}</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') }}</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) }}</span>
    <a href="{{ route('espace') }}" class="alert-link">{{ __('APP.MENU_MY_SPACE') }}</a>
  </div>
  @endif

  {{-- 2. l'agenda --}}
  @if ($agenda)
  <div class="card p-3">
    <h5>{{ __('AGENDA.TITLE_PREFIX') }} {{ $agenda->jour }} - {{ $agenda->medecin }}</h5>

    @if ($agenda->lignes)
    <table class="table table-hover align-middle">
      <thead>
        <tr>
          <th>{{ __('AGENDA.COLUMN_SLOT') }}</th>
          <th>{{ __('AGENDA.COLUMN_STATUS') }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @foreach ($agenda->lignes as $ligne)
        <tr @class(['creneau-libre' => $ligne->reservable])>
          <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') }}</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') }}</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') }}</a>
            @endif
            {{-- "Annuler" MODIFIE des données : c'est donc un POST --}}
            @if ($ligne->idRv)
            <form method="post" action="{{ route('agenda.annuler') }}" class="m-0"
                  data-confirmation="{{ __('AGENDA.CANCEL_CONFIRM') }}">
              @csrf
              <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') }}</button>
            </form>
            @endif
          </td>
        </tr>
        @endforeach
      </tbody>
    </table>
    @else
    <p class="text-muted mb-0">{{ __('AGENDA.NO_SLOTS') }}</p>
    @endif
  </div>
  @else
  <p class="text-muted">{{ __('AGENDA.EMPTY_STATE') }}</p>
  @endif

  {{-- 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... --}}
  @if ($reservation)
  @include('fragments.reservation')
  @endif
@endsection

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.18.3.3. resources/views/personnes/liste.blade.php

{{-- =========================================================================
     resources/views/personnes/liste.blade.php - 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).
     ========================================================================= --}}
@extends('layout')

@section('contenu')
  <div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">{{ __($cles->titre) }}</h4>
    <a class="btn btn-primary" href="{{ route("$section.ajout") }}">{{ __($cles->ajouter) }}</a>
  </div>

  <div class="card p-3">
    @if ($personnes->isNotEmpty())
    <table class="table table-hover align-middle mb-0">
      <thead>
        <tr>
          <th>{{ __('PERSONNES.COLUMN_TITLE') }}</th>
          <th>{{ __('PERSONNES.COLUMN_FIRSTNAME') }}</th>
          <th>{{ __('PERSONNES.COLUMN_LASTNAME') }}</th>
          <th>{{ __('PERSONNES.COLUMN_LOGIN') }}</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @foreach ($personnes as $personne)
        <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="{{ route("$section.modification", ['id' => $personne->id]) }}">{{ __('PERSONNES.EDIT') }}</a>
              {{-- data-confirmation : une fenêtre de confirmation s'ouvrira avant
                   l'envoi du formulaire (cf. public/js/app.js) --}}
              <form method="post" action="{{ route("$section.supprimer", ['id' => $personne->id]) }}" class="m-0"
                    data-confirmation="{{ __($cles->confirmerSuppression) }}">
                @csrf
                <button type="submit" class="btn btn-sm btn-outline-danger">{{ __('PERSONNES.DELETE') }}</button>
              </form>
            </div>
          </td>
        </tr>
        @endforeach
      </tbody>
    </table>
    @else
    <p class="text-muted mb-0">{{ __($cles->vide) }}</p>
    @endif
  </div>
@endsection

La liste des médecins ou des clients : les libellés propres à chacun sont des clés de traduction ([$cles]), et les URL sont fabriquées à partir du nom de la section ([route("$section.modification", ['id' => ...])] : dans une chaîne entre guillemets, [$section] est remplacée par sa valeur, [medecins] ou [clients]). [$personne->compte?->login] : le login du compte, chargé avec la liste ([with('compte')], couche DAO). [isNotEmpty()] : la collection n'est pas vide. Le bouton Supprimer est un formulaire POST, avec confirmation.

11.18.3.4. resources/views/personnes/formulaire.blade.php

{{-- =========================================================================
     resources/views/personnes/formulaire.blade.php - création OU modification d'un
     médecin ou d'un client (ADMIN). $creation distingue les deux cas ; $personne
     est la personne modifiée (null pour une création).

     Quand la saisie est refusée (champ vide, login déjà pris...), l'action renvoie
     le navigateur vers CETTE page (redirection), et la vue affiche :
       - les valeurs déjà saisies (old(), dans les composants x-champ) ;
       - 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).
     ========================================================================= --}}
@extends('layout')

@section('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) }}</h5>

      {{-- novalidate : on laisse le SERVEUR valider (c'est lui qui fait foi) ; les
           contrôles du navigateur (required...) ne sont qu'un confort, qu'un
           utilisateur peut toujours contourner --}}
      <form method="post" action="{{ $action }}" novalidate>
        @csrf
        @unless ($creation)
        {{-- la version de la fiche affichée : le verrou optimiste --}}
        <input type="hidden" name="version" value="{{ old('version', $personne->version) }}">
        @endunless

        {{-- titre, prénom, nom : composant commun à trois formulaires --}}
        <x-champs-personne :personne="$personne" />

        <h6 class="mt-4 text-muted">{{ __('PERSONNES.ACCOUNT_SECTION') }}</h6>

        <div class="mb-3">
          <x-champ nom="login" :libelle="__('PERSONNES.LOGIN_LABEL')" :valeur="$personne?->compte?->login"
                   max="30" autocomplete="off"
                   :aide="__($creation ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT')" />
        </div>

        <div class="mb-4">
          {{-- jamais pré-rempli : le mot de passe ne repart jamais vers le navigateur --}}
          <x-champ nom="password" type="password" :libelle="__('PERSONNES.PASSWORD_LABEL')" max="72"
                   autocomplete="new-password"
                   :aide="__($creation ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT')" />
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ __('PERSONNES.SAVE') }}</button>
          <a class="btn btn-secondary" href="{{ $urlListe }}">{{ __('PERSONNES.BACK') }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
@endsection

La création ou la modification d'un médecin ou d'un client.

  • ligne 24 : l'attribut [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. Les contrôles du navigateur ne sont qu'un confort, que n'importe qui peut contourner ; les désactiver ici permet de voir les messages du serveur ;
  • lignes 26-29 : [@unless] (« sauf si ») est le contraire de [@if]. En modification, la version de la fiche (le verrou optimiste) voyage dans un champ caché ;
  • ligne 32 : titre, prénom et nom, pré-remplis avec les valeurs de la personne ;
  • lignes 37-44 : le login, pré-rempli avec celui du compte ([$personne?->compte?->login] : deux opérateurs nullsafe enchaînés), et le mot de passe, jamais pré-rempli. Leurs aides diffèrent en création (« par défaut, le nom en minuscules ») et en modification (« laisser vide pour ne pas le changer »).

11.18.3.5. resources/views/inscription.blade.php

{{-- =========================================================================
     resources/views/inscription.blade.php - "Créer un compte client" (page
     publique, cf. InscriptionController). Mêmes champs d'identité que le
     formulaire de l'ADMIN (composant x-champs-personne), 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')

@section('contenu')
@php($min = App\Http\Requests\ChampsPersonneRequest::MOT_DE_PASSE_MIN)
<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') }}</h5>
      <p class="text-muted small mb-3">{{ __('INSCRIPTION.INTRO') }}</p>

      <form method="post" action="{{ route('inscription') }}" novalidate>
        @csrf
        <x-champs-personne />

        <h6 class="mt-4 text-muted">{{ __('PERSONNES.ACCOUNT_SECTION') }}</h6>

        <div class="mb-3">
          <x-champ nom="login" :libelle="__('PERSONNES.LOGIN_LABEL')" max="30" autocomplete="username" requis
                   :aide="__('INSCRIPTION.LOGIN_HELP')" />
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <x-champ nom="password" type="password" :libelle="__('PERSONNES.PASSWORD_LABEL')" max="72"
                     autocomplete="new-password" requis :aide="__('INSCRIPTION.PASSWORD_HELP', ['min' => $min])" />
          </div>
          <div class="col-sm-6">
            <x-champ nom="password_confirmation" type="password" :libelle="__('COMPTE.CONFIRMATION_LABEL')"
                     max="72" autocomplete="new-password" requis />
          </div>
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ __('INSCRIPTION.SUBMIT') }}</button>
          <a class="btn btn-secondary" href="{{ route('login') }}">{{ __('INSCRIPTION.BACK_TO_LOGIN') }}</a>
        </div>
      </form>
    </div>
  </div>
</div>
@endsection

La création d'un compte client : les champs d'identité (un formulaire vide), puis le login, le mot de passe et sa confirmation, obligatoires. [@php($min = ...)] : la forme courte de [@php], pour une seule instruction. L'aide du mot de passe annonce la longueur minimale : [__('INSCRIPTION.PASSWORD_HELP', ['min' => $min])], « 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.18.3.6. resources/views/espace.blade.php

{{-- =========================================================================
     resources/views/espace.blade.php - "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')

@section('contenu')
  <h4 class="mb-3">{{ __('ESPACE.HELLO', ['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="{{ route('compte') }}">
        <h5 class="mb-1">{{ __('ESPACE.MANAGE_ACCOUNT') }}</h5>
        <span class="text-muted">{{ __('ESPACE.MANAGE_ACCOUNT_HELP') }}</span>
      </a>
    </div>
    <div class="col-sm-6">
      <a class="card p-4 h-100 text-decoration-none carte-option" href="{{ route('agenda') }}">
        <h5 class="mb-1">{{ __('ESPACE.VIEW_AGENDA') }}</h5>
        <span class="text-muted">{{ __('ESPACE.VIEW_AGENDA_HELP') }}</span>
      </a>
    </div>
  </div>

  <div class="card p-3">
    <h5>{{ __('ESPACE.MY_APPOINTMENT') }}</h5>
    {{-- normalement un seul (règle "un rendez-vous à venir à la fois"), mais la
         page sait en afficher plusieurs. @forelse ... @empty : une boucle, et ce
         qu'on affiche si la liste est vide ; $loop->last : dernier élément ? --}}
    @forelse ($rendezVous as $rv)
    <div @class(['d-flex flex-wrap justify-content-between align-items-center gap-2 py-2',
                 'border-bottom' => ! $loop->last])>
      <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') }}</a>
        </div>
      </div>
      <form method="post" action="{{ route('espace.annuler') }}" class="m-0"
            data-confirmation="{{ __('AGENDA.CANCEL_CONFIRM') }}">
        @csrf
        <input type="hidden" name="idRv" value="{{ $rv->id }}">
        <button type="submit" class="btn btn-outline-danger">{{ __('ESPACE.CANCEL_APPOINTMENT') }}</button>
      </form>
    </div>
    @empty
    <p class="text-muted mb-0">{{ __('ESPACE.NO_APPOINTMENT') }}</p>
    @endforelse
  </div>
@endsection

« 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-51 : [@forelse] (exemple 14) est une boucle, suivie ([@empty]) 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.18.3.7. resources/views/compte.blade.php

{{-- =========================================================================
     resources/views/compte.blade.php - "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) ;
       2. la suppression du compte - qui annule aussi ses rendez-vous.
     L'un comme l'autre exigent le mot de passe ACTUEL (champ current_password).
     ========================================================================= --}}
@extends('layout')

@section('contenu')
@php($min = App\Http\Requests\ChampsPersonneRequest::MOT_DE_PASSE_MIN)
<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') }}</h5>

      <form method="post" action="{{ route('compte.modifier') }}" novalidate>
        @csrf
        <input type="hidden" name="version" value="{{ old('version', $patient->version) }}">

        <x-champs-personne :personne="$patient" />

        <h6 class="mt-4 text-muted">{{ __('PERSONNES.ACCOUNT_SECTION') }}</h6>

        <div class="mb-3">
          <x-champ nom="login" :libelle="__('PERSONNES.LOGIN_LABEL')" :valeur="$patient->compte->login"
                   max="30" autocomplete="username" requis />
        </div>

        <div class="row g-3 mb-3">
          <div class="col-sm-6">
            <x-champ nom="password" type="password" :libelle="__('MON_COMPTE.NEW_PASSWORD_LABEL')" max="72"
                     autocomplete="new-password" :aide="__('PERSONNES.PASSWORD_HELP_EDIT')" />
          </div>
          <div class="col-sm-6">
            <x-champ nom="password_confirmation" type="password" :libelle="__('COMPTE.CONFIRMATION_LABEL')"
                     max="72" autocomplete="new-password" />
          </div>
        </div>

        <div class="mb-4">
          <x-champ nom="current_password" type="password" :libelle="__('MON_COMPTE.CURRENT_PASSWORD_LABEL')"
                   max="72" autocomplete="current-password" requis
                   :aide="__('MON_COMPTE.CURRENT_PASSWORD_HELP')" />
        </div>

        <div class="d-flex gap-2">
          <button type="submit" class="btn btn-primary">{{ __('PERSONNES.SAVE') }}</button>
          <a class="btn btn-secondary" href="{{ route('espace') }}">{{ __('MON_COMPTE.BACK') }}</a>
        </div>
      </form>
    </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') }}</h5>
      <p class="text-muted small">{{ __('MON_COMPTE.DELETE_HELP') }}</p>
      <form method="post" action="{{ route('compte.supprimer') }}" class="d-flex flex-wrap gap-2 align-items-end"
            data-confirmation="{{ __('MON_COMPTE.DELETE_CONFIRM') }}">
        @csrf
        <div class="flex-grow-1">
          <label class="form-label" for="mot_de_passe_suppression">{{ __('MON_COMPTE.CURRENT_PASSWORD_LABEL') }}</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') }}</button>
      </form>
    </div>

  </div>
</div>
@endsection

« Gérer mon compte » : deux formulaires indépendants sur une même page.

  • lignes 19-53 : la modification : la version (verrou optimiste), les champs d'identité, le login, le nouveau mot de passe et sa confirmation (facultatifs), et le mot de passe actuel, obligatoire ;
  • lignes 62-71 : la suppression du compte, avec son propre champ de mot de passe actuel, et une confirmation. Le champ a le même nom que celui du premier formulaire ([current_password] : un seul formulaire est envoyé à la fois), mais pas le même id : deux éléments d'une page ne peuvent pas avoir le même id, que l'attribut [for] d'un libellé désigne.

11.18.3.8. resources/views/erreur.blade.php

{{-- =========================================================================
     resources/views/erreur.blade.php - la page d'erreur (400, 403, 404, 419,
     500...), fabriquée par bootstrap/app.php (withExceptions).
     ========================================================================= --}}
@extends('layout')

@section('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) }}</p>
      <div><a class="btn btn-primary" href="{{ route('accueil') }}">{{ __('ERROR_PAGE.BACK') }}</a></div>
    </div>
  </div>
</div>
@endsection

La page d'erreur, fabriquée par [bootstrap/app.php] : 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.19. 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.19.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. resources/views/fragments/erreurs.blade.php).
   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.19.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 : resources/views/fragments/confirmation.blade.php).
// 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-21 : 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-50 : 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.