Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 823 minIntermédiaire

PDO : fetch, erreurs et transactions

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • choisir entre fetch, fetchAll, fetchColumn et les modes de récupération ;
  • traiter un résultat vide, les types et les dates ;
  • gérer les exceptions et traduire une erreur SQL ;
  • regrouper des requêtes dans une transaction avec un verrou.

4 exercices corrigés

Après execute(), les méthodes fetch(), fetchAll() et fetchColumn() de PDO lisent le résultat d'une requête, ligne par ligne ou d'un coup, sous la forme que tu choisis. Ce chapitre montre comment récupérer les résultats, traiter un résultat vide, convertir les types et les dates, gérer les erreurs SQL avec les exceptions, et regrouper plusieurs requêtes dans une transaction pour que l'enregistrement d'un emprunt soit tout ou rien. Il suit le chapitre 7, qui explique la connexion et les requêtes préparées.

fetch, fetchAll, fetchColumn : lire un résultat

MéthodeRenvoieUsage typique
fetch()La ligne suivante, ou false s'il n'y en a plusUne seule ligne attendue (recherche par identifiant), ou lecture ligne par ligne
fetchAll()Un tableau de toutes les lignes (vide s'il n'y en a aucune)Une liste à afficher ou à renvoyer en JSON
fetchColumn()La première colonne de la ligne suivante, ou falseUn COUNT(*), un SUM(), une seule valeur
foreach ($stmt as $ligne)Chaque ligne, une par tour de boucleParcourir un grand résultat sans tout charger en mémoire
php
<?php

$pdo = bdd();

// Une ligne
$stmt = $pdo->prepare('SELECT id, reference, libelle, etat FROM materiel WHERE id = :id');
$stmt->execute(['id' => 4]);
$materiel = $stmt->fetch();
// ['id' => 4, 'reference' => 'TAB-0003', 'libelle' => 'Tablette Samsung Galaxy Tab S9 FE', 'etat' => 'en_reparation']

// Toutes les lignes
$stmt = $pdo->prepare('SELECT reference, libelle FROM materiel WHERE categorie = :c ORDER BY reference');
$stmt->execute(['c' => 'portable']);
$portables = $stmt->fetchAll();   // 3 lignes : PC-0087, PC-0101, PC-0102

// Une valeur
$stmt = $pdo->prepare('SELECT COUNT(*) FROM emprunt WHERE date_retour IS NULL');
$stmt->execute();
$enCours = (int) $stmt->fetchColumn();   // 2

fetchAll() charge tout en mémoire : c'est parfait pour une page de 20 ou 50 lignes, à éviter pour exporter 200 000 lignes, où l'on parcourt le résultat avec foreach. Et si tu n'affiches qu'une page de résultats, limite la requête avec LIMIT plutôt que de tout lire pour n'en garder que vingt.

Les modes de récupération

Le mode de récupération dit sous quelle forme arrive chaque ligne. La connexion du cours fixe FETCH_ASSOC par défaut ; on peut en demander un autre à l'appel.

ModeUne ligne arrive sous la formeExemple de résultat
PDO::FETCH_ASSOCTableau associatif['reference' => 'PC-0101', 'libelle' => '…']
PDO::FETCH_NUMListe[0 => 'PC-0101', 1 => '…']
PDO::FETCH_BOTHLes deux à la fois : le défaut de PDO si tu ne règles rien['reference' => 'PC-0101', 0 => 'PC-0101']
PDO::FETCH_OBJObjet anonyme$m->reference
PDO::FETCH_COLUMN (avec fetchAll)Liste des valeurs de la première colonne['PC-0101', 'VP-0001']
PDO::FETCH_KEY_PAIR (avec fetchAll)Tableau première colonne => deuxième colonne['camera' => 1, 'portable' => 3]
PDO::FETCH_GROUP (avec fetchAll)Lignes regroupées par la première colonne['portable' => [ligne, ligne], …]
PDO::FETCH_CLASSInstance d'une classe que tu nommeschapitre 11

Deux combinaisons rendent de grands services pour remplir une liste déroulante ou un tableau de bord :

php
<?php

// Pour un <select> : id => libellé
$options = $pdo->query('SELECT id, reference FROM materiel ORDER BY reference')
               ->fetchAll(PDO::FETCH_KEY_PAIR);
// [5 => 'CAM-0001', 6 => 'PC-0087', 1 => 'PC-0101', 2 => 'PC-0102', 4 => 'TAB-0003', 3 => 'VP-0001']

// Nombre de matériels par catégorie
$parCategorie = $pdo->query('SELECT categorie, COUNT(*) FROM materiel GROUP BY categorie ORDER BY categorie')
                    ->fetchAll(PDO::FETCH_KEY_PAIR);
// ['portable' => 3, 'videoprojecteur' => 1, 'tablette' => 1, 'camera' => 1] : une colonne ENUM se trie dans l'ordre de sa définition, pas dans l'ordre alphabétique

Un résultat introuvable : false

Quand aucune ligne ne correspond, fetch() et fetchColumn() renvoient false, et fetchAll() un tableau vide. Le code qui suit doit traiter ce cas, sinon il produit l'avertissement « Trying to access array offset on false » et affiche une page vide.

php
<?php

declare(strict_types=1);

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (!is_int($id)) {
    http_response_code(400);
    exit('Identifiant invalide.');
}

$stmt = bdd()->prepare('SELECT * FROM materiel WHERE id = :id');
$stmt->execute(['id' => $id]);
$materiel = $stmt->fetch();

if ($materiel === false) {
    http_response_code(404);
    exit('Ce matériel n\'existe pas.');
}
// À partir d'ici, $materiel est un tableau

Les deux codes de statut ont un sens : 400 pour une requête mal formée (l'identifiant n'est pas un entier), 404 pour une ressource qui n'existe pas (les codes HTTP). Une page bien construite les renvoie, avec un message lisible.

Les types des valeurs lues

Avec la connexion du cours (pilote MySQL, émulation désactivée), les colonnes arrivent dans ces types PHP :

Type de la colonneType PHP reçuExemple
INT, BIGINT, TINYINTint4
FLOAT, DOUBLEfloat0.5
DECIMAL(8, 2)string'449.90' : la valeur exacte, sans arrondi binaire
VARCHAR, TEXT, ENUMstring'en_reparation'
DATE, DATETIMEstring'2024-11-20', '2026-11-02 09:15:00'
N'importe quelle colonne à NULLnull

Le DECIMAL arrive en chaîne pour ne rien perdre en précision. Pour calculer, convertis-le avec (float) en sachant que tu acceptes l'approximation des nombres à virgule, ou fais le calcul en SQL (SUM(valeur)). Pour l'afficher, number_format((float) $m['valeur'], 2, ',', ' ') donne 449,90.

Les dates

Les dates arrivent sous forme de chaînes AAAA-MM-JJ. Pour les afficher à la française ou calculer, transforme-les en objets DateTimeImmutable :

php
<?php

declare(strict_types=1);

date_default_timezone_set('Europe/Paris');   // ou date.timezone dans php.ini

$retourPrevu = new DateTimeImmutable('2026-10-30');
$aujourdhui = new DateTimeImmutable('2026-11-02');   // en production : new DateTimeImmutable('today')

echo $retourPrevu->format('d/m/Y');                   // 30/10/2026

if ($retourPrevu < $aujourdhui) {
    $retard = $retourPrevu->diff($aujourdhui)->days;  // 3
    echo "En retard de $retard jour(s)";
}

$dateRetour = $aujourdhui->modify('+14 days');        // un nouvel objet, l'original ne change pas
echo $dateRetour->format('Y-m-d');                    // 2026-11-16 : le format attendu par MySQL
FormatDonneUsage
'Y-m-d'2026-11-02Pour MySQL et les champs <input type="date">
'd/m/Y'02/11/2026Affichage en France
'Y-m-d H:i:s'2026-11-02 09:15:00Colonne DATETIME
DATE_ATOM2026-11-02T09:15:00+01:00API JSON, format ISO 8601

Préfère DateTimeImmutable à DateTime : ses méthodes modify() ou add() renvoient un nouvel objet, alors que celles de DateTime modifient l'objet lui-même, source d'erreurs quand on calcule plusieurs dates à partir de la même. Pour écrire « lundi 2 novembre 2026 », l'extension intl fournit IntlDateFormatter, qui connaît les noms des jours et des mois en français.

Les exceptions : try, catch, finally

Quand une opération échoue, PHP lève une exception : un objet qui décrit l'erreur et remonte la pile des appels jusqu'à ce qu'un bloc catch l'attrape. Si personne ne l'attrape, le script s'arrête avec « Fatal error: Uncaught … ».

php
<?php

declare(strict_types=1);

try {
    $stmt = bdd()->prepare('SELECT * FROM materiels');   // faute de frappe : la table s'appelle materiel
    $stmt->execute();
} catch (PDOException $e) {
    error_log('Erreur SQL : ' . $e->getMessage());       // le détail va dans le journal du serveur
    http_response_code(500);
    exit('Une erreur est survenue. Réessaie plus tard.');  // l'utilisateur reçoit un message neutre
} finally {
    // exécuté dans tous les cas, erreur ou non : fermer un fichier, libérer une ressource
}

Toutes les erreurs et exceptions de PHP héritent de Throwable. Les plus courantes :

ClasseQuand
PDOExceptionErreur de connexion ou d'exécution SQL
TypeErrorUn argument n'a pas le type déclaré (en typage strict)
ValueErrorUn argument a le bon type mais une valeur impossible
JsonExceptionUn JSON invalide, avec l'option JSON_THROW_ON_ERROR
RuntimeException, InvalidArgumentException, LogicExceptionLes exceptions génériques que tu lèves toi-même

Tu peux créer tes propres exceptions pour signaler une règle de gestion non respectée. Une classe d'une ligne suffit (le chapitre 11 explique la syntaxe des classes) :

php
<?php

final class EmpruntImpossible extends RuntimeException
{
}

throw new EmpruntImpossible('Ce matériel est déjà prêté.');

Le code appelant distingue alors une règle métier (message à montrer à l'utilisateur) d'une panne technique (message neutre, détail dans le journal).

Gérer une erreur SQL

Une PDOException contient le code de l'erreur. $e->errorInfo[0] est le code SQLSTATE, normalisé (23000 pour une violation de contrainte d'intégrité), et $e->errorInfo[1] le numéro propre à MySQL, plus précis :

Code MySQLSignificationExemple dans PrêtmatMessage pour l'utilisateur
1062Valeur en double sur une colonne UNIQUELa référence PC-0101 existe déjà« Cette référence est déjà utilisée. »
1452Clé étrangère vers une ligne qui n'existe pasEmprunt d'un matériel supprimé entre-temps« Ce matériel n'existe plus. Recharge la page. »
1451Suppression d'une ligne encore référencéeSupprimer un matériel qui a un historique d'emprunts« Ce matériel a des emprunts : réforme-le plutôt. »
3819Contrainte CHECK non respectéeRetour prévu avant le début« La date de retour doit suivre la date de début. »
1406Valeur trop longue pour la colonneLibellé de 150 caractères« Le libellé est trop long. »
php
<?php

declare(strict_types=1);

try {
    $stmt = bdd()->prepare('INSERT INTO materiel (reference, libelle, categorie) VALUES (:reference, :libelle, :categorie)');
    $stmt->execute(['reference' => 'PC-0101', 'libelle' => 'Portable Dell', 'categorie' => 'portable']);
} catch (PDOException $e) {
    if (($e->errorInfo[1] ?? null) === 1062) {
        $erreurs['reference'] = 'Cette référence est déjà utilisée.';
    } else {
        throw $e;   // une autre erreur : on ne sait pas la traiter ici, on la laisse remonter
    }
}

Une bonne application valide d'abord les données (format, longueur, liste blanche) pour que ces erreurs restent rares ; la contrainte en base est le filet de sécurité, notamment quand deux utilisateurs saisissent la même référence au même moment.

Les transactions avec PDO

Enregistrer un emprunt demande plusieurs requêtes : vérifier que le matériel est disponible, vérifier qu'il n'est pas déjà prêté, insérer l'emprunt. Si deux gestionnaires prêtent le même portable au même instant, les deux vérifications peuvent passer avant que l'une des insertions ait eu lieu : le portable est prêté deux fois. Une transaction regroupe les requêtes en un tout, et le verrou FOR UPDATE fait attendre la seconde tant que la première n'est pas terminée.

MéthodeRôle
$pdo->beginTransaction()Commence la transaction : les modifications suivantes ne sont pas définitives
$pdo->commit()Valide toutes les modifications d'un coup
$pdo->rollBack()Annule toutes les modifications depuis le début de la transaction
$pdo->inTransaction()Indique si une transaction est en cours
php
<?php

declare(strict_types=1);

final class EmpruntImpossible extends RuntimeException
{
}

function emprunter(PDO $pdo, int $idMateriel, int $idUtilisateur,
                   DateTimeImmutable $debut, DateTimeImmutable $retourPrevu): int
{
    $pdo->beginTransaction();
    try {
        // 1. Lire le matériel en posant un verrou : un second emprunt simultané attendra ici
        $stmt = $pdo->prepare('SELECT etat FROM materiel WHERE id = :id FOR UPDATE');
        $stmt->execute(['id' => $idMateriel]);
        $etat = $stmt->fetchColumn();
        if ($etat === false) {
            throw new EmpruntImpossible('Ce matériel n\'existe pas.');
        }
        if ($etat !== 'disponible') {
            throw new EmpruntImpossible('Ce matériel est en réparation ou réformé.');
        }

        // 2. Vérifier qu'aucun emprunt n'est en cours
        $stmt = $pdo->prepare('SELECT COUNT(*) FROM emprunt WHERE id_materiel = :id AND date_retour IS NULL');
        $stmt->execute(['id' => $idMateriel]);
        if ((int) $stmt->fetchColumn() > 0) {
            throw new EmpruntImpossible('Ce matériel est déjà prêté.');
        }

        // 3. Enregistrer l'emprunt
        $stmt = $pdo->prepare(
            'INSERT INTO emprunt (id_materiel, id_utilisateur, date_debut, date_retour_prevue)
             VALUES (:materiel, :utilisateur, :debut, :retour)'
        );
        $stmt->execute([
            'materiel' => $idMateriel,
            'utilisateur' => $idUtilisateur,
            'debut' => $debut->format('Y-m-d'),
            'retour' => $retourPrevu->format('Y-m-d'),
        ]);
        $idEmprunt = (int) $pdo->lastInsertId();

        $pdo->commit();
        return $idEmprunt;
    } catch (Throwable $e) {
        $pdo->rollBack();   // annule tout et libère le verrou
        throw $e;           // l'appelant décide du message
    }
}

Le contrôleur qui appelle cette fonction attrape EmpruntImpossible pour afficher le message à l'utilisateur, et laisse remonter les autres exceptions :

php
<?php

try {
    emprunter(bdd(), $idMateriel, $_SESSION['utilisateur']['id'], $debut, $retourPrevu);
    flash('succes', 'Emprunt enregistré.');
} catch (EmpruntImpossible $e) {
    flash('erreur', $e->getMessage());   // message écrit par nous : on peut l'afficher
}
header('Location: /materiels.php', true, 303);
exit;

Quelques points à retenir :

  • Le catch (Throwable $e) annule la transaction quelle que soit l'erreur (règle métier, erreur SQL, bogue), puis relance l'exception : la transaction ne reste jamais ouverte.
  • Le verrou FOR UPDATE ne fonctionne qu'à l'intérieur d'une transaction, avec le moteur InnoDB (celui de MySQL par défaut).
  • Une transaction doit être courte : pas d'envoi de mail ni d'appel à une API pendant qu'elle tient des verrous.
  • Les instructions qui modifient la structure (CREATE TABLE, ALTER TABLE) valident implicitement la transaction en cours avec MySQL : ne les mélange pas aux transactions de l'application.

Le cours SQL explique les propriétés ACID, les niveaux d'isolation et les interblocages (transactions SQL).

Exercices

Exercice 1 sur 4 Choisir la bonne méthode Réussi À revoir

Pour chaque besoin, donne la méthode et, s'il le faut, le mode de récupération.

  1. Afficher la fiche d'un matériel à partir de son identifiant.
  2. Afficher le nombre de matériels en réparation.
  3. Remplir une liste déroulante des utilisateurs (valeur : id, texte : nom).
  4. Exporter en CSV les 80 000 emprunts de l'historique.
  5. Obtenir la liste des références des matériels réformés, pour un message « À recycler : PC-0087 ».
Voir la réponseMasquer la réponse de l'exercice 1
  1. fetch(), puis tester false pour renvoyer une erreur 404.
  2. fetchColumn() sur un SELECT COUNT(*) … WHERE etat = 'en_reparation', converti en (int).
  3. fetchAll(PDO::FETCH_KEY_PAIR) sur SELECT id, nom FROM utilisateur ORDER BY nom : on obtient directement [id => nom].
  4. Un foreach sur le PDOStatement (ligne par ligne, avec fputcsv()), pas fetchAll(), qui chargerait les 80 000 lignes en mémoire.
  5. fetchAll(PDO::FETCH_COLUMN) sur SELECT reference FROM materiel WHERE etat = 'reforme', puis implode(', ', …). Avec le jeu de données : « À recycler : PC-0087 ».

Tu avais trouvé ?

Exercice 2 sur 4 Enregistrer un retour Réussi À revoir

Écris la fonction rendre(PDO $pdo, int $idEmprunt, DateTimeImmutable $date, bool $enPanne): void : elle enregistre la date de retour de l'emprunt (qui doit être en cours) et, si le matériel est rendu en panne, passe son état à en_reparation. Les deux modifications doivent être faites ensemble ou pas du tout. Lève une EmpruntImpossible si l'emprunt n'existe pas ou est déjà terminé.

Voir la réponseMasquer la réponse de l'exercice 2
php
<?php
declare(strict_types=1);

function rendre(PDO $pdo, int $idEmprunt, DateTimeImmutable $date, bool $enPanne): void
{
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare('SELECT id_materiel FROM emprunt WHERE id = :id AND date_retour IS NULL FOR UPDATE');
        $stmt->execute(['id' => $idEmprunt]);
        $idMateriel = $stmt->fetchColumn();
        if ($idMateriel === false) {
            throw new EmpruntImpossible('Cet emprunt n\'existe pas ou a déjà été rendu.');
        }

        $stmt = $pdo->prepare('UPDATE emprunt SET date_retour = :date WHERE id = :id');
        $stmt->execute(['date' => $date->format('Y-m-d'), 'id' => $idEmprunt]);

        if ($enPanne) {
            $stmt = $pdo->prepare("UPDATE materiel SET etat = 'en_reparation' WHERE id = :id");
            $stmt->execute(['id' => $idMateriel]);
        }

        $pdo->commit();
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

La condition date_retour IS NULL dans le SELECT refuse un emprunt déjà rendu, et le FOR UPDATE empêche deux retours simultanés du même emprunt. Si la seconde mise à jour échoue, la première est annulée : on n'a jamais un emprunt clos sur un matériel dont l'état n'a pas été mis à jour.

Tu avais trouvé ?

Exercice 3 sur 4 Traduire les erreurs Réussi À revoir

Le formulaire d'ajout d'un utilisateur reçoit une PDOException. Pour chaque valeur de errorInfo[1], indique la cause probable et le message à afficher. Que fait le code pour une valeur qui n'est pas dans ta liste ?

  1. 1062
  2. 1406
  3. 1265, avec le message « Data truncated for column 'role' »
Voir la réponseMasquer la réponse de l'exercice 3
  1. 1062 : l'adresse email existe déjà, c'est la seule colonne UNIQUE saisie. Message : « Un compte utilise déjà cette adresse. »
  2. 1406 : une valeur dépasse la taille de sa colonne (le nom au-delà de 100 caractères, l'email au-delà de 180). Message : « Un champ est trop long. » La validation PHP aurait dû l'arrêter avant : c'est le signe d'un contrôle manquant.
  3. 1265 : la valeur du rôle n'est pas une des valeurs de l'ENUM (employe, gestionnaire). Un formulaire normal n'envoie jamais cela : c'est une requête modifiée à la main. La validation par liste blanche doit la refuser avant la base ; le message peut rester générique (« Rôle invalide »).

Pour toute autre erreur, le code relance l'exception (throw $e;) : le gestionnaire global écrit le détail dans le journal et affiche une page d'erreur 500 neutre. On ne tente pas de deviner.

Tu avais trouvé ?

Exercice 4 sur 4 Les jours de retard Réussi À revoir

Écris la requête et le code PHP qui affichent, pour chaque emprunt en cours en retard au 2 novembre 2026, la référence du matériel, le nom de l'emprunteur et le nombre de jours de retard. Passe la date du jour en paramètre de la requête. Qu'affiche le code avec le jeu de données ?

Voir la réponseMasquer la réponse de l'exercice 4
php
<?php
declare(strict_types=1);

$aujourdhui = new DateTimeImmutable('2026-11-02');

$stmt = bdd()->prepare(
    'SELECT m.reference, u.nom, e.date_retour_prevue
     FROM emprunt e
     JOIN materiel m ON m.id = e.id_materiel
     JOIN utilisateur u ON u.id = e.id_utilisateur
     WHERE e.date_retour IS NULL AND e.date_retour_prevue < :aujourdhui
     ORDER BY e.date_retour_prevue'
);
$stmt->execute(['aujourdhui' => $aujourdhui->format('Y-m-d')]);

foreach ($stmt as $e) {
    $retard = (new DateTimeImmutable($e['date_retour_prevue']))->diff($aujourdhui)->days;
    echo e($e['reference']) . ' chez ' . e($e['nom']) . " : $retard jour(s) de retard\n";
}

Résultat : CAM-0001 chez Julie Lefèvre : 3 jour(s) de retard. L'emprunt de Karim (retour prévu le 9 novembre) n'est pas en retard, celui du vidéoprojecteur est terminé. Passer la date en paramètre plutôt qu'écrire CURDATE() permet de tester la requête avec une date fixe. En MySQL, DATEDIFF(:aujourdhui, e.date_retour_prevue) calculerait aussi le retard directement dans la requête.

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 : Inscription et connexion en PHP : password_hash et jeton CSRFRevoir le sommaire du cours