Partie 20 — Requêtes SQL sécurisées : requêtes préparées (PDO)
Si tu devais retenir une seule règle en PHP/MySQL, ce serait celle-ci : ne concatène jamais des valeurs utilisateur dans une requête SQL. Même si “ça marche”, même si tu filtres un peu… c’est la porte ouverte à la SQL injection.
Les requêtes préparées sont la solution standard et professionnelle : elles séparent le SQL (structure) des données (valeurs), ce qui empêche MySQL d’interpréter un paramètre comme du code SQL.
👉 Objectif : maîtriser les requêtes préparées à 100% : placeholders, bind, types, IN dynamique, LIMIT/OFFSET, LIKE, erreurs fréquentes, et un mini CRUD sécurisé réutilisable.
1) SQL Injection : comprendre le danger (simple et concret)
La SQL injection arrive quand tu écris une requête comme ça :
<?php $email = $_GET["email"]; // entrée utilisateur $sql = "SELECT * FROM users WHERE email = '$email'"; ?>
Si un attaquant envoie :
test@test.com' OR 1=1 --
Ta requête devient :
SELECT * FROM users WHERE email = 'test@test.com' OR 1=1 --'
Résultat : la condition devient toujours vraie → l’attaquant peut lire des données qu’il ne devrait jamais voir.
⚠️ Même si tu “remplaces les quotes” ou tu utilises addslashes(), ce n’est pas fiable.
La vraie solution est : requête préparée.
2) Principe des requêtes préparées (structure vs valeurs)
Une requête préparée se fait en 2 étapes :
- prepare() : MySQL “compile” la requête SQL avec des emplacements (placeholders)
- execute() : tu envoies les valeurs séparément
✅ Comme ça, même si l’utilisateur envoie du SQL, MySQL le traite comme une simple string.
<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(["email" => $email]);
$user = $stmt->fetch();
?>
3) Placeholders : nommés (:name) vs positionnels (?)
3.1 Placeholders nommés
Plus lisibles, surtout quand tu as beaucoup de paramètres.
<?php $sql = "SELECT * FROM users WHERE email = :email AND status = :status"; $stmt = $pdo->prepare($sql); $stmt->execute([ "email" => $email, "status" => "active" ]); ?>
3.2 Placeholders positionnels (?)
Plus courts, mais l’ordre compte (tu dois envoyer les valeurs dans le bon ordre).
<?php $sql = "SELECT * FROM users WHERE email = ? AND status = ?"; $stmt = $pdo->prepare($sql); $stmt->execute([$email, "active"]); ?>
En pratique : utilise les nommés pour la lisibilité, et les positionnels quand tu fais un IN dynamique (on le verra).
4) bindValue() vs bindParam() : différence claire
Beaucoup confondent les deux :
- bindValue : lie une valeur (immédiate)
- bindParam : lie une variable (référence, valeur lue au moment de execute)
4.1 bindValue (le plus courant)
<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->bindValue(":id", 10, PDO::PARAM_INT);
$stmt->execute();
?>
4.2 bindParam (quand tu modifies la variable avant execute)
<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->bindParam(":id", $id, PDO::PARAM_INT);
$id = 10;
$stmt->execute();
$id = 20;
$stmt->execute();
?>
Si tu n’as pas un besoin spécial, bindValue ou execute([...]) suffit.
5) Types et sécurité : forcer INT, BOOL, NULL
PDO va généralement gérer, mais certains paramètres doivent être forcés : surtout LIMIT / OFFSET et certains IDs.
5.1 Exemple pagination (LIMIT/OFFSET)
<?php
$page = max(1, (int)($_GET["page"] ?? 1));
$perPage = 20;
$offset = ($page - 1) * $perPage;
$sql = "SELECT id, title FROM posts ORDER BY id DESC LIMIT :lim OFFSET :off";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(":lim", $perPage, PDO::PARAM_INT);
$stmt->bindValue(":off", $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
?>
⚠️ Ne concatène pas LIMIT/OFFSET depuis l’URL. Force des entiers.
5.2 NULL (valeur vide)
<?php
$stmt = $pdo->prepare("UPDATE users SET phone = :p WHERE id = :id");
$stmt->bindValue(":p", null, PDO::PARAM_NULL);
$stmt->bindValue(":id", 5, PDO::PARAM_INT);
$stmt->execute();
?>
6) IN dynamique : le cas qui bloque tout le monde
Tu ne peux pas faire :
-- ❌ Mauvais SELECT * FROM posts WHERE id IN (:ids)
Parce que :ids serait une seule valeur, pas une liste.
La bonne méthode : générer le bon nombre de ?.
6.1 Exemple IN propre
<?php
$ids = [3, 7, 9];
$placeholders = implode(",", array_fill(0, count($ids), "?"));
$sql = "SELECT id, title FROM posts WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);
$rows = $stmt->fetchAll();
?>
Important : assure-toi que ce sont bien des entiers (cast).
<?php
$ids = array_map("intval", $ids);
?>
✅ C’est la méthode standard pour un IN dynamique avec PDO.
7) LIKE sécurisé : recherche sans injection
Avec LIKE, le piège est de concaténer le % dans le SQL.
Tu peux le faire, mais proprement on le met dans la valeur.
<?php
$q = $_GET["q"] ?? "";
$stmt = $pdo->prepare("SELECT id, title FROM posts WHERE title LIKE :q ORDER BY id DESC");
$stmt->execute(["q" => "%" . $q . "%"]);
$rows = $stmt->fetchAll();
?>
Ça bloque la SQL injection, mais attention : LIKE peut être lent sans index (selon le pattern).
8) Erreurs PDO fréquentes : HY093 et compagnie
8.1 SQLSTATE[HY093] (paramètres invalides)
Cette erreur arrive souvent quand :
- tu as un placeholder
:iddans le SQL mais tu n’envoies pasiddans execute() - tu envoies un paramètre en plus qui n’existe pas dans le SQL
- tu as mal écrit le nom (
:emailvsemail)
Exemple typique :
<?php
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(["user_id" => 5]); // ❌ mauvais nom
?>
Fix :
<?php $stmt->execute(["id" => 5]); // ✅ ?>
8.2 “Unknown column” / “Table doesn’t exist”
Ce sont des erreurs SQL, pas PDO. Tu corriges le schéma ou la requête. Réflexe : copie la requête et teste-la directement dans MySQL (phpMyAdmin, CLI).
9) Mini CRUD sécurisé (users) : modèle réutilisable
On va écrire 4 fonctions “propres” : createUser, findUser, updateUser, deleteUser. C’est une base parfaite pour ton projet.
<?php
// user_repo.php
function createUser(PDO $pdo, string $name, string $email): int
{
$stmt = $pdo->prepare("INSERT INTO users (name, email, created_at) VALUES (:n, :e, NOW())");
$stmt->execute(["n" => $name, "e" => $email]);
return (int)$pdo->lastInsertId();
}
function findUser(PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare("SELECT id, name, email, created_at FROM users WHERE id = :id LIMIT 1");
$stmt->execute(["id" => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
function updateUser(PDO $pdo, int $id, string $name): int
{
$stmt = $pdo->prepare("UPDATE users SET name = :n WHERE id = :id");
$stmt->execute(["n" => $name, "id" => $id]);
return $stmt->rowCount();
}
function deleteUser(PDO $pdo, int $id): int
{
$stmt = $pdo->prepare("DELETE FROM users WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->rowCount();
}
?>
Avec ça, ton code “controller” devient simple :
<?php require "db.php"; require "user_repo.php"; $pdo = db(); $id = createUser($pdo, "Amine", "amine@test.com"); $user = findUser($pdo, $id); echo "<pre>" . print_r($user, true) . "</pre>"; ?>
10) Requêtes préparées et performance : 3 réflexes
-
Index : si tu fais souvent
WHERE email = ?, indexeemail. - LIMIT : quand tu listes, ne récupère pas 10 000 lignes.
-
Colonnes : évite
SELECT *dans les grosses tables (prends ce dont tu as besoin).
✅ La sécurité et la performance vont ensemble : requêtes préparées + schéma propre + index.
11) Exercices (pour être solide)
- Faire une page “Recherche posts” (LIKE préparé) avec pagination.
- Faire un SELECT avec IN dynamique (ids venant d’un tableau) + cast int.
- Corriger une erreur HY093 (volontairement) en alignant les noms de paramètres.
- Créer un CRUD posts (insert/update/delete) uniquement avec prepare/execute.
- Ajouter une transaction : créer un post + écrire un log dans une table logs.
Conclusion
Les requêtes préparées sont la fondation de la sécurité SQL. Si tu les utilises partout (SELECT/INSERT/UPDATE/DELETE), tu bloques l’injection, tu rends ton code plus propre, et tu deviens beaucoup plus confiant quand tu branches ton site à une base de données.
Partie 21 logique : CRUD complet (users/posts) avec validations + sessions + flash, ou sécurité web globale (XSS/CSRF + SQL + upload).