Tutoriel
Partie 20 — Requêtes SQL sécurisées : requêtes préparées

Partie 20 — Requêtes SQL sécurisées : requêtes préparées

Apprends les requêtes préparées PDO en PHP : protection SQL injection, paramètres nommés/?, bindValue/bindParam, IN dynamique, LIMIT/OFFSET, LIKE, erreurs courantes.

PHP 47 Mis à jour 22 minutes ago
Conseil : lisez d’abord les sections clés, puis essayez un QCM lié à la même notion pour valider votre compréhension.

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 :

  1. prepare() : MySQL “compile” la requête SQL avec des emplacements (placeholders)
  2. 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 :id dans le SQL mais tu n’envoies pas id dans execute()
  • tu envoies un paramètre en plus qui n’existe pas dans le SQL
  • tu as mal écrit le nom (:email vs email)

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 = ?, indexe email.
  • 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)

  1. Faire une page “Recherche posts” (LIKE préparé) avec pagination.
  2. Faire un SELECT avec IN dynamique (ids venant d’un tableau) + cast int.
  3. Corriger une erreur HY093 (volontairement) en alignant les noms de paramètres.
  4. Créer un CRUD posts (insert/update/delete) uniquement avec prepare/execute.
  5. 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).