Partie 21 — CRUD : créer, lire, modifier, supprimer des données
CRUD, c’est le cœur de 90% des applications : tu crées des données, tu les lis, tu les modifies, tu les supprimes. Un système de QCM, un blog, une page concours, un espace utilisateur… tout ça repose sur CRUD.
Dans cette partie, on va construire un CRUD complet propre : PHP + MySQL via PDO, requêtes préparées, validations, messages flash, et protections minimales (CSRF, erreurs).
👉 Mini-projet : CRUD “Posts” (articles). Ensuite tu pourras adapter exactement la même logique à “Concours”, “QCM”, “Catégories”, “Utilisateurs”, etc.
1) Structure propre des fichiers (simple mais scalable)
On organise le projet en dossiers/fichiers clairs :
/crud/
db.php
helpers.php
posts_repo.php
/posts/
index.php (liste)
create.php (form + insert)
show.php (détail)
edit.php (form + update)
delete.php (suppression POST)
Objectif : garder la DB dans db.php, les fonctions utiles dans helpers.php,
et les requêtes SQL dans posts_repo.php. Tes pages deviennent lisibles.
2) Table posts (exemple réel)
On part sur une table posts classique :
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
INDEX (status),
INDEX (created_at)
);
✅ Tu peux ajouter plus tard : user_id, category_id, slug, image…
3) Connexion PDO (db.php) — base solide
<?php
// db.php
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) return $pdo;
$host = "127.0.0.1";
$db = "myapp";
$user = "root";
$pass = "";
$charset = "utf8mb4";
$dsn = "mysql:host={$host};dbname={$db};charset={$charset}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $user, $pass, $options);
return $pdo;
} catch (PDOException $e) {
error_log("DB Error: " . $e->getMessage());
http_response_code(500);
die("Erreur serveur (DB).");
}
}
?>
4) Helpers essentiels : validation + flash + CSRF (helpers.php)
Pour un CRUD propre, tu as besoin de 3 choses :
- Validation : empêcher les champs vides / trop longs
- Flash messages : afficher “créé”, “modifié”, “supprimé”
- CSRF : empêcher la suppression via site externe
<?php
// helpers.php
session_start();
function e(string $str): string {
return htmlspecialchars($str, ENT_QUOTES, "UTF-8");
}
function flash(string $key, ?string $value = null): ?string
{
if ($value !== null) {
$_SESSION["flash"][$key] = $value;
return null;
}
$msg = $_SESSION["flash"][$key] ?? null;
if ($msg !== null) unset($_SESSION["flash"][$key]);
return $msg;
}
function csrf_token(): string
{
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
return $_SESSION["csrf_token"];
}
function csrf_check(string $token): void
{
$sessionToken = $_SESSION["csrf_token"] ?? "";
if (!$sessionToken || !hash_equals($sessionToken, $token)) {
http_response_code(403);
die("CSRF token invalide.");
}
}
function validate_post(array $data): array
{
$errors = [];
$title = trim($data["title"] ?? "");
$body = trim($data["body"] ?? "");
$status = $data["status"] ?? "draft";
if ($title === "") $errors["title"] = "Titre obligatoire.";
if (mb_strlen($title) > 255) $errors["title"] = "Titre trop long (255 max).";
if ($body === "") $errors["body"] = "Contenu obligatoire.";
if (!in_array($status, ["draft","published"], true)) {
$errors["status"] = "Statut invalide.";
}
return $errors;
}
?>
✅ Avec ça, toutes tes pages CRUD deviennent simples et cohérentes.
5) Repository : requêtes SQL propres (posts_repo.php)
On met toutes les requêtes liées à posts dans un seul fichier. C’est propre et réutilisable.
<?php
// posts_repo.php
require_once __DIR__ . "/db.php";
function posts_paginated(int $page, int $perPage, string $q = ""): array
{
$pdo = db();
$page = max(1, $page);
$perPage = max(1, min(100, $perPage));
$offset = ($page - 1) * $perPage;
if ($q !== "") {
$stmt = $pdo->prepare("
SELECT id, title, status, created_at
FROM posts
WHERE title LIKE :q
ORDER BY id DESC
LIMIT :lim OFFSET :off
");
$stmt->bindValue(":q", "%" . $q . "%", PDO::PARAM_STR);
} else {
$stmt = $pdo->prepare("
SELECT id, title, status, created_at
FROM posts
ORDER BY id DESC
LIMIT :lim OFFSET :off
");
}
$stmt->bindValue(":lim", $perPage, PDO::PARAM_INT);
$stmt->bindValue(":off", $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function posts_count(string $q = ""): int
{
$pdo = db();
if ($q !== "") {
$stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM posts WHERE title LIKE :q");
$stmt->execute(["q" => "%" . $q . "%"]);
} else {
$stmt = $pdo->query("SELECT COUNT(*) AS c FROM posts");
}
$row = $stmt->fetch();
return (int)($row["c"] ?? 0);
}
function post_find(int $id): ?array
{
$stmt = db()->prepare("SELECT * FROM posts WHERE id = :id LIMIT 1");
$stmt->execute(["id" => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
function post_create(string $title, string $body, string $status): int
{
$stmt = db()->prepare("
INSERT INTO posts (title, body, status, created_at)
VALUES (:t, :b, :s, NOW())
");
$stmt->execute(["t" => $title, "b" => $body, "s" => $status]);
return (int)db()->lastInsertId();
}
function post_update(int $id, string $title, string $body, string $status): int
{
$stmt = db()->prepare("
UPDATE posts
SET title = :t, body = :b, status = :s, updated_at = NOW()
WHERE id = :id
");
$stmt->execute(["t" => $title, "b" => $body, "s" => $status, "id" => $id]);
return $stmt->rowCount();
}
function post_delete(int $id): int
{
$stmt = db()->prepare("DELETE FROM posts WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->rowCount();
}
?>
Tu as maintenant toutes les briques d’un CRUD complet. On passe aux pages : list/create/show/edit/delete.
6) READ : Liste (index.php) + recherche + pagination
La page la plus “réelle” : liste paginée, recherche, actions.
<?php
// posts/index.php
require_once __DIR__ . "/../helpers.php";
require_once __DIR__ . "/../posts_repo.php";
$page = max(1, (int)($_GET["page"] ?? 1));
$q = trim($_GET["q"] ?? "");
$perPage = 10;
$total = posts_count($q);
$rows = posts_paginated($page, $perPage, $q);
$flash = flash("success");
?>
<div style="font-family:Tahoma; max-width:980px; margin:0 auto;">
<h2>Posts</h2>
<?php if ($flash): ?>
<div style="padding:12px;background:#ecfeff;border-left:4px solid #06b6d4;margin:12px 0;">
<?= e($flash) ?>
</div>
<?php endif; ?>
<form method="GET" style="margin:12px 0;">
<input name="q" value="<?= e($q) ?>" placeholder="Rechercher un titre...">
<button>Rechercher</button>
<a href="create.php" style="margin-left:10px;">+ Nouveau</a>
</form>
<table border="1" cellpadding="10" cellspacing="0" width="100%" style="border-collapse:collapse;">
<thead style="background:#e5e7eb;">
<tr>
<th>ID</th>
<th>Titre</th>
<th>Statut</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= (int)$r["id"] ?></td>
<td><?= e($r["title"]) ?></td>
<td><?= e($r["status"]) ?></td>
<td><?= e($r["created_at"]) ?></td>
<td>
<a href="show.php?id=<?= (int)$r["id"] ?>">Voir</a> |
<a href="edit.php?id=<?= (int)$r["id"] ?>">Modifier</a> |
<form action="delete.php" method="POST" style="display:inline;" onsubmit="return confirm('Supprimer ?');">
<input type="hidden" name="id" value="<?= (int)$r["id"] ?>">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<button>Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$pages = (int)ceil($total / $perPage);
if ($pages < 1) $pages = 1;
?>
<div style="margin:14px 0;">
<?php for ($p=1; $p<=$pages; $p++): ?>
<a href="?page=<?= $p ?>&q=<?= urlencode($q) ?>"
style="margin-right:8px; <?= $p===$page ? 'font-weight:bold;' : '' ?>">
<?= $p ?>
</a>
<?php endfor; ?>
</div>
</div>
✅ Ici tu as déjà : READ + recherche + pagination + actions (show/edit/delete).
7) CREATE : create.php (form + insert)
<?php
// posts/create.php
require_once __DIR__ . "/../helpers.php";
require_once __DIR__ . "/../posts_repo.php";
$errors = [];
$old = ["title"=>"", "body"=>"", "status"=>"draft"];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrf_check($_POST["csrf"] ?? "");
$errors = validate_post($_POST);
$old["title"] = trim($_POST["title"] ?? "");
$old["body"] = trim($_POST["body"] ?? "");
$old["status"] = $_POST["status"] ?? "draft";
if (!$errors) {
post_create($old["title"], $old["body"], $old["status"]);
flash("success", "Post créé avec succès.");
header("Location: index.php");
exit;
}
}
?>
<div style="font-family:Tahoma; max-width:900px; margin:0 auto;">
<h2>Créer un post</h2>
<form method="POST">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<label>Titre</label><br>
<input name="title" value="<?= e($old["title"]) ?>" style="width:100%;">
<div style="color:#b91c1c;"><?= e($errors["title"] ?? "") ?></div>
<br><br>
<label>Contenu</label><br>
<textarea name="body" rows="8" style="width:100%;"><?= e($old["body"]) ?></textarea>
<div style="color:#b91c1c;"><?= e($errors["body"] ?? "") ?></div>
<br><br>
<label>Statut</label><br>
<select name="status">
<option value="draft" <?= $old["status"]==="draft" ? "selected" : "" ?>>Draft</option>
<option value="published" <?= $old["status"]==="published" ? "selected" : "" ?>>Published</option>
</select>
<div style="color:#b91c1c;"><?= e($errors["status"] ?? "") ?></div>
<br><br>
<button>Enregistrer</button>
<a href="index.php" style="margin-left:10px;">Retour</a>
</form>
</div>
8) READ : show.php (afficher une ligne)
<?php
// posts/show.php
require_once __DIR__ . "/../helpers.php";
require_once __DIR__ . "/../posts_repo.php";
$id = (int)($_GET["id"] ?? 0);
$post = $id > 0 ? post_find($id) : null;
if (!$post) {
http_response_code(404);
die("Post introuvable.");
}
?>
<div style="font-family:Tahoma; max-width:900px; margin:0 auto;">
<h2><?= e($post["title"]) ?></h2>
<div style="color:#555; font-size:14px;">
Statut: <strong><?= e($post["status"]) ?></strong> —
Créé: <?= e($post["created_at"]) ?>
</div>
<hr style="margin:18px 0;">
<div><?= nl2br(e($post["body"])) ?></div>
<hr style="margin:18px 0;">
<a href="edit.php?id=<?= (int)$post["id"] ?>">Modifier</a> |
<a href="index.php">Retour</a>
</div>
9) UPDATE : edit.php (form + update)
<?php
// posts/edit.php
require_once __DIR__ . "/../helpers.php";
require_once __DIR__ . "/../posts_repo.php";
$id = (int)($_GET["id"] ?? 0);
$post = $id > 0 ? post_find($id) : null;
if (!$post) {
http_response_code(404);
die("Post introuvable.");
}
$errors = [];
$old = ["title"=>$post["title"], "body"=>$post["body"], "status"=>$post["status"]];
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrf_check($_POST["csrf"] ?? "");
$errors = validate_post($_POST);
$old["title"] = trim($_POST["title"] ?? "");
$old["body"] = trim($_POST["body"] ?? "");
$old["status"] = $_POST["status"] ?? "draft";
if (!$errors) {
post_update($id, $old["title"], $old["body"], $old["status"]);
flash("success", "Post modifié avec succès.");
header("Location: index.php");
exit;
}
}
?>
<div style="font-family:Tahoma; max-width:900px; margin:0 auto;">
<h2>Modifier le post #<?= (int)$id ?></h2>
<form method="POST">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<label>Titre</label><br>
<input name="title" value="<?= e($old["title"]) ?>" style="width:100%;">
<div style="color:#b91c1c;"><?= e($errors["title"] ?? "") ?></div>
<br><br>
<label>Contenu</label><br>
<textarea name="body" rows="8" style="width:100%;"><?= e($old["body"]) ?></textarea>
<div style="color:#b91c1c;"><?= e($errors["body"] ?? "") ?></div>
<br><br>
<label>Statut</label><br>
<select name="status">
<option value="draft" <?= $old["status"]==="draft" ? "selected" : "" ?>>Draft</option>
<option value="published" <?= $old["status"]==="published" ? "selected" : "" ?>>Published</option>
</select>
<br><br>
<button>Enregistrer</button>
<a href="index.php" style="margin-left:10px;">Retour</a>
</form>
</div>
10) DELETE : delete.php (suppression sécurisée)
Suppression = action sensible → toujours en POST + CSRF + confirmation.
<?php
// posts/delete.php
require_once __DIR__ . "/../helpers.php";
require_once __DIR__ . "/../posts_repo.php";
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
http_response_code(405);
die("Méthode non autorisée.");
}
csrf_check($_POST["csrf"] ?? "");
$id = (int)($_POST["id"] ?? 0);
if ($id <= 0) die("ID invalide.");
$deleted = post_delete($id);
if ($deleted > 0) {
flash("success", "Post supprimé.");
} else {
flash("success", "Aucune ligne supprimée (déjà supprimé ?).");
}
header("Location: index.php");
exit;
?>
11) Bonnes pratiques CRUD (checklist)
- PDO + prepare partout (jamais de concat SQL)
- Validation avant insert/update
- POST-Redirect-GET pour éviter double soumission
- Flash messages pour UX
- CSRF sur actions sensibles (create/update/delete)
- 404 si ressource introuvable
- Pagination sur les listes
✅ Si tu appliques cette checklist, ton CRUD est déjà “niveau production” pour beaucoup de projets.
12) Exercices (pour consolider)
- Ajouter un champ
slugà posts + le générer automatiquement. - Ajouter un upload d’image (Partie 15) et stocker le chemin en DB.
- Créer une table
categorieset associer un post à une catégorie. - Ajouter une recherche par
title+body(LIKE sur deux colonnes). - Créer une page “Mes posts” filtrée par
user_id(avec session).
Conclusion
Tu viens de construire un CRUD complet, sécurisé et organisé. C’est exactement ce qu’on retrouve dans les projets réels : des pages propres, des requêtes préparées, des validations, et des protections. Tu peux maintenant adapter ce modèle à n’importe quel module de ton site.
Partie 22 logique : authentification complète (register/login + rôles + password_hash) ou sécurité web (XSS/CSRF/SQLi + checklist globale).