Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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érationce que voit l'utilisateurHTTPSQLCreatecréerformulaire d'ajoutpuis redirection 303POSTINSERTReadlireliste paginée, ficherecherche et filtres dans l'URLGETSELECTUpdatemodifierformulaire préremplipuis redirection 303POSTUPDATEDeletesupprimerbouton dans un formulaireavec jeton CSRF, jamais un lienPOSTDELETE
Chaque opération du CRUD a sa page, sa méthode HTTP et son instruction SQL.
OpérationPage de PrêtmatMéthode HTTPSQLQui
Read (liste)materiels.php?q=…&categorie=…&page=…GETSELECT … LIMITTout utilisateur connecté
Read (fiche)materiel.php?id=4GETSELECT … WHERE id = :idTout utilisateur connecté
Createajouter.phpGET (formulaire), POST (traitement)INSERTGestionnaire
Updatemodifier.php?id=4GET (formulaire prérempli), POST (traitement)UPDATEGestionnaire
Deletesupprimer.phpPOST uniquementDELETEGestionnaire

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
<?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 manqueUsage
requireErreur fatale, le script s'arrêteUn fichier indispensable : configuration, fonctions
includeSimple avertissement, le script continueRarement utile
require_once, include_onceComme ci-dessus, mais le fichier n'est inclus qu'une foisUn 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
<?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 WHERE est 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, :q1 et :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 prete vaut 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.
  • $valeurs vient toujours de validerMateriel(), dont les clés correspondent exactement aux marqueurs. On ne passe jamais $_POST directement à 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
<?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=999 affiche 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
<?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
<?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
<?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
<?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
<?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
<?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
<?php
// templates/bas.php
?>
</main>
</body>
</html>

La liste de contrôle d'un CRUD sûr

PointOùComment
L'utilisateur est connecté et a le droit d'agirDébut de chaque pageexigerConnexion(), exigerRole(), et la propriété de la donnée si elle appartient à quelqu'un
Une modification passe par POSTAjout, modification, suppression, déconnexionFormulaire method="post" ; 405 pour une autre méthode
La requête vient bien d'un formulaire de l'applicationDébut du traitement POSTverifierCsrf()
Chaque donnée reçue est validéeTraitementfilter_input(), liste blanche, fonction valider…()
Aucune saisie n'est concaténée dans le SQLFonctions d'accès aux donnéesRequêtes préparées ; noms de colonnes par liste blanche
Chaque donnée affichée est échappéeVuese() dans chaque <?=
L'élément demandé existeFiche, modification, suppression404 sinon
Les erreurs SQL prévisibles ont un message clairTraitementCodes 1062, 1451, 1452
Après un POST réussi, on redirigeTraitementheader('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
<?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
<?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.

  1. Combien y a-t-il de pages ?
  2. Quels LIMIT et OFFSET donne la page 3 ? Quels numéros de matériels (dans l'ordre de tri) affiche-t-elle ?
  3. Que fait la page avec ?page=9, ?page=0 et ?page=abc ?
Voir la réponseMasquer la réponse de l'exercice 2
  1. ceil(47 / 10) = 5 pages : quatre de 10 matériels et une de 7.
  2. LIMIT 10 OFFSET 20 : la page 3 affiche les matériels 21 à 30. L'OFFSET vaut (page - 1) × par page.
  3. ?page=9 est valide pour filter_input() (un entier supérieur à 1) mais dépasse le nombre de pages : min(9, 5) ramène à la page 5. ?page=0 ne respecte pas min_range et ?page=abc n'est pas un entier : filter_input() renvoie false, et ?: 1 affiche 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
<?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 : $colonne vient 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 $pdo sorti 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
  1. Base : ALTER TABLE materiel ADD numero_serie VARCHAR(50) NULL UNIQUE;. Une contrainte UNIQUE accepte plusieurs NULL : les matériels sans numéro ne gênent pas.
  2. Validation (validerMateriel()) : lire texte($saisie, 'numero_serie'), refuser plus de 50 caractères, et renvoyer null si le champ est vide, sinon deux matériels sans numéro auraient la même valeur '' et violeraient l'unicité. Ajouter la clé numero_serie au tableau valeurs.
  3. Accès aux données : ajouter la colonne et le marqueur :numero_serie dans l'INSERT de ajouterMateriel() et dans le SET de modifierMateriel() (les clés de $valeurs doivent correspondre exactement aux marqueurs).
  4. Formulaire : ajouter le champ ('numero_serie' => 'Numéro de série' dans la boucle du gabarit partiel).
  5. 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 un SELECT avant l'enregistrement, pour placer le message sur le bon champ.
  6. Affichage : la fiche, et éventuellement la recherche (OR m.numero_serie LIKE :q3).
  7. 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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Classes et objets en PHP : syntaxe, enum, readonlyRevoir le sommaire du cours