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éthode | Renvoie | Usage typique |
|---|---|---|
fetch() | La ligne suivante, ou false s'il n'y en a plus | Une 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 false | Un COUNT(*), un SUM(), une seule valeur |
foreach ($stmt as $ligne) | Chaque ligne, une par tour de boucle | Parcourir un grand résultat sans tout charger en mémoire |
<?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.
| Mode | Une ligne arrive sous la forme | Exemple de résultat |
|---|---|---|
PDO::FETCH_ASSOC | Tableau associatif | ['reference' => 'PC-0101', 'libelle' => '…'] |
PDO::FETCH_NUM | Liste | [0 => 'PC-0101', 1 => '…'] |
PDO::FETCH_BOTH | Les deux à la fois : le défaut de PDO si tu ne règles rien | ['reference' => 'PC-0101', 0 => 'PC-0101'] |
PDO::FETCH_OBJ | Objet 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_CLASS | Instance d'une classe que tu nommes | chapitre 11 |
Deux combinaisons rendent de grands services pour remplir une liste déroulante ou un tableau de bord :
<?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
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 colonne | Type PHP reçu | Exemple |
|---|---|---|
INT, BIGINT, TINYINT | int | 4 |
FLOAT, DOUBLE | float | 0.5 |
DECIMAL(8, 2) | string | '449.90' : la valeur exacte, sans arrondi binaire |
VARCHAR, TEXT, ENUM | string | 'en_reparation' |
DATE, DATETIME | string | '2024-11-20', '2026-11-02 09:15:00' |
N'importe quelle colonne à NULL | null |
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
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
| Format | Donne | Usage |
|---|---|---|
'Y-m-d' | 2026-11-02 | Pour MySQL et les champs <input type="date"> |
'd/m/Y' | 02/11/2026 | Affichage en France |
'Y-m-d H:i:s' | 2026-11-02 09:15:00 | Colonne DATETIME |
DATE_ATOM | 2026-11-02T09:15:00+01:00 | API 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
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 :
| Classe | Quand |
|---|---|
PDOException | Erreur de connexion ou d'exécution SQL |
TypeError | Un argument n'a pas le type déclaré (en typage strict) |
ValueError | Un argument a le bon type mais une valeur impossible |
JsonException | Un JSON invalide, avec l'option JSON_THROW_ON_ERROR |
RuntimeException, InvalidArgumentException, LogicException | Les 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
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 MySQL | Signification | Exemple dans Prêtmat | Message pour l'utilisateur |
|---|---|---|---|
| 1062 | Valeur en double sur une colonne UNIQUE | La référence PC-0101 existe déjà | « Cette référence est déjà utilisée. » |
| 1452 | Clé étrangère vers une ligne qui n'existe pas | Emprunt d'un matériel supprimé entre-temps | « Ce matériel n'existe plus. Recharge la page. » |
| 1451 | Suppression d'une ligne encore référencée | Supprimer un matériel qui a un historique d'emprunts | « Ce matériel a des emprunts : réforme-le plutôt. » |
| 3819 | Contrainte CHECK non respectée | Retour prévu avant le début | « La date de retour doit suivre la date de début. » |
| 1406 | Valeur trop longue pour la colonne | Libellé de 150 caractères | « Le libellé est trop long. » |
<?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éthode | Rô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
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
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 UPDATEne 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.
- Afficher la fiche d'un matériel à partir de son identifiant.
- Afficher le nombre de matériels en réparation.
- Remplir une liste déroulante des utilisateurs (valeur :
id, texte :nom). - Exporter en CSV les 80 000 emprunts de l'historique.
- 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
fetch(), puis testerfalsepour renvoyer une erreur 404.fetchColumn()sur unSELECT COUNT(*) … WHERE etat = 'en_reparation', converti en(int).fetchAll(PDO::FETCH_KEY_PAIR)surSELECT id, nom FROM utilisateur ORDER BY nom: on obtient directement[id => nom].- Un
foreachsur lePDOStatement(ligne par ligne, avecfputcsv()), pasfetchAll(), qui chargerait les 80 000 lignes en mémoire. fetchAll(PDO::FETCH_COLUMN)surSELECT reference FROM materiel WHERE etat = 'reforme', puisimplode(', ', …). 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
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 ?
- 1062
- 1406
- 1265, avec le message « Data truncated for column 'role' »
Voir la réponseMasquer la réponse de l'exercice 3
- 1062 : l'adresse email existe déjà, c'est la seule colonne
UNIQUEsaisie. Message : « Un compte utilise déjà cette adresse. » - 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.
- 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
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é ?