Chapitre 1812 minDébutant
SQL dans une application (PDO, ORM)
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- connecter une application PHP à MySQL avec PDO ;
- exécuter une requête préparée et parcourir ses résultats ;
- gérer les erreurs et les transactions dans le code ;
- comprendre ce qu'apporte un ORM.
3 exercices corrigés
Une application PHP, Node.js ou C# envoie ses requêtes SQL au serveur par un pilote : PDO en PHP, mysql2 en Node.js, MySqlConnector en C#. Ce chapitre montre comment se connecter, exécuter des requêtes préparées, gérer les erreurs et les transactions dans le code, régler un pool de connexions et utiliser un ORM. Le cours PHP et MySQL construit une application complète avec PDO.
Se connecter
Une application se connecte au serveur avec un compte dédié et lit ses paramètres de connexion dans des variables d'environnement ou un fichier de configuration hors du dépôt Git : un mot de passe écrit dans le code finit tôt ou tard publié.
// PHP, avec PDO
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
getenv('DB_HOST'), getenv('DB_PORT') ?: '3306', getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// Node.js, avec mysql2
import mysql from 'mysql2/promise';
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10,
});
// C#, avec MySqlConnector
var chaine = Environment.GetEnvironmentVariable("PARC_CONNECTION_STRING");
// par exemple : "Server=10.0.1.10;Database=parc;User ID=app_parc;Password=...;SslMode=Required"
await using var connexion = new MySqlConnection(chaine);
await connexion.OpenAsync();
Le charset=utf8mb4 du DSN PHP est indispensable : sans lui, les accents peuvent être mal transmis entre l'application et le serveur.
Récupérer et parcourir les résultats
// Plusieurs lignes
$stmt = $pdo->prepare('SELECT id_ticket, titre, statut FROM ticket WHERE id_demandeur = ? ORDER BY date_ouverture DESC');
$stmt->execute([$idEmploye]);
foreach ($stmt as $ticket) {
echo htmlspecialchars($ticket['titre']) . ' (' . $ticket['statut'] . ')' . PHP_EOL;
}
// Une seule valeur
$stmt = $pdo->prepare('SELECT COUNT(*) FROM ticket WHERE statut = ?');
$stmt->execute(['ouvert']);
$nbOuverts = (int) $stmt->fetchColumn();
// Après un INSERT : l'identifiant généré
$stmt = $pdo->prepare('INSERT INTO ticket (titre, id_demandeur, priorite) VALUES (?, ?, ?)');
$stmt->execute([$titre, $idEmploye, 'normale']);
$idTicket = (int) $pdo->lastInsertId();
// Node.js : execute renvoie [lignes, métadonnées]
const [tickets] = await pool.execute(
'SELECT id_ticket, titre, statut FROM ticket WHERE id_demandeur = ? ORDER BY date_ouverture DESC',
[idEmploye]
);
for (const t of tickets) console.log(`${t.titre} (${t.statut})`);
const [resultat] = await pool.execute(
'INSERT INTO ticket (titre, id_demandeur, priorite) VALUES (?, ?, ?)',
[titre, idEmploye, 'normale']
);
const idTicket = resultat.insertId;
htmlspecialchars() protège l'affichage contre une autre injection, le XSS : une donnée de la base ne doit jamais être insérée telle quelle dans une page HTML.
Erreurs et transactions côté code
Une erreur SQL arrive dans le code sous forme d'exception. Son code MySQL permet un message utile à l'utilisateur ; les détails techniques vont dans le journal de l'application, jamais à l'écran.
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT nb_licences FROM logiciel WHERE id_logiciel = ? FOR UPDATE');
$stmt->execute([$idLogiciel]);
$nbLicences = $stmt->fetchColumn();
$stmt = $pdo->prepare('SELECT COUNT(*) FROM installation WHERE id_logiciel = ?');
$stmt->execute([$idLogiciel]);
$utilisees = (int) $stmt->fetchColumn();
if ($nbLicences !== null && $utilisees >= (int) $nbLicences) {
$pdo->rollBack();
throw new RuntimeException('Plus aucune licence disponible pour ce logiciel.');
}
$stmt = $pdo->prepare('INSERT INTO installation (id_poste, id_logiciel, date_installation) VALUES (?, ?, CURDATE())');
$stmt->execute([$idPoste, $idLogiciel]);
$pdo->commit();
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if (($e->errorInfo[1] ?? null) === 1062) {
throw new RuntimeException('Ce logiciel est déjà installé sur ce poste.');
}
error_log($e->getMessage());
throw new RuntimeException('Installation impossible, réessaie plus tard.');
}
C'est le scénario de la dernière licence, cette fois dans le code. $e->errorInfo[1] contient le numéro d'erreur MySQL (1062 pour un doublon), $e->errorInfo[0] le code SQLSTATE. Un logiciel libre (nb_licences à NULL) n'a pas de limite.
Le pool de connexions
Ouvrir une connexion coûte cher : échange réseau, authentification, négociation TLS. Un pool garde un petit nombre de connexions ouvertes et les prête aux requêtes qui arrivent. Avec mysql2, createPool le fait (ici 10 connexions au plus) ; avec MySqlConnector, le pool est actif par défaut et se règle dans la chaîne de connexion (Maximum Pool Size). En PHP, chaque requête HTTP ouvre en général sa propre connexion, ce qui reste raisonnable pour une application modeste.
Règle la taille du pool en fonction de max_connections du serveur (151 par défaut) : dix instances d'une application avec 20 connexions chacune, c'est déjà 200 connexions.
Les ORM
Un ORM (object-relational mapping) fait correspondre les tables à des classes, et les lignes à des objets. Tu écris $ticket->getDemandeur()->getNom() et l'ORM génère le SQL.
| Langage | ORM courants |
|---|---|
| PHP | Doctrine (Symfony), Eloquent (Laravel) |
| Node.js | Prisma, Sequelize, TypeORM |
| C# | Entity Framework Core |
Les avantages : moins de code répétitif, des requêtes paramétrées par défaut (donc protégées contre l'injection), des migrations intégrées. Les pièges :
- le problème N+1 : charger une liste puis accéder à un objet lié dans une boucle déclenche une requête par élément. Les ORM proposent un chargement anticipé (
with()dans Eloquent,includedans Prisma et Entity Framework) ; - le SQL généré n'est pas toujours efficace : active l'affichage des requêtes en développement et lis-les ;
- un ORM ne dispense pas de connaître SQL : jointures externes, agrégats, index et transactions restent ta responsabilité, et chaque ORM permet d'écrire du SQL brut quand il le faut.
La couche d'accès aux données d'une application complète est construite dans les chapitres CRUD complet en PHP avec PDO, Node.js et MySQL avec mysql2 et Connexion C# à MySQL ; le chapitre Pattern Repository et DAO explique comment l'isoler du reste du code.
Exercices
Exercice 1 sur 3 Afficher les tickets d'un employé Réussi À revoir
En PHP avec PDO, écris le code qui affiche le titre et la date d'ouverture (au format JJ/MM/AAAA) des tickets non résolus de l'employé dont l'identifiant est dans $_GET['id'], du plus récent au plus ancien.
Voir la réponseMasquer la réponse de l'exercice 1
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
exit('Identifiant invalide');
}
$stmt = $pdo->prepare(
"SELECT titre, DATE_FORMAT(date_ouverture, '%d/%m/%Y') AS ouvert_le
FROM ticket
WHERE id_demandeur = :id AND statut <> 'resolu'
ORDER BY date_ouverture DESC"
);
$stmt->execute(['id' => $id]);
foreach ($stmt as $t) {
echo htmlspecialchars($t['titre']) . ' - ' . $t['ouvert_le'] . '<br>';
}
La valeur est validée (entier), passée par un marqueur, et le titre est échappé à l'affichage. La mise en forme de la date peut se faire en SQL, comme ici, ou en PHP.
Tu avais trouvé ?
Exercice 2 sur 3 Traduire une erreur Réussi À revoir
Lors de la création d'un employé, l'application reçoit une PDOException dont errorInfo[1] vaut 1062, puis une autre fois 1452. Quel message afficher à l'utilisateur dans chaque cas, et que faut-il écrire dans le journal de l'application ?
Voir la réponseMasquer la réponse de l'exercice 2
- 1062 (doublon) : l'email existe déjà, puisque c'est la seule colonne
UNIQUEsaisie. Message : « Un employé utilise déjà cette adresse email. » - 1452 (clé étrangère) : le service ou le responsable choisi n'existe pas. Message : « Le service ou le responsable sélectionné n'existe plus. Recharge la page. » (Un formulaire à jour ne devrait jamais produire cette erreur : elle signale une donnée supprimée entre-temps ou une requête forgée.)
Dans les deux cas, le journal reçoit le message complet de l'exception, le code SQLSTATE et le contexte (utilisateur, date), mais pas les mots de passe ni les données personnelles inutiles. À l'écran, jamais le message brut de MySQL : il révèle les noms des tables et des contraintes à un éventuel attaquant.
Tu avais trouvé ?
Exercice 3 sur 3 Repérer le N+1 Réussi À revoir
Avec un ORM, ce code affiche la liste des postes et le nom de leur utilisateur. Combien de requêtes SQL déclenche-t-il pour 15 postes, si l'utilisateur est chargé à la demande ? Comment le corriger en SQL ?
foreach ($repositoryPoste->findAll() as $poste) {
echo $poste->getNumInventaire() . ' : ' . $poste->getEmploye()?->getNom() . PHP_EOL;
}
Voir la réponseMasquer la réponse de l'exercice 3
Une requête pour la liste des postes, puis une par employé à charger : 1 + 12 = 13 requêtes dans la base d'exemple (13 postes affectés, dont deux à Sofiane Benali, que l'ORM ne charge qu'une fois). Avec N postes affectés à N personnes différentes, c'est N + 1 requêtes. En SQL, une seule requête suffit :
SELECT p.num_inventaire, e.nom
FROM poste p
LEFT JOIN employe e ON e.id_employe = p.id_employe
ORDER BY p.num_inventaire;
Avec l'ORM, on demande un chargement anticipé de la relation (une jointure ou une requête IN groupée), par exemple une requête DQL SELECT p, e FROM Poste p LEFT JOIN p.employe e avec Doctrine.
Tu avais trouvé ?