Un inventaire qui disparaît à chaque fermeture du script n’a aucune utilité. Il faut une base de données. PHP parle aux bases via PDO (PHP Data Objects), une couche unifiée qui s’adresse à MySQL, MariaDB, PostgreSQL ou SQLite avec la même API. Dans ce tutoriel, on connecte le projet « Atelier » à MySQL, on crée la table des pièces, et on écrit une implémentation persistante du dépôt — celle que le tutoriel orienté objet annonçait. Le fil conducteur : tout passe par des requêtes préparées, la seule façon sûre de mêler du SQL et des données utilisateur.
Article de référence : ce tutoriel fait partie du guide complet du PHP moderne. Il réutilise l’interface DepotPieces définie dans le tutoriel sur la programmation orientée objet.
Ce que vous allez apprendre
- Établir une connexion PDO robuste vers MySQL avec les bonnes options.
- Exécuter des requêtes préparées avec paramètres nommés.
- Lire des résultats (un, plusieurs, agrégats) et les transformer en objets.
- Insérer, mettre à jour et supprimer des lignes (CRUD complet).
- Implémenter
DepotPiecesavec une vraie persistance.
Ce que vous allez construire
Une classe DepotPdo qui implémente l’interface DepotPieces du modèle objet, branchée sur une table MySQL pieces. À la fin, ajouter une pièce l’enregistre durablement, et la relire après redémarrage la retrouve intacte.
Prérequis
- PHP 8.4 avec l’extension
pdo_mysqlactivée (vérifiez avecphp -m | grep pdoouphp -i | findstr pdosous Windows). - Un serveur MySQL 8 ou MariaDB accessible, et un client (ligne de commande, phpMyAdmin, Adminer…).
- Le projet structuré avec Composer du tutoriel précédent.
- ⏱️ Temps estimé : ~50 minutes.
Étape 1 — Créer la base et la table
Avant tout code PHP, préparons le terrain côté base. On crée une base atelier et une table pieces qui reflète notre classe : une référence unique, un nom, une catégorie, un prix et une quantité. Le choix de l’encodage utf8mb4 est important : c’est le seul jeu de caractères MySQL qui gère tout l’Unicode, y compris les caractères accentués sans surprise.
CREATE DATABASE atelier CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE atelier;
CREATE TABLE pieces (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reference VARCHAR(32) NOT NULL UNIQUE,
nom VARCHAR(120) NOT NULL,
categorie VARCHAR(20) NOT NULL,
prix DECIMAL(10,2) NOT NULL,
quantite INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB;
La contrainte UNIQUE sur reference garantit qu’on ne stocke pas deux fois la même pièce : la base elle-même refuse les doublons. Le type DECIMAL(10,2) stocke un prix exact (contrairement à FLOAT, qui introduit des erreurs d’arrondi inacceptables pour de la monnaie). Le moteur InnoDB apporte les transactions et les clés étrangères. Cette structure correspond exactement aux propriétés de la classe Piece ; la colonne categorie stocke la valeur de l’énumération (« visserie », « electrique »…).
Étape 2 — Établir la connexion PDO
La connexion à la base se fait en instanciant PDO avec une chaîne DSN (Data Source Name) qui décrit le pilote, l’hôte, la base et l’encodage, suivie de l’identifiant et du mot de passe. Le point déterminant, ce sont les options que l’on passe : elles transforment PDO d’un outil permissif en un outil rigoureux.
<?php
declare(strict_types=1);
namespace Atelier\Depot;
use PDO;
function connexion(): PDO
{
$dsn = 'mysql:host=127.0.0.1;dbname=atelier;charset=utf8mb4';
return new PDO($dsn, 'atelier_user', 'mot_de_passe_solide', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
Détaillons les trois options, car elles sont la marque d’une connexion bien faite. ATTR_ERRMODE => ERRMODE_EXCEPTION fait lever une PDOException à la moindre erreur SQL, au lieu de la laisser passer silencieusement ; c’est le mode par défaut depuis PHP 8.0, mais le déclarer reste une bonne habitude documentaire. ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC fait que les lignes lues sont des tableaux associatifs (colonne => valeur), sans la duplication numérique inutile. ATTR_EMULATE_PREPARES => false demande à MySQL de préparer réellement les requêtes côté serveur, plutôt que de laisser PDO simuler la préparation — c’est plus sûr et plus fidèle aux types.
Petit confort de PHP 8.4 : la nouvelle fabrique statique PDO::connect($dsn, ...) renvoie directement une instance de la sous-classe spécifique au pilote — ici un objet Pdo\Mysql — qui expose des méthodes propres à MySQL. On peut aussi l’instancier directement avec new Pdo\Mysql($dsn, ...). Pour l’apprentissage, le constructeur new PDO(...) classique reste parfait et universel.
Point d’étape — Appelez
connexion()dans un script de test. Aucune erreur ne doit s’afficher. Si vous obtenez « SQLSTATE[HY000] [1045] Access denied », vérifiez l’identifiant et le mot de passe ; « [2002] Connection refused » signale que le serveur MySQL n’est pas démarré ou que l’hôte/port est faux.
Étape 3 — Insérer avec une requête préparée
Une requête préparée sépare la structure SQL des données. On écrit d’abord le SQL avec des emplacements nommés (:reference), puis on associe les valeurs réelles au moment de l’exécution. La base traite ces valeurs comme des données pures, jamais comme du code — c’est ce qui ferme la porte à l’injection SQL. Insérons une pièce.
<?php
function insererPiece(PDO $pdo, Piece $piece): void
{
$sql = 'INSERT INTO pieces (reference, nom, categorie, prix, quantite)
VALUES (:reference, :nom, :categorie, :prix, :quantite)';
$stmt = $pdo->prepare($sql);
$stmt->execute([
'reference' => $piece->reference,
'nom' => $piece->nom,
'categorie' => $piece->categorie->value,
'prix' => $piece->prix,
'quantite' => $piece->quantite,
]);
}
La méthode prepare() renvoie un objet PDOStatement ; execute() reçoit un tableau associatif qui lie chaque emplacement à sa valeur. Remarquez $piece->categorie->value : on stocke la valeur sous-jacente de l’énumération (la chaîne), pas l’objet. Même si $piece->nom contenait une apostrophe ou une tentative d’injection comme '); DROP TABLE pieces;--, elle serait insérée comme un texte inoffensif. C’est la raison numéro un d’utiliser systématiquement les requêtes préparées, détaillée dans le tutoriel sur la sécurité.
Étape 4 — Lire et reconstruire des objets
Lire fonctionne sur le même principe : on prépare, on exécute avec les paramètres, puis on récupère les résultats. fetch() renvoie une ligne, fetchAll() toutes les lignes. Comme on a choisi FETCH_ASSOC, chaque ligne est un tableau associatif qu’on transforme en objet Piece. Écrivons la lecture d’une pièce par sa référence.
<?php
function pieceParReference(PDO $pdo, string $reference): ?Piece
{
$stmt = $pdo->prepare('SELECT * FROM pieces WHERE reference = :reference');
$stmt->execute(['reference' => $reference]);
$ligne = $stmt->fetch();
if ($ligne === false) {
return null; // aucune pièce trouvée
}
return new Piece(
reference: $ligne['reference'],
nom: $ligne['nom'],
categorie: Categorie::from($ligne['categorie']),
prix: (float) $ligne['prix'],
quantite: (int) $ligne['quantite'],
);
}
Quand aucune ligne ne correspond, fetch() renvoie false : on le traduit en null, conformément au contrat de DepotPieces. La reconstruction utilise les arguments nommés (vus dans le tutoriel de syntaxe) pour la lisibilité. Notez les conversions explicites : selon la configuration, une colonne peut revenir sous forme de chaîne — avec l’émulation des requêtes préparées désactivée et mysqlnd, les entiers reviennent en int. On caste tout de même prix en float et quantite en int pour garantir le typage strict de la classe quelle que soit la configuration. Et Categorie::from() retransforme la chaîne stockée en cas d’énumération typé — le pont base/code annoncé dans le tutoriel objet.
Point d’étape — Insérez une pièce puis relisez-la par sa référence. L’objet reconstruit doit avoir les mêmes valeurs, et
$piece->categoriedoit être une instance deCategorie, pas une chaîne. SiCategorie::from()lève « not a valid backing value », c’est que la colonnecategoriecontient une valeur hors énumération — vérifiez vos insertions.
Étape 5 — Mettre à jour et supprimer
Les opérations d’écriture suivent le même schéma préparé. Mettons à jour la quantité d’une pièce et supprimons-en une. La méthode rowCount() indique combien de lignes ont été affectées, ce qui permet de savoir si l’opération a réellement touché quelque chose.
<?php
function majQuantite(PDO $pdo, string $reference, int $quantite): bool
{
$stmt = $pdo->prepare('UPDATE pieces SET quantite = :q WHERE reference = :ref');
$stmt->execute(['q' => $quantite, 'ref' => $reference]);
return $stmt->rowCount() > 0; // true si une ligne a changé
}
function supprimerPiece(PDO $pdo, string $reference): bool
{
$stmt = $pdo->prepare('DELETE FROM pieces WHERE reference = :ref');
$stmt->execute(['ref' => $reference]);
return $stmt->rowCount() > 0;
}
Chaque requête d’écriture cible une ligne précise via la clause WHERE sur la référence unique. rowCount() renvoie 0 si la référence n’existait pas : on peut s’en servir pour signaler « pièce introuvable » à l’appelant. Une règle absolue transparaît ici : jamais de WHERE reference = '$reference' avec la variable collée dans la chaîne. Toujours un emplacement :ref et une valeur liée. Cette discipline, sans exception, est ce qui sépare une application sûre d’une passoire.
Étape 6 — Implémenter DepotPieces avec PDO
Rassemblons tout dans la classe promise par le tutoriel objet. DepotPdo reçoit sa connexion par injection de dépendances (le constructeur prend un PDO), ce qui la rend testable et découplée de la façon dont la connexion est créée.
<?php
declare(strict_types=1);
namespace Atelier\Depot;
use Atelier\Piece;
use Atelier\Categorie;
use PDO;
final class DepotPdo implements DepotPieces
{
public function __construct(private PDO $pdo) {}
public function ajouter(Piece $piece): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO pieces (reference, nom, categorie, prix, quantite)
VALUES (:reference, :nom, :categorie, :prix, :quantite)'
);
$stmt->execute([
'reference' => $piece->reference,
'nom' => $piece->nom,
'categorie' => $piece->categorie->value,
'prix' => $piece->prix,
'quantite' => $piece->quantite,
]);
}
public function parReference(string $reference): ?Piece
{
$stmt = $this->pdo->prepare('SELECT * FROM pieces WHERE reference = :ref');
$stmt->execute(['ref' => $reference]);
$l = $stmt->fetch();
return $l === false ? null : $this->hydrater($l);
}
/** @return Piece[] */
public function toutes(): array
{
$stmt = $this->pdo->query('SELECT * FROM pieces ORDER BY reference');
return array_map([$this, 'hydrater'], $stmt->fetchAll());
}
private function hydrater(array $l): Piece
{
return new Piece(
reference: $l['reference'],
nom: $l['nom'],
categorie: Categorie::from($l['categorie']),
prix: (float) $l['prix'],
quantite: (int) $l['quantite'],
);
}
}
La méthode privée hydrater() centralise la transformation ligne-vers-objet, évitant la répétition. query() sert pour une requête sans paramètre utilisateur (ici, lister tout), tandis que prepare()/execute() s’imposent dès qu’une valeur extérieure entre en jeu. Le point décisif : le reste de l’application ne voit que l’interface DepotPieces. On peut donc, dans les tests, injecter DepotMemoire, et en production DepotPdo, sans changer une ligne du code métier. C’est l’aboutissement de l’architecture commencée plusieurs tutoriels plus tôt.
Étape 7 — Les transactions pour les opérations groupées
Quand plusieurs écritures doivent réussir ou échouer ensemble — par exemple transférer du stock d’une pièce à une autre — on les enveloppe dans une transaction. Soit tout est validé (commit), soit tout est annulé (rollBack) en cas de problème, garantissant que la base ne reste jamais dans un état incohérent.
<?php
$pdo->beginTransaction();
try {
majQuantite($pdo, 'VIS-M6', 90);
majQuantite($pdo, 'VIS-M8', 60);
$pdo->commit();
} catch (\PDOException $e) {
$pdo->rollBack();
throw $e; // remontée pour journalisation
}
Entre beginTransaction() et commit(), les modifications sont en attente. Si la seconde mise à jour échoue, le catch appelle rollBack() et la première est annulée : on n’aura jamais débité une pièce sans créditer l’autre. La gestion fine de ces exceptions fait l’objet du tutoriel sur la gestion des erreurs.
Étape 8 — Vérification finale
Écrivez un script qui crée un DepotPdo avec une vraie connexion, ajoute trois pièces, en relit une par référence, met à jour une quantité, liste l’ensemble, puis supprime une pièce. Arrêtez et relancez le script sans réinsérer : les pièces restantes doivent toujours être là. Cette persistance entre deux exécutions est la preuve que votre couche d’accès aux données fonctionne. Vous disposez désormais d’un dépôt persistant, sûr face aux injections, et interchangeable avec sa version en mémoire.
Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
could not find driver |
Extension pdo_mysql non activée |
Activer extension=pdo_mysql dans php.ini |
SQLSTATE[HY000] [1045] Access denied |
Identifiant ou mot de passe incorrect | Vérifier les droits de l’utilisateur MySQL |
SQLSTATE[23000] Duplicate entry |
Violation de la contrainte UNIQUE sur la référence |
Vérifier l’existence avant insertion, ou gérer l’exception |
| Caractères accentués cassés | Encodage manquant dans le DSN | Ajouter charset=utf8mb4 au DSN |
| Les nombres reviennent en chaînes | Émulation des préparations active (comportement par défaut) | Caster explicitement, ou désactiver l’émulation des préparations |
Réalités du terrain
Les identifiants de connexion n’ont rien à faire en dur dans le code : on les place dans des variables d’environnement ou un fichier .env hors du dépôt Git, lu au démarrage. Sur un hébergement mutualisé, la base est souvent imposée (nom, hôte, utilisateur fournis par le panneau), et le serveur MySQL n’écoute que localement : l’hôte est alors localhost et non une IP publique. Pensez aussi à créer un utilisateur MySQL dédié à l’application, avec seulement les droits nécessaires (SELECT, INSERT, UPDATE, DELETE) sur la base atelier — jamais le compte root. C’est une défense en profondeur : même si l’application est compromise, l’attaquant ne peut pas toucher aux autres bases.
Récapitulatif
Vous avez donné une mémoire durable à « Atelier ». PDO connecte l’application à MySQL avec trois options qui la rendent stricte ; les requêtes préparées séparent SQL et données, fermant la porte à l’injection ; fetch et fetchAll lisent les résultats qu’on reconstruit en objets typés ; le CRUD complet (insérer, lire, mettre à jour, supprimer) couvre les besoins courants ; les transactions garantissent la cohérence des opérations groupées ; et DepotPdo réalise enfin l’interface du modèle objet. La couche de données est en place.
Aide-mémoire
| Élément | Rôle |
|---|---|
new PDO($dsn, $user, $pass, $options) |
Ouvrir une connexion |
ATTR_ERRMODE => ERRMODE_EXCEPTION |
Lever une exception sur erreur SQL |
$pdo->prepare($sql) |
Préparer une requête à paramètres |
$stmt->execute([...]) |
Exécuter avec les valeurs liées |
$stmt->fetch() / fetchAll() |
Lire une ligne / toutes les lignes |
$stmt->rowCount() |
Nombre de lignes affectées |
$pdo->lastInsertId() |
Dernier identifiant auto-incrémenté |
beginTransaction / commit / rollBack |
Regrouper des écritures atomiquement |
À vous de jouer
Ajoutez à DepotPdo une méthode valeurTotaleStock(): float qui calcule, côté base, la somme de prix × quantite de toutes les pièces avec une requête d’agrégation.
Voir une solution
public function valeurTotaleStock(): float
{
$stmt = $this->pdo->query(
'SELECT COALESCE(SUM(prix * quantite), 0) AS total FROM pieces'
);
return (float) $stmt->fetchColumn();
}
Tutoriels liés
- Sécuriser une application PHP — pourquoi les requêtes préparées sont vitales.
- La gestion des erreurs et des exceptions — gérer proprement les
PDOException.
Pour aller plus loin
- 🔝 Retour au guide : PHP moderne, le guide complet.
- Documentation de PDO : php.net — PDO.
- Les requêtes préparées : php.net — Requêtes préparées.
Foire aux questions
PDO ou mysqli ?
PDO, dans la plupart des cas. mysqli ne fonctionne qu’avec MySQL/MariaDB, alors que PDO offre la même API pour plusieurs moteurs, ce qui facilite un éventuel changement de base et homogénéise le code. Les requêtes préparées de PDO, avec paramètres nommés, sont aussi plus lisibles.
Paramètres nommés ou positionnels (?) ?
Les deux fonctionnent. Les paramètres nommés (:reference) sont plus lisibles et ne dépendent pas de l’ordre, ce qui aide sur les requêtes longues. Les positionnels (?) sont plus concis pour une ou deux valeurs. C’est une question de style ; l’important est de toujours en utiliser, jamais la concaténation.
Faut-il désactiver l’émulation des requêtes préparées ?
Oui, en règle générale : avec ATTR_EMULATE_PREPARES => false, MySQL prépare réellement la requête côté serveur, ce qui respecte mieux les types et offre une sécurité un cran au-dessus. L’émulation reste utile dans de rares cas de compatibilité, mais par défaut, on la désactive.