Objectif du TP
- À la fin de ce TP, l'apprenant sera capable de :
- Mettre en place une CRUD complète (Create, Read, Update, Delete) sans framework.
- Utiliser PDO (PHP Data Objects) pour interagir de manière sécurisée avec MySQL.
- Structurer une application PHP proprement selon le pattern MVC (Modèle – Vue – Contrôleur).
- Créer un modèle
Userrelié à une table MySQL. - Utiliser un contrôleur pour gérer les actions CRUD.
- Afficher les données dans des vues propres et sécurisées.
- Comprendre le cheminement complet d'une requête dans une architecture MVC.
-
Rappel – Qu'est-ce que le MVC ?
Le pattern MVC sépare l'application en trois couches distinctes :- Modèle (Model): accès aux données, SQL, logique métier.
- Vue (View): affichage HTML, aucune logique métier.
- Contrôleur (Controller): réception des requêtes, appel du modèle, choix de la vue.
- À la fin de ce TP, l'apprenant sera capable de :
Création de la base de données
- Nous allons créer une base de données
mvc_appcontenant une tableuserspour notre CRUD. - Exécutez ce script SQL dans phpMyAdmin ou votre terminal MySQL :
- Remarque : L'attribut
UNIQUEsuremailempêche les doublons au niveau de la base de données.
-- Création de la base de données CREATE DATABASE mvc_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- Sélection de la base USE mvc_app; -- Création de la table users CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, -- Identifiant unique auto-incrémenté name VARCHAR(100) NOT NULL, -- Nom de l'utilisateur (obligatoire) email VARCHAR(150) NOT NULL UNIQUE -- Email unique (obligatoire) ); -- Insertion de données de test INSERT INTO users (name, email) VALUES ('Alice Martin', 'alice@example.com'), ('Bob Dupont', 'bob@example.com');- Nous allons créer une base de données
Architecture complète du projet MVC (CRUD Users)
- Voici l'arborescence complète du projet à créer manuellement:
- ? Tous ces fichiers doivent être créés manuellement.
- Principe du Front Controller : toutes les requêtes HTTP passent par
public/index.php, qui délègue ensuite au bon contrôleur via le routeur.
mvc-users/ │ ├── app/ ← Code applicatif (MVC) │ ├── controllers/ │ │ └── UserController.php ← Contrôleur : gère les actions CRUD │ │ │ ├── models/ │ │ └── User.php ← Modèle : requêtes SQL sur la table users │ │ │ └── views/ │ └── users/ │ ├── index.php ← Vue : liste des utilisateurs │ ├── create.php ← Vue : formulaire d'ajout │ └── edit.php ← Vue : formulaire de modification │ ├── core/ ← Classes de base du framework maison │ ├── Model.php ← Connexion PDO partagée │ ├── Controller.php ← Classe parente des contrôleurs │ └── Router.php ← Routeur URL → contrôleur/action │ ├── config/ │ └── database.php ← Paramètres de connexion BDD │ ├── routes/ │ └── web.php ← Déclaration des routes de l'application │ ├── public/ │ └── index.php ← Point d'entrée unique (Front Controller) │ ├── .htaccess ← Réécriture d'URL vers public/index.php └── README.md ← Documentation du projetFichiers
public/index.php – Point d'entrée unique (Front Controller)
- Chemin :
public/index.php - Rôle : démarre l'application MVC – c'est le seul fichier appelé directement par le navigateur.
- Toutes les requêtes passent obligatoirement par ce fichier grâce au .htaccess.
<?php // Chargement du routeur central require_once '../core/Router.php'; // Instanciation du routeur $router = new Router(); // Chargement des routes déclarées dans routes/web.php require_once '../routes/web.php'; // Démarrage du routeur : analyse l'URL et dispatche vers le bon contrôleur $router->run();- Chemin :
.htaccess – Réécriture d'URL (à la racine du projet)
- Chemin :
.htaccess(à la racine du projet) - Sert à :
- Masquer les fichiers internes (modèles, config, etc.) du navigateur.
- Forcer toutes les URLs à passer par le Front Controller MVC.
- Permettre des URLs propres comme
/usersou/users/edit/3.
- ⚠️ Prérequis : le module
mod_rewritedoit être activé dans Apache.
RewriteEngine On # Si le fichier ou dossier demandé n'existe pas physiquement sur le serveur RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d # Redirige toutes les requêtes vers public/index.php RewriteRule ^(.*)$ public/index.php [QSA,L]- Chemin :
config/database.php – Configuration de la base de données
- Chemin :
config/database.php - Bonne pratique : aucun SQL ici, uniquement la configuration. En production, ne commitez jamais ce fichier dans Git (ajoutez-le à
.gitignore).
<?php // Retourne un tableau de configuration pour la connexion PDO // Ce fichier est chargé par core/Model.php return [ 'host' => 'localhost', // Hôte MySQL (souvent localhost en dev) 'dbname' => 'mvc_app', // Nom de la base de données créée précédemment 'user' => 'root', // Utilisateur MySQL 'password' => '' // Mot de passe (vide par défaut avec XAMPP/WAMP) ];- Chemin :
core/Model.php – Classe de base PDO (parent de tous les modèles)
- Chemin :
core/Model.php - Tous les modèles héritent de cette classe via
class User extends Model. - Avantage : la connexion est instanciée une seule fois par modèle, centralisée et facile à maintenir.
<?php /** * Classe Model – Classe parente de tous les modèles de l'application. * Elle centralise la connexion PDO pour éviter de la répéter dans chaque modèle. */ class Model { // Propriété partagée : instance PDO accessible dans toutes les sous-classes protected $db; public function __construct() { // Chargement de la configuration depuis config/database.php $config = require '../config/database.php'; // Création de la connexion PDO avec le DSN MySQL $this->db = new PDO( "mysql:host={$config['host']};dbname={$config['dbname']};charset=utf8mb4", $config['user'], $config['password'] ); // Mode d'erreur : lance une exception en cas d'erreur SQL (facilite le débogage) $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Retourne les résultats sous forme de tableaux associatifs par défaut $this->db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); } }- Chemin :
core/Router.php – Routeur URL
- Chemin :
core/Router.php
<?php /** * Classe Router – Analyse l'URL et dispatche vers le bon contrôleur et la bonne action. */ class Router { // Tableau des routes enregistrées : ['METHOD /chemin' => callable] private $routes = []; /** * Enregistre une route GET. * @param string $path L'URL à matcher (ex: '/users') * @param callable $callback La fonction ou méthode à exécuter */ public function get($path, $callback) { $this->routes['GET ' . $path] = $callback; } /** * Enregistre une route POST. */ public function post($path, $callback) { $this->routes['POST ' . $path] = $callback; } /** * Démarre le routeur : compare l'URL courante aux routes enregistrées. */ public function run() { // Récupère la méthode HTTP (GET ou POST) $method = $_SERVER['REQUEST_METHOD']; // Récupère le chemin URL sans les paramètres de requête $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); foreach ($this->routes as $route => $callback) { // Convertit les segments dynamiques comme {id} en regex [$routeMethod, $routePath] = explode(' ', $route, 2); $pattern = preg_replace('/\{[a-z]+\}/', '([^/]+)', $routePath); $pattern = '#^' . $pattern . '$#'; if ($routeMethod === $method && preg_match($pattern, $uri, $matches)) { // Supprime le match complet, garde uniquement les paramètres array_shift($matches); // Appelle le callback avec les paramètres extraits de l'URL call_user_func_array($callback, $matches); return; } } // Aucune route correspondante → erreur 404 http_response_code(404); echo "<h1>404 – Page non trouvée</h1>"; } }- Chemin :
routes/web.php – Déclaration des routes
- Chemin :
routes/web.php
<?php // Chargement du contrôleur utilisateur require_once '../app/controllers/UserController.php'; $controller = new UserController(); // Affiche la liste des utilisateurs $router->get('/users', function() use ($controller) { $controller->index(); }); // Affiche le formulaire de création $router->get('/users/create', function() use ($controller) { $controller->create(); }); // Traite le formulaire de création (POST) $router->post('/users/store', function() use ($controller) { $controller->store(); }); // Affiche le formulaire de modification pour l'utilisateur {id} $router->get('/users/edit/{id}', function($id) use ($controller) { $controller->edit($id); }); // Traite le formulaire de modification (POST) $router->post('/users/update/{id}', function($id) use ($controller) { $controller->update($id); }); // Supprime l'utilisateur {id} $router->get('/users/delete/{id}', function($id) use ($controller) { $controller->delete($id); });- Chemin :
app/models/User.php – Modèle User (toutes les opérations SQL)
- Chemin :
app/models/User.php - ✅ Règle d'or : SQL uniquement ici – jamais de HTML dans un modèle.
- Sécurité : toutes les requêtes avec données utilisateur utilisent des requêtes préparées (
prepare+execute) pour éviter les injections SQL.
<?php require_once '../core/Model.php'; /** * Classe User – Modèle représentant la table `users`. * Contient UNIQUEMENT les opérations SQL (jamais de HTML ici). */ class User extends Model { /** * Récupère tous les utilisateurs de la table. * @return array Tableau de tous les utilisateurs */ public function getAll() { // query() suffit pour une requête sans paramètre dynamique return $this->db ->query("SELECT * FROM users ORDER BY id DESC") ->fetchAll(PDO::FETCH_ASSOC); } /** * Récupère un utilisateur par son identifiant. * @param int $id L'identifiant de l'utilisateur * @return array|false Les données de l'utilisateur ou false si non trouvé */ public function getById($id) { // prepare() + execute() = requête préparée → protège contre les injections SQL $stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?"); $stmt->execute([$id]); return $stmt->fetch(PDO::FETCH_ASSOC); } /** * Insère un nouvel utilisateur. * @param string $name Le nom de l'utilisateur * @param string $email L'adresse email * @return bool true en cas de succès */ public function create($name, $email) { $stmt = $this->db->prepare( "INSERT INTO users (name, email) VALUES (?, ?)" ); return $stmt->execute([$name, $email]); } /** * Met à jour les données d'un utilisateur existant. * @param int $id L'identifiant de l'utilisateur à modifier * @param string $name Le nouveau nom * @param string $email Le nouvel email * @return bool true en cas de succès */ public function update($id, $name, $email) { $stmt = $this->db->prepare( "UPDATE users SET name = ?, email = ? WHERE id = ?" ); // Attention à l'ordre des paramètres : name, email, puis id return $stmt->execute([$name, $email, $id]); } /** * Supprime un utilisateur par son identifiant. * @param int $id L'identifiant de l'utilisateur à supprimer * @return bool true en cas de succès */ public function delete($id) { $stmt = $this->db->prepare("DELETE FROM users WHERE id = ?"); return $stmt->execute([$id]); } }- Chemin :
app/controllers/UserController.php – Contrôleur User
- Chemin :
app/controllers/UserController.php - ✅ Bonne pratique : toujours ajouter
exit;après unheader("Location: ...")pour éviter que le script continue à s'exécuter.
<?php require_once '../app/models/User.php'; /** * Classe UserController – Contrôleur gérant toutes les actions CRUD sur les utilisateurs. * Le contrôleur fait le lien entre le modèle (données) et la vue (affichage). * Il ne contient ni SQL, ni HTML brut. */ class UserController { /** * Action index – Affiche la liste de tous les utilisateurs. * Correspond à la route : GET /users */ public function index() { $model = new User(); $users = $model->getAll(); // Récupère les données via le modèle require '../app/views/users/index.php'; // Délègue l'affichage à la vue } /** * Action create – Affiche le formulaire de création d'un utilisateur. * Correspond à la route : GET /users/create */ public function create() { require '../app/views/users/create.php'; } /** * Action store – Traite le formulaire de création (méthode POST). * Correspond à la route : POST /users/store */ public function store() { // Récupération et nettoyage des données du formulaire $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); // Validation basique avant insertion if (!empty($name) && filter_var($email, FILTER_VALIDATE_EMAIL)) { $model = new User(); $model->create($name, $email); } // Redirection vers la liste après enregistrement header("Location: /users"); exit; // Important : stoppe l'exécution après un header redirect } /** * Action edit – Affiche le formulaire de modification d'un utilisateur. * Correspond à la route : GET /users/edit/{id} * @param int $id L'identifiant de l'utilisateur à modifier */ public function edit($id) { $model = new User(); $data = $model->getById($id); // Récupère les données actuelles de l'utilisateur if (!$data) { // Si l'utilisateur n'existe pas, redirection vers la liste header("Location: /users"); exit; } require '../app/views/users/edit.php'; // Passe $data à la vue } /** * Action update – Traite le formulaire de modification (méthode POST). * Correspond à la route : POST /users/update/{id} * @param int $id L'identifiant de l'utilisateur à mettre à jour */ public function update($id) { $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); if (!empty($name) && filter_var($email, FILTER_VALIDATE_EMAIL)) { $model = new User(); $model->update($id, $name, $email); } header("Location: /users"); exit; } /** * Action delete – Supprime un utilisateur. * Correspond à la route : GET /users/delete/{id} * @param int $id L'identifiant de l'utilisateur à supprimer */ public function delete($id) { $model = new User(); $model->delete($id); header("Location: /users"); exit; } }- Chemin :
Vue : Liste des utilisateurs
- Chemin :
app/views/users/index.php - La variable
$usersest transmise par le contrôleur. La vue ne fait qu'afficher les données. - Sécurité :
htmlspecialchars()convertit les caractères spéciaux HTML (<,>, etc.) en entités HTML pour éviter les attaques XSS. - Sécurité :
(int)$user['id']force le transtypage en entier pour éviter toute injection dans l'URL.
<!-- Lien vers le formulaire de création --> <a href="/users/create">➕ Ajouter un utilisateur</a> <table border="1"> <thead> <tr> <th>ID</th> <th>Nom</th> <th>Email</th> <th>Actions</th> </tr> </thead> <tbody> <?php if (empty($users)): ?> <!-- Cas où la table est vide --> <tr> <td colspan="4">Aucun utilisateur trouvé.</td> </tr> <?php else: ?> <?php foreach ($users as $user): ?> <tr> <td><?= $user['id'] ?></td> <!-- htmlspecialchars() protège contre les attaques XSS --> <td><?= htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8') ?></td> <td><?= htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8') ?></td> <td> <a href="/users/edit/<?= (int)$user['id'] ?>">✏️ Modifier</a> <!-- Idéalement, la suppression devrait passer par un formulaire POST --> <a href="/users/delete/<?= (int)$user['id'] ?>" onclick="return confirm('Confirmer la suppression ?')"> ?️ Supprimer </a> </td> </tr> <?php endforeach; ?> <?php endif; ?> </tbody> </table>- Chemin :
Vue : Création d'un utilisateur
- Chemin :
app/views/users/create.php - Note : les attributs
requiredettype="email"offrent une première validation côté navigateur. La validation côté serveur (dans le contrôleur) reste indispensable.
<h2>Ajouter un utilisateur</h2> <!-- action pointe vers la route POST /users/store --> <form method="post" action="/users/store"> <label for="name">Nom :</label><br> <input type="text" id="name" name="name" placeholder="Ex : Alice Martin" required maxlength="100"> <br><br> <label for="email">Email :</label><br> <input type="email" id="email" name="email" placeholder="Ex : alice@example.com" required maxlength="150"> <br><br> <button type="submit">? Enregistrer</button> <a href="/users">Annuler</a> </form>- Chemin :
Vue : Modification d'un utilisateur
- Chemin :
app/views/users/edit.php - La variable
$datacontient les informations actuelles de l'utilisateur, transmises par le contrôleur.
<h2>Modifier l'utilisateur</h2> <!-- L'id est intégré dans l'URL de l'action --> <form method="post" action="/users/update/<?= (int)$data['id'] ?>"> <label for="name">Nom :</label><br> <!-- value pré-remplit le champ avec la valeur actuelle – htmlspecialchars protège contre le XSS --> <input type="text" id="name" name="name" value="<?= htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8') ?>" required maxlength="100"> <br><br> <label for="email">Email :</label><br> <input type="email" id="email" name="email" value="<?= htmlspecialchars($data['email'], ENT_QUOTES, 'UTF-8') ?>" required maxlength="150"> <br><br> <button type="submit">✏️ Mettre à jour</button> <a href="/users">Annuler</a> </form>- Chemin :
Schéma du flux MVC – Comment fonctionne une requête ?
- Voici le cheminement complet d'une requête dans notre application MVC :
Navigateur │ │ GET /users ▼ .htaccess │ Redirige tout vers public/index.php ▼ public/index.php (Front Controller) │ Instancie le Router et charge les routes ▼ core/Router.php │ Analyse l'URL, trouve la route correspondante ▼ app/controllers/UserController.php → action index() │ Appelle le modèle pour récupérer les données ▼ app/models/User.php → getAll() │ Exécute la requête SQL via PDO │ Retourne un tableau de résultats ▼ UserController::index() │ Passe les données ($users) à la vue ▼ app/views/users/index.php │ Génère le HTML avec les données ▼ Navigateur (affichage de la page)À retenir
- ✅ Le Modèle contient tout le SQL – rien d'autre.
- ✅ Le Contrôleur gère la logique : reçoit la requête, appelle le modèle, choisit la vue.
- ✅ La Vue affiche les données – aucune logique métier, aucun SQL.
- ✅ Toujours utiliser des requêtes préparées (PDO
prepare+execute) pour sécuriser les requêtes SQL. - ✅ Toujours utiliser
htmlspecialchars()dans les vues pour protéger contre le XSS. - ✅ Toujours ajouter
exit;aprèsheader("Location: ..."). - ❌ Pas de SQL dans les vues.
- ❌ Pas de HTML dans les modèles.
- ❌ Pas de logique métier complexe dans les vues.
- ✅ MVC = structure claire, maintenable et professionnelle.