Chapitre 1038 minIntermédiaire
CRUD complet en PHP avec PDO et MySQL
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- afficher une liste avec recherche, filtre et pagination ;
- écrire les formulaires d'ajout et de modification ;
- supprimer sans perdre l'historique ;
- vérifier un CRUD avec une liste de contrôle de sécurité.
4 exercices corrigés1 schéma
Un CRUD (Create, Read, Update, Delete) regroupe les quatre opérations de base sur des données : créer, lire, modifier et supprimer. C'est le cœur de presque toutes les applications de gestion, et la première chose que l'on attend d'une réalisation professionnelle. Ce chapitre construit le CRUD complet du matériel de Prêtmat en PHP avec PDO : une liste avec recherche, filtre et pagination, une fiche détaillée, les formulaires d'ajout et de modification, et la suppression, avec la validation, la protection CSRF, le contrôle des droits et la gestion des erreurs vus dans les chapitres précédents.
Les quatre opérations
| Opération | Page de Prêtmat | Méthode HTTP | SQL | Qui |
|---|---|---|---|---|
| Read (liste) | materiels.php?q=…&categorie=…&page=… | GET | SELECT … LIMIT | Tout utilisateur connecté |
| Read (fiche) | materiel.php?id=4 | GET | SELECT … WHERE id = :id | Tout utilisateur connecté |
| Create | ajouter.php | GET (formulaire), POST (traitement) | INSERT | Gestionnaire |
| Update | modifier.php?id=4 | GET (formulaire prérempli), POST (traitement) | UPDATE | Gestionnaire |
| Delete | supprimer.php | POST uniquement | DELETE | Gestionnaire |
Ce chapitre écrit une page PHP par écran, dans le dossier public/. C'est la façon la plus directe de comprendre le CRUD ; le chapitre 12 réorganise ensuite le même code avec un contrôleur frontal, des contrôleurs et des vues.
Le fichier de démarrage
Chaque page commence par les mêmes lignes : typage strict, session, connexion, fonctions utilitaires. On les regroupe dans un fichier de démarrage, inclus en tête de chaque page :
<?php
declare(strict_types=1);
// src/bootstrap.php
date_default_timezone_set('Europe/Paris');
session_start([
'name' => 'pretmat_session',
'cookie_httponly' => true,
'cookie_secure' => (getenv('APP_ENV') ?: 'prod') === 'prod',
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
]);
require __DIR__ . '/bdd.php'; // bdd() : chapitre 7
require __DIR__ . '/helpers.php'; // e(), flash(), flashes() : chapitres 5 et 6
require __DIR__ . '/validation.php'; // texte(), validerMateriel() : chapitre 4
require __DIR__ . '/auth.php'; // exigerConnexion(), exigerRole() : chapitre 9
require __DIR__ . '/csrf.php'; // champCsrf(), verifierCsrf() : chapitre 9
require __DIR__ . '/materiels.php'; // les fonctions d'accès aux données, ci-dessous
Le dossier src/ est en dehors de la racine du site (public/) : aucun de ces fichiers n'est accessible directement depuis un navigateur.
require ou include ? | Si le fichier manque | Usage |
|---|---|---|
require | Erreur fatale, le script s'arrête | Un fichier indispensable : configuration, fonctions |
include | Simple avertissement, le script continue | Rarement utile |
require_once, include_once | Comme ci-dessus, mais le fichier n'est inclus qu'une fois | Un fichier de fonctions inclus depuis plusieurs endroits |
Le chemin commence toujours par __DIR__, le dossier du fichier courant : un chemin relatif simple (require 'bdd.php') dépend du dossier depuis lequel le script a été lancé, et casse au premier déplacement.
Les fonctions d'accès aux données
Les requêtes SQL ne sont pas écrites dans les pages, mais regroupées dans des fonctions, dans src/materiels.php. Les pages ne voient plus de SQL ; elles appellent trouverMateriel($pdo, 4). C'est le début d'une couche d'accès aux données, que le chapitre 11 transforme en classe.
<?php
declare(strict_types=1);
// src/materiels.php
/** Construit la clause WHERE et ses paramètres à partir des filtres validés. */
function conditionsMateriels(array $filtres): array
{
$conditions = [];
$params = [];
if ($filtres['q'] !== '') {
$motif = '%' . addcslashes($filtres['q'], '%_\\') . '%';
$conditions[] = '(m.reference LIKE :q1 OR m.libelle LIKE :q2)';
$params['q1'] = $motif;
$params['q2'] = $motif;
}
if ($filtres['categorie'] !== '') {
$conditions[] = 'm.categorie = :categorie';
$params['categorie'] = $filtres['categorie'];
}
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
return [$where, $params];
}
function compterMateriels(PDO $pdo, array $filtres): int
{
[$where, $params] = conditionsMateriels($filtres);
$stmt = $pdo->prepare("SELECT COUNT(*) FROM materiel m $where");
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
function listerMateriels(PDO $pdo, array $filtres, int $page, int $parPage): array
{
[$where, $params] = conditionsMateriels($filtres);
$stmt = $pdo->prepare(
"SELECT m.id, m.reference, m.libelle, m.categorie, m.etat,
EXISTS (SELECT 1 FROM emprunt e
WHERE e.id_materiel = m.id AND e.date_retour IS NULL) AS prete
FROM materiel m
$where
ORDER BY m.reference
LIMIT :limite OFFSET :decalage"
);
foreach ($params as $nom => $valeur) {
$stmt->bindValue($nom, $valeur);
}
$stmt->bindValue('limite', $parPage, PDO::PARAM_INT);
$stmt->bindValue('decalage', ($page - 1) * $parPage, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function trouverMateriel(PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare('SELECT * FROM materiel WHERE id = :id');
$stmt->execute(['id' => $id]);
$materiel = $stmt->fetch();
return $materiel === false ? null : $materiel;
}
function ajouterMateriel(PDO $pdo, array $valeurs): int
{
$stmt = $pdo->prepare(
'INSERT INTO materiel (reference, libelle, categorie, date_achat, valeur)
VALUES (:reference, :libelle, :categorie, :date_achat, :valeur)'
);
$stmt->execute($valeurs);
return (int) $pdo->lastInsertId();
}
function modifierMateriel(PDO $pdo, int $id, array $valeurs): void
{
$stmt = $pdo->prepare(
'UPDATE materiel
SET reference = :reference, libelle = :libelle, categorie = :categorie,
date_achat = :date_achat, valeur = :valeur
WHERE id = :id'
);
$stmt->execute([...$valeurs, 'id' => $id]);
}
function supprimerMateriel(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare('DELETE FROM materiel WHERE id = :id');
$stmt->execute(['id' => $id]);
}
Quelques remarques sur ce code :
- La clause
WHEREest construite à partir de morceaux écrits dans le code ('m.categorie = :categorie'), jamais à partir de la saisie : les valeurs passent toutes par des marqueurs. C'est la façon sûre de faire une recherche multicritère. - La recherche utilise deux marqueurs,
:q1et:q2, avec la même valeur : avec de vraies requêtes préparées, un marqueur nommé ne peut pas apparaître deux fois (chapitre 7). - La colonne calculée
pretevaut 1 si un emprunt est en cours, 0 sinon : on obtient la disponibilité de chaque matériel en une seule requête, au lieu d'une requête par ligne affichée (le problème N+1). compterMateriels()reprend les mêmes conditions que la liste : c'est indispensable pour que la pagination soit juste.$valeursvient toujours devaliderMateriel(), dont les clés correspondent exactement aux marqueurs. On ne passe jamais$_POSTdirectement àexecute(): un champ ajouté à la main au formulaire ferait échouer la requête, et un code qui construirait la requête à partir des clés reçues permettrait de modifier n'importe quelle colonne.
Read : la liste avec recherche, filtre et pagination
<?php
declare(strict_types=1);
// public/materiels.php
require __DIR__ . '/../src/bootstrap.php';
exigerConnexion();
const PAR_PAGE = 10;
$categorie = texte($_GET, 'categorie');
$filtres = [
'q' => mb_substr(texte($_GET, 'q'), 0, 50),
'categorie' => in_array($categorie, CATEGORIES, true) ? $categorie : '',
];
$total = compterMateriels(bdd(), $filtres);
$nbPages = max(1, (int) ceil($total / PAR_PAGE));
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: 1;
$page = min($page, $nbPages);
$materiels = listerMateriels(bdd(), $filtres, $page, PAR_PAGE);
$titre = 'Matériel';
require __DIR__ . '/../templates/haut.php';
?>
<form method="get" action="/materiels.php" role="search">
<label for="q">Rechercher</label>
<input id="q" name="q" value="<?= e($filtres['q']) ?>" placeholder="Référence ou libellé">
<label for="categorie">Catégorie</label>
<select id="categorie" name="categorie">
<option value="">Toutes</option>
<?php foreach (CATEGORIES as $c): ?>
<option value="<?= e($c) ?>" <?= $filtres['categorie'] === $c ? 'selected' : '' ?>><?= e($c) ?></option>
<?php endforeach; ?>
</select>
<button>Filtrer</button>
</form>
<p><?= e($total) ?> matériel(s)</p>
<table>
<thead>
<tr><th>Référence</th><th>Libellé</th><th>Catégorie</th><th>Disponibilité</th></tr>
</thead>
<tbody>
<?php foreach ($materiels as $m): ?>
<tr>
<td><a href="/materiel.php?id=<?= e($m['id']) ?>"><?= e($m['reference']) ?></a></td>
<td><?= e($m['libelle']) ?></td>
<td><?= e($m['categorie']) ?></td>
<td><?= match (true) {
$m['etat'] !== 'disponible' => e(str_replace('_', ' ', $m['etat'])),
(bool) $m['prete'] => 'prêté',
default => 'disponible',
} ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($nbPages > 1): ?>
<nav aria-label="Pagination">
<?php for ($p = 1; $p <= $nbPages; $p++): ?>
<?php $url = '/materiels.php?' . http_build_query([...$filtres, 'page' => $p]); ?>
<a href="<?= e($url) ?>" <?= $p === $page ? 'aria-current="page"' : '' ?>><?= e($p) ?></a>
<?php endfor; ?>
</nav>
<?php endif; ?>
<?php require __DIR__ . '/../templates/bas.php'; ?>
Les points à retenir :
- La recherche et le filtre passent en GET : l'URL de la liste filtrée peut être mise en favori ou partagée.
- Chaque paramètre est validé : la catégorie par liste blanche (une valeur inconnue revient à « toutes »), la page en entier positif, la recherche limitée à 50 caractères.
- La page demandée est ramenée au nombre de pages existantes :
?page=999affiche la dernière page au lieu d'une liste vide. - Les liens de pagination conservent les filtres grâce à
http_build_query(). - Avec 6 matériels et 10 par page, il n'y a qu'une page : la navigation ne s'affiche pas. Le calcul
ceil(total / par page)donne 3 pages pour 25 matériels (10, 10 et 5).
La pagination avec LIMIT … OFFSET est simple, mais elle ralentit sur les pages très lointaines de très grandes tables : le serveur lit puis ignore toutes les lignes sautées (LIMIT, OFFSET et pagination). Pour quelques milliers de lignes, c'est sans importance.
Read : la fiche d'un matériel
<?php
declare(strict_types=1);
// public/materiel.php
require __DIR__ . '/../src/bootstrap.php';
$utilisateur = exigerConnexion();
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$materiel = is_int($id) ? trouverMateriel(bdd(), $id) : null;
if ($materiel === null) {
http_response_code(404);
$titre = 'Matériel introuvable';
require __DIR__ . '/../templates/haut.php';
echo '<p>Ce matériel n\'existe pas. <a href="/materiels.php">Retour à la liste</a></p>';
require __DIR__ . '/../templates/bas.php';
exit;
}
$stmt = bdd()->prepare(
'SELECT e.date_debut, e.date_retour_prevue, e.date_retour, u.nom
FROM emprunt e JOIN utilisateur u ON u.id = e.id_utilisateur
WHERE e.id_materiel = :id
ORDER BY e.date_debut DESC'
);
$stmt->execute(['id' => $materiel['id']]);
$historique = $stmt->fetchAll();
$titre = $materiel['reference'];
require __DIR__ . '/../templates/haut.php';
?>
<h1><?= e($materiel['reference']) ?> : <?= e($materiel['libelle']) ?></h1>
<dl>
<dt>Catégorie</dt><dd><?= e($materiel['categorie']) ?></dd>
<dt>Acheté le</dt>
<dd><?= $materiel['date_achat'] === null ? 'inconnu' : e((new DateTimeImmutable($materiel['date_achat']))->format('d/m/Y')) ?></dd>
<dt>Valeur</dt>
<dd><?= $materiel['valeur'] === null ? 'inconnue' : e(number_format((float) $materiel['valeur'], 2, ',', ' ')) . ' €' ?></dd>
</dl>
<?php if ($utilisateur['role'] === 'gestionnaire'): ?>
<a href="/modifier.php?id=<?= e($materiel['id']) ?>">Modifier</a>
<form method="post" action="/supprimer.php">
<?= champCsrf() ?>
<input type="hidden" name="id" value="<?= e($materiel['id']) ?>">
<button>Supprimer</button>
</form>
<?php endif; ?>
<h2>Historique des emprunts</h2>
<ul>
<?php foreach ($historique as $h): ?>
<li><?= e($h['nom']) ?>, du <?= e($h['date_debut']) ?> au <?= e($h['date_retour'] ?? 'pas encore rendu') ?></li>
<?php endforeach; ?>
</ul>
<?php require __DIR__ . '/../templates/bas.php'; ?>
Les boutons de gestion ne s'affichent que pour le gestionnaire, mais ce n'est qu'un confort d'affichage : modifier.php et supprimer.php vérifient eux-mêmes le rôle.
Create : le formulaire d'ajout
Le formulaire est le même pour l'ajout et la modification : on l'écrit une fois, dans un gabarit partiel.
<?php
// templates/materiel-form.php : attend $saisie, $erreurs, $action et $titre
?>
<h1><?= e($titre) ?></h1>
<form method="post" action="<?= e($action) ?>" novalidate>
<?= champCsrf() ?>
<?php foreach (['reference' => 'Référence', 'libelle' => 'Libellé', 'date_achat' => 'Date d\'achat', 'valeur' => 'Valeur (€)'] as $champ => $libelle): ?>
<label for="<?= $champ ?>"><?= $libelle ?></label>
<input id="<?= $champ ?>" name="<?= $champ ?>"
type="<?= $champ === 'date_achat' ? 'date' : 'text' ?>"
value="<?= e(texte($saisie, $champ)) ?>"
<?= isset($erreurs[$champ]) ? 'aria-invalid="true" aria-describedby="err-' . $champ . '"' : '' ?>>
<?php if (isset($erreurs[$champ])): ?>
<p id="err-<?= $champ ?>" class="erreur"><?= e($erreurs[$champ]) ?></p>
<?php endif; ?>
<?php endforeach; ?>
<label for="categorie">Catégorie</label>
<select id="categorie" name="categorie">
<option value="">-- choisir --</option>
<?php foreach (CATEGORIES as $c): ?>
<option value="<?= e($c) ?>" <?= texte($saisie, 'categorie') === $c ? 'selected' : '' ?>><?= e($c) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($erreurs['categorie'])): ?>
<p class="erreur"><?= e($erreurs['categorie']) ?></p>
<?php endif; ?>
<button>Enregistrer</button>
</form>
<?php
declare(strict_types=1);
// public/ajouter.php
require __DIR__ . '/../src/bootstrap.php';
exigerRole('gestionnaire');
$saisie = [];
$erreurs = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifierCsrf();
$saisie = $_POST;
['valeurs' => $valeurs, 'erreurs' => $erreurs] = validerMateriel($saisie);
if ($erreurs === []) {
try {
$id = ajouterMateriel(bdd(), $valeurs);
flash('succes', "Le matériel {$valeurs['reference']} a été ajouté.");
header("Location: /materiel.php?id=$id", true, 303);
exit;
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) !== 1062) {
throw $e;
}
$erreurs['reference'] = 'Cette référence est déjà utilisée.';
}
}
}
$titre = 'Ajouter un matériel';
$action = '/ajouter.php';
require __DIR__ . '/../templates/haut.php';
require __DIR__ . '/../templates/materiel-form.php';
require __DIR__ . '/../templates/bas.php';
Tout le parcours du chapitre 4 est là : premier affichage en GET, envoi en POST, validation, réaffichage avec les erreurs ou enregistrement suivi d'une redirection 303 et d'un message flash. S'y ajoutent le contrôle du rôle, le jeton CSRF et la traduction de l'erreur 1062.
Update : modifier un matériel
La modification ressemble à l'ajout, avec deux différences : le formulaire est prérempli avec les valeurs de la base au premier affichage, et l'identifiant du matériel doit être validé et exister.
<?php
declare(strict_types=1);
// public/modifier.php
require __DIR__ . '/../src/bootstrap.php';
exigerRole('gestionnaire');
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$materiel = is_int($id) ? trouverMateriel(bdd(), $id) : null;
if ($materiel === null) {
http_response_code(404);
exit('Ce matériel n\'existe pas.');
}
$saisie = $materiel; // premier affichage : les valeurs de la base
$erreurs = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifierCsrf();
$saisie = $_POST;
['valeurs' => $valeurs, 'erreurs' => $erreurs] = validerMateriel($saisie);
if ($erreurs === []) {
try {
modifierMateriel(bdd(), $materiel['id'], $valeurs);
flash('succes', "Le matériel {$valeurs['reference']} a été modifié.");
header("Location: /materiel.php?id={$materiel['id']}", true, 303);
exit;
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) !== 1062) {
throw $e;
}
$erreurs['reference'] = 'Cette référence est déjà utilisée par un autre matériel.';
}
}
}
$titre = 'Modifier ' . $materiel['reference'];
$action = '/modifier.php?id=' . $materiel['id'];
require __DIR__ . '/../templates/haut.php';
require __DIR__ . '/../templates/materiel-form.php';
require __DIR__ . '/../templates/bas.php';
L'identifiant du matériel à modifier vient de l'URL du formulaire (action="/modifier.php?id=4"), et il est revérifié à l'envoi : le matériel existe-t-il toujours ? Si l'application gérait des données propres à chaque utilisateur (ses emprunts, son profil), il faudrait aussi vérifier qu'il en a le droit (IDOR).
Delete : supprimer ou réformer
La suppression se déclenche par un formulaire POST avec jeton CSRF, jamais par un lien GET. Un matériel qui a un historique d'emprunts ne peut pas être supprimé : la clé étrangère de la table emprunt l'interdit (erreur 1451), et c'est tant mieux, car on perdrait l'historique. Dans ce cas, on propose de le réformer (passer son état à reforme) : c'est une suppression logique, qui garde la ligne mais la retire de l'usage.
<?php
declare(strict_types=1);
// public/supprimer.php
require __DIR__ . '/../src/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('Méthode non autorisée.');
}
exigerRole('gestionnaire');
verifierCsrf();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$materiel = is_int($id) ? trouverMateriel(bdd(), $id) : null;
if ($materiel === null) {
http_response_code(404);
exit('Ce matériel n\'existe pas.');
}
try {
supprimerMateriel(bdd(), $materiel['id']);
flash('succes', "Le matériel {$materiel['reference']} a été supprimé.");
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) !== 1451) {
throw $e;
}
flash('erreur', "{$materiel['reference']} a des emprunts dans son historique : réforme-le plutôt que de le supprimer.");
}
header('Location: /materiels.php', true, 303);
exit;
Pour éviter un clic malheureux, ajoute une confirmation : une page intermédiaire « Supprimer PC-0087 ? » avec le bouton de validation, ou, plus simplement, un confirm() en JavaScript sur le bouton. La confirmation est un confort pour l'utilisateur, pas une sécurité : c'est le contrôle côté serveur qui protège.
Le gabarit commun
Les fichiers templates/haut.php et templates/bas.php contiennent le début et la fin de chaque page : en-tête HTML, menu, messages flash.
<?php
// templates/haut.php : attend $titre
$connecte = utilisateurConnecte();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($titre) ?> | Prêtmat</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<a href="/materiels.php">Prêtmat</a>
<?php if ($connecte !== null): ?>
<span><?= e($connecte['nom']) ?></span>
<?php if ($connecte['role'] === 'gestionnaire'): ?>
<a href="/ajouter.php">Ajouter un matériel</a>
<?php endif; ?>
<form method="post" action="/deconnexion.php"><?= champCsrf() ?><button>Se déconnecter</button></form>
<?php endif; ?>
</header>
<main>
<?php foreach (flashes() as $f): ?>
<p class="alerte alerte-<?= e($f['type']) ?>" role="status"><?= e($f['message']) ?></p>
<?php endforeach; ?>
<?php
// templates/bas.php
?>
</main>
</body>
</html>
La liste de contrôle d'un CRUD sûr
| Point | Où | Comment |
|---|---|---|
| L'utilisateur est connecté et a le droit d'agir | Début de chaque page | exigerConnexion(), exigerRole(), et la propriété de la donnée si elle appartient à quelqu'un |
| Une modification passe par POST | Ajout, modification, suppression, déconnexion | Formulaire method="post" ; 405 pour une autre méthode |
| La requête vient bien d'un formulaire de l'application | Début du traitement POST | verifierCsrf() |
| Chaque donnée reçue est validée | Traitement | filter_input(), liste blanche, fonction valider…() |
| Aucune saisie n'est concaténée dans le SQL | Fonctions d'accès aux données | Requêtes préparées ; noms de colonnes par liste blanche |
| Chaque donnée affichée est échappée | Vues | e() dans chaque <?= |
| L'élément demandé existe | Fiche, modification, suppression | 404 sinon |
| Les erreurs SQL prévisibles ont un message clair | Traitement | Codes 1062, 1451, 1452 |
| Après un POST réussi, on redirige | Traitement | header('Location: …', true, 303); exit; et un message flash |
Exercices
Exercice 1 sur 4 Ajouter un filtre de disponibilité Réussi À revoir
Le gestionnaire veut une case « Seulement le matériel disponible » sur la liste. Modifie conditionsMateriels() et la lecture des filtres dans materiels.php. Avec le jeu de données du chapitre 7 au 2 novembre 2026, quelles références la liste affiche-t-elle quand la case est cochée ?
Voir la réponseMasquer la réponse de l'exercice 1
Dans materiels.php, un filtre de plus (une case non cochée n'est pas envoyée) :
<?php
$filtres = [
'q' => mb_substr(texte($_GET, 'q'), 0, 50),
'categorie' => in_array($categorie, CATEGORIES, true) ? $categorie : '',
'disponible' => texte($_GET, 'disponible') === '1',
];
Dans conditionsMateriels(), une condition écrite dans le code, sans paramètre :
<?php
if ($filtres['disponible']) {
$conditions[] = "m.etat = 'disponible' AND NOT EXISTS (
SELECT 1 FROM emprunt e WHERE e.id_materiel = m.id AND e.date_retour IS NULL)";
}
Dans le formulaire : <input type="checkbox" name="disponible" value="1" <?= $filtres['disponible'] ? 'checked' : '' ?>>. Avec le jeu de données, la liste affiche PC-0101 et VP-0001 : PC-0102 et CAM-0001 sont prêtés, TAB-0003 est en réparation et PC-0087 réformé. Comme compterMateriels() utilise la même fonction, le total et la pagination restent justes. http_build_query() transforme true en 1 dans les liens de pagination, la case reste donc cochée d'une page à l'autre.
Tu avais trouvé ?
Exercice 2 sur 4 Calculer la pagination Réussi À revoir
La table contient 47 matériels, affichés 10 par page.
- Combien y a-t-il de pages ?
- Quels
LIMITetOFFSETdonne la page 3 ? Quels numéros de matériels (dans l'ordre de tri) affiche-t-elle ? - Que fait la page avec
?page=9,?page=0et?page=abc?
Voir la réponseMasquer la réponse de l'exercice 2
ceil(47 / 10)= 5 pages : quatre de 10 matériels et une de 7.LIMIT 10 OFFSET 20: la page 3 affiche les matériels 21 à 30. L'OFFSETvaut(page - 1) × par page.?page=9est valide pourfilter_input()(un entier supérieur à 1) mais dépasse le nombre de pages :min(9, 5)ramène à la page 5.?page=0ne respecte pasmin_rangeet?page=abcn'est pas un entier :filter_input()renvoiefalse, et?: 1affiche la page 1.
Tu avais trouvé ?
Exercice 3 sur 4 Relire un modifier.php Réussi À revoir
Un étudiant présente cette page de modification à l'E6. Trouve ce qui ne va pas.
<?php
require 'bdd.php';
$id = $_GET['id'];
if (isset($_GET['supprimer'])) {
$pdo->exec("DELETE FROM materiel WHERE id = $id");
}
if ($_POST) {
$champs = [];
foreach ($_POST as $colonne => $valeur) {
$champs[] = "$colonne = " . $pdo->quote($valeur);
}
$pdo->exec('UPDATE materiel SET ' . implode(', ', $champs) . " WHERE id = $id");
echo 'Modification enregistrée';
}
Voir la réponseMasquer la réponse de l'exercice 3
- Aucun contrôle d'accès : ni connexion ni rôle vérifiés. N'importe qui peut modifier ou supprimer.
- Suppression en GET (
?supprimer) : un lien suffit, sans confirmation ni jeton. Une image ou un lien piégé sur une autre page peut la déclencher (CSRF). - Injection SQL par
$id, concaténé sans validation dans les deux requêtes. - Injection par les noms de colonnes :
$colonnevient des clés de$_POST, que l'utilisateur choisit.quote()protège les valeurs, pas les noms. On peut injecter du SQL dans un nom de champ, ou simplement modifier une colonne que le formulaire ne montre pas (l'état, la photo) : c'est l'affectation de masse (mass assignment). - Aucune validation des valeurs (format de référence, catégorie, date).
- Pas de jeton CSRF sur le formulaire de modification.
- Pas de redirection après le POST (risque de double envoi) et pas de gestion d'erreur (référence en double).
require 'bdd.php'sans__DIR__, et$pdosorti de nulle part.
La version correcte est celle de la section Update : modifier un matériel : exigerRole(), verifierCsrf(), identifiant validé, validerMateriel() qui ne garde que les champs prévus, requête préparée avec des colonnes écrites dans le code, redirection 303. La suppression va dans sa propre page, en POST.
Tu avais trouvé ?
Exercice 4 sur 4 Une évolution type E6 Réussi À revoir
Pendant l'heure de réalisation de l'E6, la commission te demande d'ajouter le numéro de série du fabricant à chaque matériel : facultatif, 50 caractères au plus, unique quand il est renseigné. Liste toutes les modifications à faire, dans l'ordre, du SQL à l'affichage.
Voir la réponseMasquer la réponse de l'exercice 4
- Base :
ALTER TABLE materiel ADD numero_serie VARCHAR(50) NULL UNIQUE;. Une contrainteUNIQUEaccepte plusieursNULL: les matériels sans numéro ne gênent pas. - Validation (
validerMateriel()) : liretexte($saisie, 'numero_serie'), refuser plus de 50 caractères, et renvoyernullsi le champ est vide, sinon deux matériels sans numéro auraient la même valeur''et violeraient l'unicité. Ajouter la clénumero_serieau tableauvaleurs. - Accès aux données : ajouter la colonne et le marqueur
:numero_seriedans l'INSERTdeajouterMateriel()et dans leSETdemodifierMateriel()(les clés de$valeursdoivent correspondre exactement aux marqueurs). - Formulaire : ajouter le champ (
'numero_serie' => 'Numéro de série'dans la boucle du gabarit partiel). - Erreur 1062 : le message « référence déjà utilisée » devient faux si le doublon porte sur le numéro de série. Lire le nom de la contrainte dans le message de l'exception (
str_contains($e->getMessage(), 'numero_serie')), ou vérifier les doublons par unSELECTavant l'enregistrement, pour placer le message sur le bon champ. - Affichage : la fiche, et éventuellement la recherche (
OR m.numero_serie LIKE :q3). - Tests : ajouter un matériel sans numéro, deux matériels sans numéro, deux matériels avec le même numéro (refus), un numéro de 51 caractères (refus).
Décrire cette liste à voix haute avant de coder montre à la commission que tu maîtrises ton application de bout en bout (les évolutions types de l'E6).
Tu avais trouvé ?