Architecture MVC en PHP

Structurez vos applications PHP complexes grâce au pattern Model-View-Controller.

Introduction au pattern MVC

MVC signifie Modèle-Vue-Contrôleur (Model-View-Controller). C'est un design pattern architectural qui sépare une application en trois composants principaux :

Modèle (Model)

Gère les données et la logique métier de l'application

Contrôleur (Controller)

Traite les requêtes utilisateur et coordonne l'application

Vue (View)

Gère l'affichage et l'interface utilisateur

Cette séparation claire permet une meilleure organisation du code, facilite la maintenance et favorise la réutilisation. Le pattern MVC est devenu la norme pour le développement d'applications web complexes.

Histoire du MVC : Né dans les années 1970 pour le langage Smalltalk, le pattern MVC est aujourd'hui l'un des designs patterns les plus utilisés dans le développement web. Il a fait ses preuves dans de nombreux frameworks comme Laravel, Symfony, CodeIgniter, mais aussi dans d'autres environnements comme Django (Python) ou Ruby on Rails.

Détails des trois composants

Le Modèle (Model)

Le modèle est responsable de la gestion des données de l'application. Il encapsule la logique métier et l'accès aux données, que ce soit une base de données, une API ou un autre service. Les principales responsabilités du modèle sont :

Exemple d'un modèle simple

// models/ArticleModel.php
class ArticleModel {
    private $db;
    
    public function __construct($dbConnection) {
        $this->db = $dbConnection;
    }
    
    public function getAllArticles() {
        $query = "SELECT * FROM articles ORDER BY created_at DESC";
        $stmt = $this->db->prepare($query);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
      public function getArticleById($id) {
        $query = "SELECT * FROM articles WHERE id = :id";
        $stmt = $this->db->prepare($query);
        $stmt->bindParam(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
    
    public function createArticle($title, $content, $userId) {
        $query = "INSERT INTO articles (title, content, user_id, created_at) 
                VALUES (:title, :content, :user_id, NOW())";
        $stmt = $this->db->prepare($query);
        $stmt->bindParam(':title', $title, PDO::PARAM_STR);
        $stmt->bindParam(':content', $content, PDO::PARAM_STR);
        $stmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
        return $stmt->execute();
    }
}

Explication : Ce modèle ArticleModel gère tout ce qui concerne les articles dans notre application. Il contient des méthodes pour récupérer tous les articles, obtenir un article spécifique par son ID, et créer un nouvel article. Notez que le modèle ne fait que gérer les données - il ne s'occupe pas de l'affichage ni des décisions sur ce qu'il faut afficher.

La Vue (View)

La vue est responsable de l'affichage des données à l'utilisateur. Elle représente l'interface utilisateur de l'application. Les principales responsabilités de la vue sont :

Exemple d'une vue simple

<!-- views/articles/index.php -->
<!DOCTYPE html>
<html>
<head>
    <title>Liste des articles</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <div class="container">
        <h1>Nos articles</h1>
        
        <?php if(empty($articles)): ?>
            <p>Aucun article disponible pour le moment.</p>
        <?php else: ?>
            <div class="articles">
                <?php foreach($articles as $article): ?>
                    <div class="article">                        <h2><a href="/article/view/<?= $article['id'] ?>">
                            <?= htmlspecialchars($article[?>
                        </a></h2>
                        <p class="date">Publié le <?= date('d/m/Y', strtotime($article[?></p>
                        <p><?= substr(htmlspecialchars($article['content']), 0, 200) ?>...</p>
                        <a href="/article/view/<?= $article['id'] ?>" class="read-more">Lire la suite</a>
                    </div>
                <?php endforeach; ?>
            </div>
        <?php endif; ?>
    </div>
</body>
</html>

Explication : Cette vue affiche une liste d'articles. Elle reçoit un tableau $articles du contrôleur et boucle à travers chaque article pour l'afficher. Notez l'utilisation de htmlspecialchars() pour échapper les données et éviter les failles XSS. La vue ne contient que la logique minimale nécessaire à l'affichage (comme la boucle foreach et la condition if).

Le Contrôleur (Controller)

Le contrôleur sert d'intermédiaire entre le modèle et la vue. Il répond aux actions de l'utilisateur, interagit avec le modèle pour obtenir ou mettre à jour les données, puis sélectionne la vue appropriée. Les principales responsabilités du contrôleur sont :

Exemple d'un contrôleur simple

// controllers/ArticleController.php
class ArticleController {
    private $articleModel;
    
    public function __construct($dbConnection) {
        // Initialiser le modèle avec la connexion à la BD
        $this->articleModel = new ArticleModel($dbConnection);
    }
    
    // Afficher la liste des articles
    public function index() {
        // Récupérer tous les articles via le modèle
        $articles = $this->articleModel->getAllArticles();
        
        // Charger la vue avec les données
        require_once 'views/articles/index.php';
    }
      // Afficher un article spécifique
    public function view($id) {
        // Récupérer l'article par son ID
        $article = $this->articleModel->getArticleById($id);
        
        if (!$article) {
            // Article non trouvé, afficher une erreur
            header("HTTP/1.0 404 Not Found");
            require_once 'views/errors/404.php';
            return;
        }
        
        // Charger la vue de l'article avec les données
        require_once 'views/articles/view.php';
    }
    
    // Afficher le formulaire de création d'article
    public function create() {
        // Vérifier si l'utilisateur est connecté
        if (!isset($_SESSION['user_id'])) {
            // Rediriger vers la page de connexion
            header('Location: /login');
            exit;
        }
        
        // Charger la vue du formulaire
        require_once 'views/articles/create.php';
    }
    
    // Traiter la soumission du formulaire de création
    public function store() {
        // Vérifier si l'utilisateur est connecté
        if (!isset($_SESSION['user_id'])) {
            header('Location: /login');
            exit;
        }
        
        // Valider et nettoyer les données
        $title = trim($_POST['title'] ?? '');
        $content = trim($_POST['content'] ?? '');
        $userId = $_SESSION['user_id'];
        
        // Validation basique
        $errors = [];
        if (empty($title)) {
            $errors['title'] = "Le titre est obligatoire.";
        }
        if (empty($content)) {
            $errors['content'] = "Le contenu est obligatoire.";
        }
        
        // S'il y a des erreurs, réafficher le formulaire
        if (!empty($errors)) {
            $_SESSION['errors'] = $errors;
            $_SESSION['form_data'] = ['title' => $title, 'content' => $content];
            header('Location: /article/create');
            exit;
        }
        
        // Créer l'article via le modèle
        if ($this->articleModel->createArticle($title, $content, $userId)) {
            $_SESSION['success'] = "Article créé avec succès!";
            header('Location: /articles');
        } else {
            $_SESSION['errors'] = ['db' => "Erreur lors de la création de l'article."];
            header('Location: /article/create');
        }
        exit;
    }
}

Explication : Ce contrôleur gère toutes les actions liées aux articles. La méthode index() affiche tous les articles, view() affiche un article spécifique, create() montre le formulaire de création, et store() traite la soumission du formulaire. Notez que le contrôleur coordonne le flux : il récupère des données du modèle et les transmet à la vue.

Flux d'une requête dans l'architecture MVC

Voici comment une requête typique traverse une application MVC :

  1. 1. L'utilisateur interagit avec l'interface (ex: clique sur un lien "Voir l'article")
  2. 2. La requête arrive au point d'entrée (généralement index.php)
  3. 3. Le routeur analyse l'URL et détermine quel contrôleur et quelle action appeler
  4. 4. Le contrôleur est instancié et la méthode appropriée est appelée
  5. 5. Le contrôleur interagit avec le modèle pour récupérer ou manipuler des données
  6. 6. Le modèle effectue des opérations sur les données (requêtes SQL, validations, etc.)
  7. 7. Le modèle renvoie les données au contrôleur
  8. 8. Le contrôleur prépare les données pour la vue et sélectionne la vue appropriée
  9. 9. La vue reçoit les données et génère le HTML
  10. 10. Le HTML final est renvoyé au navigateur de l'utilisateur
Flux d'une requête MVC

Diagramme du flux d'une requête dans l'architecture MVC

Pourquoi utiliser MVC ?

Structure d'un projet MVC en PHP

Une structure bien organisée est essentielle pour un projet MVC. Voici une structure recommandée :

my-project/ ├── app/ # Code de l'application │ ├── controllers/ # Contrôleurs │ ├── models/ # Modèles │ ├── views/ # Vues │ │ ├── layouts/ # Templates principaux │ │ └── partials/ # Éléments de vues réutilisables │ └── helpers/ # Fonctions d'assistance ├── config/ # Configuration (BD, environnement, etc.) ├── public/ # Point d'entrée et fichiers publics │ ├── css/ # Feuilles de style │ ├── js/ # JavaScript │ ├── images/ # Images │ └── index.php # Point d'entrée unique (Front Controller) ├── routes/ # Définition des routes ├── vendor/ # Bibliothèques externes (via Composer) └── .htaccess # Règles de réécriture d'URL

Explication : Cette structure sépare clairement les différentes parties de l'application. Le dossier app contient tout le code propre à l'application, tandis que public est le seul dossier accessible directement par le navigateur, ce qui augmente la sécurité.

Le routeur et le Front Controller

Dans une architecture MVC, le routeur est responsable de diriger les requêtes HTTP vers les contrôleurs appropriés. Le Front Controller est un point d'entrée unique qui initialise l'application et utilise le routeur pour traiter les requêtes.

Front Controller (Point d'entrée unique)

Exemple de Front Controller - public/index.php

<?php
// Définir le chemin de base
define('ROOT', dirname(__DIR__));

// Chargement de l'autoloader (si vous utilisez Composer)
require_once ROOT . '/vendor/autoload.php';

// Chargement des configurations
require_once ROOT . '/config/config.php';
require_once ROOT . '/config/database.php';
require_once ROOT . '/app/helpers/functions.php';

// Initialiser la session
session_start();

// Créer la connexion à la base de données
$db = new PDO(
    "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8",
    DB_USER,
    DB_PASS,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

// Instancier et exécuter le routeur
$router = new Router($db);
$router->dispatch();
?>

Explication : Le Front Controller est le premier fichier exécuté pour chaque requête. Il initialise l'environnement de l'application (configuration, base de données, sessions), puis instancie le routeur qui va s'occuper de diriger la requête vers le bon contrôleur. C'est un point central qui simplifie la gestion des requêtes.

Implémentation d'un routeur

Exemple d'un routeur simple - app/Router.php

<?php
class Router {
    private $routes = [];
    private $db;
    
    public function __construct($dbConnection) {
        $this->db = $dbConnection;
        
        // Définir les routes (format: 'URL' => ['Contrôleur', 'méthode'])
        $this->routes = [
            '/' => ['HomeController', 'index'],
            '/articles' => ['ArticleController', 'index'],
            '/article/view' => ['ArticleController', 'view'],
            '/article/create' => ['ArticleController', 'create'],
            '/article/store' => ['ArticleController', 'store'],
            '/login' => ['AuthController', 'loginForm'],            '/login/process' => ['AuthController', 'login'],
            '/logout' => ['AuthController', 'logout'],
            // Autres routes...
        ];
    }
    
    public function dispatch() {
        // Récupérer l'URL demandée
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        
        // Vérifier si la route existe
        if (array_key_exists($uri, $this->routes)) {
            $controller = $this->routes[$uri][0];
            $method = $this->routes[$uri][1];
            
            // Charger le contrôleur
            require_once ROOT . "/app/controllers/$controller.php";
            
            // Instancier le contrôleur avec la connexion à la BD
            $controllerInstance = new $controller($this->db);
            
            // Appeler la méthode avec les paramètres éventuels
            if (isset($_GET['id'])) {
                $controllerInstance->$method($_GET['id']);
            } else {
                $controllerInstance->$method();
            }
        } else {
            // Route non trouvée (erreur 404)
            header("HTTP/1.0 404 Not Found");
            require_once ROOT . '/app/views/errors/404.php';
        }
    }
}
?>

Explication : Ce routeur simple utilise un tableau pour définir les routes. Pour chaque route, il spécifie quel contrôleur et quelle méthode appeler. La méthode dispatch() analyse l'URL demandée, charge le contrôleur approprié et appelle la méthode correspondante. Si la route n'existe pas, il affiche une page d'erreur 404.

Routeur avec paramètres dynamiques

Pour un routeur plus avancé, on peut gérer des URL dynamiques comme /article/view/42 où "42" est l'ID de l'article :

Exemple de routeur avancé

<?php
class AdvancedRouter {
    private $routes = [];
    private $db;
    
    public function __construct($dbConnection) {
        $this->db = $dbConnection;
    }
    
    // Méthodes pour définir les routes
    public function get($path, $callback) {
        $this->routes['GET'][$path] = $callback;
    }
    
    public function post($path, $callback) {
        $this->routes['POST'][$path] = $callback;
    }
      // Gestion des URL dynamiques et des paramètres
    public function dispatch() {
        // Obtenir la méthode HTTP (GET, POST, etc.)
        $method = $_SERVER['REQUEST_METHOD'];
        
        // Obtenir le chemin de l'URL
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        
        // Chercher une correspondance dans nos routes
        foreach ($this->routes[$method] ?? [] as $route => $callback) {
            // Transformer les paramètres dynamiques (:id) en expression régulière
            $pattern = preg_replace('#:[a-zA-Z0-9]+#', '([a-zA-Z0-9]+)', $route);
            $pattern = "#^$pattern$#";
            
            if (preg_match($pattern, $uri, $matches)) {
                array_shift($matches); // Enlever la première correspondance (l'URL complète)
                
                // Charger et instancier le contrôleur
                if (is_array($callback)) {
                    $controllerName = $callback[0];
                    $methodName = $callback[1];
                    
                    require_once ROOT . "/app/controllers/$controllerName.php";
                    $controller = new $controllerName($this->db);
                    
                    // Appeler la méthode avec les paramètres extraits de l'URL
                    call_user_func_array([$controller, $methodName], $matches);
                } else {
                    // Si c'est une fonction anonyme
                    call_user_func_array($callback, $matches);
                }
                
                return;
            }
        }
        
        // Si aucune route ne correspond, afficher une erreur 404
        header("HTTP/1.0 404 Not Found");
        require_once ROOT . '/app/views/errors/404.php';
    }
}

// Exemple d'utilisation:
$router = new AdvancedRouter($db);

$router->get('/', ['HomeController', 'index']);
$router->get('/articles', ['ArticleController', 'index']);
$router->get('/article/view/:id', ['ArticleController', 'view']);
$router->get('/article/create', ['ArticleController', 'create']);
$router->post('/article/store', ['ArticleController', 'store']);
?>

Explication : Ce routeur plus avancé permet de définir des routes avec des paramètres dynamiques comme :id. Il utilise les expressions régulières pour faire correspondre les URL demandées aux modèles de routes définis. Il distingue également les méthodes HTTP (GET, POST), ce qui permet d'avoir différentes actions pour la même URL selon la méthode utilisée.

Pour des projets complexes : Si votre application devient plus grande, envisagez d'utiliser un routeur existant comme celui de Symfony (symfony/routing) ou FastRoute de Nikic, au lieu de réinventer la roue.

Base de données et modèles avancés

Dans une application plus complexe, vous aurez besoin de modèles plus sophistiqués pour gérer l'interaction avec la base de données. Voici un exemple de modèle de base avec une classe abstraite :

Modèle de base abstrait - app/models/Model.php

<?php
abstract class Model {
    protected $db;
    protected $table;
    protected $primaryKey = 'id';
    
    public function __construct($dbConnection) {
        $this->db = $dbConnection;
    }
    
    // Méthodes CRUD de base
    
    // Récupérer tous les enregistrements
    public function all() {
        $stmt = $this->db->prepare("SELECT * FROM {$this->table}");
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
      // Trouver un enregistrement par son id
    public function find($id) {
        $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id");
        $stmt->bindParam(':id', $id);
        $stmt->execute();
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
    
    // Créer un nouvel enregistrement
    public function create($data) {
        // Construire la requête dynamiquement
        $fields = array_keys($data);
        $fieldsList = implode(', ', $fields);
        $placeholders = ':' . implode(', :', $fields);
        
        $sql = "INSERT INTO {$this->table} ($fieldsList) VALUES ($placeholders)";
        
        $stmt = $this->db->prepare($sql);
        foreach ($data as $key => $value) {
            $stmt->bindValue(":$key", $value);
        }
        
        if ($stmt->execute()) {
            return $this->db->lastInsertId();
        }
        return false;
    }
    
    // Mettre à jour un enregistrement
    public function update($id, $data) {
        // Construire les parties SET de la requête
        $setParts = [];
        foreach (array_keys($data) as $field) {
            $setParts[] = "$field = :$field";
        }
        $setClause = implode(', ', $setParts);
        
        $sql = "UPDATE {$this->table} SET $setClause WHERE {$this->primaryKey} = :id";
        
        $stmt = $this->db->prepare($sql);
        $stmt->bindParam(':id', $id);
        
        foreach ($data as $key => $value) {
            $stmt->bindValue(":$key", $value);
        }
        
        return $stmt->execute();
    }
    
    // Supprimer un enregistrement
    public function delete($id) {
        $stmt = $this->db->prepare("DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id");
        $stmt->bindParam(':id', $id);
        return $stmt->execute();
    }
    
    // Rechercher par critères
    public function where($conditions) {
        $whereClauses = [];
        $params = [];
        
        foreach ($conditions as $field => $value) {
            $whereClauses[] = "$field = :$field";
            $params[":$field"] = $value;
        }
        
        $whereClause = implode(' AND ', $whereClauses);
        $sql = "SELECT * FROM {$this->table} WHERE $whereClause";
        
        $stmt = $this->db->prepare($sql);
        foreach ($params as $param => $value) {
            $stmt->bindValue($param, $value);
        }
        
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}
?>

Explication : Ce modèle abstrait fournit les opérations CRUD (Create, Read, Update, Delete) de base pour tous les modèles de votre application. Les modèles spécifiques à chaque table hériteront de cette classe et n'auront qu'à définir leur table et éventuellement des méthodes spécifiques.

Exemple de modèle spécifique - app/models/UserModel.php

<?php
require_once 'Model.php';

class UserModel extends Model {
    protected $table = 'users';
    
    // Méthodes spécifiques aux utilisateurs
    
    // Trouver un utilisateur par son email
    public function findByEmail($email) {
        $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE email = :email");
        $stmt->bindParam(':email', $email);
        $stmt->execute();
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
    
    // Vérifier les identifiants lors de la connexion
    public function authenticate($email, $password) {
        $user = $this->findByEmail($email);
          if ($user && password_verify($password, $user['password'])) {
            return $user;
        }
        
        return false;
    }
    
    // Créer un nouvel utilisateur avec hachage du mot de passe
    public function register($data) {
        // Hasher le mot de passe avant de l'enregistrer
        $data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
        
        return $this->create($data);
    }
    
    // Récupérer les articles d'un utilisateur (relation)
    public function getArticles($userId) {
        $stmt = $this->db->prepare("
            SELECT * FROM articles
            WHERE user_id = :userId
            ORDER BY created_at DESC
        ");
        $stmt->bindParam(':userId', $userId);
        $stmt->execute();
        
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}
?>

Explication : Ce modèle spécifique aux utilisateurs hérite du modèle de base et ajoute des méthodes spécifiques comme l'authentification, l'enregistrement avec hachage de mot de passe, et une méthode pour récupérer les articles associés à un utilisateur (relation one-to-many).

Templates et systèmes de vues

Pour améliorer la structure et la réutilisabilité de vos vues, vous pouvez implémenter un système de templates simple ou utiliser une bibliothèque existante comme Twig.

Système de templates maison

Classe View simple - app/helpers/View.php

<?php
class View {
    // Chemin de base pour les vues
    private static $viewPath = ROOT . '/app/views/';
    
    /**
     * Affiche une vue
     * @param string $view Nom de la vue
     * @param array $data Données à passer à la vue
     */
    public static function render($view, $data = []) {
        // Extraire les données pour les rendre disponibles comme variables locales
        extract($data);
        
        // Vérifier si la vue existe
        $filePath = self::$viewPath . $view . '.php';
        if (!file_exists($filePath)) {
            throw new Exception("Vue non trouvée: $view");
        }
          // Démarrer la mise en tampon de sortie
        ob_start();
        require $filePath;
        $content = ob_get_clean();
        
        return $content;
    }
    
    /**
     * Affiche une vue avec un layout
     * @param string $view Nom de la vue
     * @param string $layout Nom du layout
     * @param array $data Données à passer à la vue et au layout
     */
    public static function renderWithLayout($view, $layout = 'default', $data = []) {
        // Rendre d'abord la vue
        $data['content'] = self::render($view, $data);
        
        // Puis rendre le layout avec la vue à l'intérieur
        return self::render('layouts/' . $layout, $data);
    }
}
?>

Explication : Cette classe View fournit deux méthodes principales : render() pour afficher une vue simple, et renderWithLayout() pour afficher une vue à l'intérieur d'un layout. La mise en tampon de sortie (ob_start/ob_get_clean) permet de capturer la sortie HTML pour l'insérer dans le layout.

Exemple de layout - app/views/layouts/default.php

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= $title ?? 'Mon Application MVC' ?></title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <header>
        <nav>
            <ul>
                <li><a href="/">Accueil</a></li>
                <li><a href="/articles">Articles</a></li>
                <?php if (isset($_SESSION['user_id'])): ?>
                    <li><a href="/article/create">Créer un article</a></li>
                    <li><a href="/profile">Mon Profil</a></li>
                    <li><a href="/logout">Déconnexion</a></li>
                <?php else: ?>
                    <li><a href="/login">Connexion</a></li>                    <li><a href="/register">Inscription</a></li>
                <?php endif; ?>
            </ul>
        </nav>
    </header>
    
    <main>
        <?php if (isset($_SESSION[?>
            <div class="alert success">
                <?= $_SESSION[?>
            </div>
            <?php unset($_SESSION[?>
        <?php endif; ?>
        
        <?php if (isset($_SESSION[?>
            <div class="alert error">
                <?= $_SESSION[?>
            </div>
            <?php unset($_SESSION[?>
        <?php endif; ?>
        
        <?= $content ?>
    </main>
    
    <footer>
        <p>© <?= date('Y') ?> Mon Application MVC</p>
    </footer>
    
    <script src="/js/main.js"></script>
</body>
</html>

Explication : Ce layout contient la structure HTML commune à toutes les pages, avec des emplacements pour le titre et le contenu principal. Il inclut également la gestion des messages flash (succès/erreur) stockés en session, et affiche un menu différent selon que l'utilisateur est connecté ou non.

Utilisation dans un contrôleur

<?php
class ArticleController {
    // ...
    
    public function index() {
        $articles = $this->articleModel->all();
        
        echo View::renderWithLayout('articles/index', 'default', [
            'title' => 'Liste des articles',
            'articles' => $articles
        ]);
    }
      public function view($id) {
        $article = $this->articleModel->find($id);
        
        if (!$article) {
            header("HTTP/1.0 404 Not Found");
            echo View::renderWithLayout('errors/404', 'default', [
                'title' => 'Article non trouvé'
            ]);
            return;
        }
        
        echo View::renderWithLayout('articles/view', 'default', [
            'title' => $article['article' => $article
        ]);
    }
    // ...
}
?>

Explication : Dans le contrôleur, nous utilisons la classe View pour rendre les vues avec un layout. Nous passons les données nécessaires comme le titre et les articles. Cette approche permet d'avoir un code plus propre et d'éviter la duplication du code HTML de base.

Utilisation de Twig (système de templates professionnel)

Pour les projets plus importants, vous pouvez utiliser Twig, un moteur de templates professionnel :

Configuration de Twig - app/helpers/TwigView.php

<?php
require_once ROOT . '/vendor/autoload.php';

class TwigView {
    private static $twig = null;
    
    public static function init() {
        $loader = new \Twig\Loader\FilesystemLoader(ROOT . '/app/views');
        self::$twig = new \Twig\Environment($loader, [
            'cache' => ROOT . '/cache/twig',
            'debug' => DEBUG_MODE,
            'auto_reload' => true
        ]);
        
        // Ajouter des extensions ou fonctions personnalisées
        if (DEBUG_MODE) {
            self::$twig->addExtension(new \Twig\Extension\DebugExtension());
        }
        
        // Ajouter des fonctions utiles
        self::$twig->addFunction(new \Twig\TwigFunction('url', function($path) {            return '/' . ltrim($path, '/');
        }));
        
        // Ajouter des filtres personnalisés
        self::$twig->addFilter(new \Twig\TwigFilter(function($date, $format = 'd/m/Y') {
            return date($format, strtotime($date));
        }));
    }
    
    public static function render($template, $data = []) {
        if (!self::$twig) {
            self::init();
        }
        
        // Ajouter des variables globales disponibles pour tous les templates
        $data['session'] = $_SESSION;
        $data['current_url'] = $_SERVER['REQUEST_URI'];
        
        try {
            return self::$twig->render($template . '.twig', $data);
        } catch (\Twig\Error\LoaderError $e) {
            throw new Exception("Template not found: $template");
        }
    }
}
?>

Explication : Cette classe configure Twig avec des options comme le cache et le mode debug. Elle ajoute également des fonctions et filtres personnalisés que vous pourrez utiliser dans vos templates, comme la fonction url() pour générer des URLs ou le filtre date_format pour formater des dates.

Exemple de template Twig - app/views/articles/view.twig

{% extends "layouts/default.twig" %}

{% block title %}{{ article.title }}{% endblock %}

{% block content %}
    <article class="single-article">
        <header>
            <h1>{{ article.title }}</h1>
            <div class="article-meta">
                <span class="date">Publié le {{ article.created_at|date_format('d F Y') }}</span>
                <span class="author">par {{ article.author_name }}</span>
            </div>
        </header>
        
        {% if article.image_url %}            <div class="article-image">
                <img src="{{ article.image_url }}" alt="{{ article.title }}">
            </div>
        {% endif %}
        
        <div class="article-content">
            {{ article.content|raw }}
        </div>
        
        <div class="article-actions">
            {% if session.user_id == article.user_id %}
                <a href="{{ url('article/edit/' ~ article.id) }}" class="btn edit">Modifier</a>
                <a href="{{ url('article/delete/' ~ article.id) }}" class="btn delete" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet article?')">Supprimer</a>
            {% endif %}
            <a href="{{ url('articles') }}" class="btn back">Retour à la liste</a>
        </div>
    </article>
    
    {% if comments is not empty %}
        <section class="comments">
            <h2>{{ comments|length }} Commentaire(s)</h2>
            
            {% for comment in comments %}
                <div class="comment">
                    <div class="comment-header">
                        <strong>{{ comment.username }}</strong>
                        <span class="date">{{ comment.created_at|date_format }}</span>
                    </div>
                    <div class="comment-content">
                        {{ comment.content }}
                    </div>
                </div>
            {% endfor %}
        </section>
    {% endif %}
    
    {% if session.user_id %}
        <section class="comment-form">
            <h2>Laisser un commentaire</h2>
            <form action="{{ url('comment/add') }}" method="post"enctype="multipart/form-data">
                <input type="hidden" name="article_id" value="{{ article.id }}">
                <div class="form-group">
                    <textarea name="content" rows="5" required></textarea>
                </div>
                <button type="submit" class="btn">Envoyer</button>
            </form>
        </section>
    {% else %}
        <p class="login-prompt"><a href="{{ url('login') }}">Connectez-vous</a> pour laisser un commentaire.</p>
    {% endif %}
{% endblock %}

Explication : Ce template Twig étend un layout de base et définit des blocs pour le titre et le contenu. Il affiche les détails d'un article, avec des actions conditionnelles selon que l'utilisateur est l'auteur ou non. Il montre également les commentaires existants et un formulaire pour en ajouter un nouveau si l'utilisateur est connecté. Notez la syntaxe élégante de Twig pour les conditions, les boucles et les filtres.

Construction d'un mini-framework MVC complet

Pour conclure, voici comment créer un mini-framework MVC complet en rassemblant tous les éléments que nous avons vus :

1. Créez une structure de répertoires


my-mvc-framework/
├── app/
│   ├── controllers/
│   │   ├── ArticleController.php
│   │   ├── CommentController.php
│   │   ├── UserController.php
│   │   └── HomeController.php
│   ├── models/
│   │   ├── ArticleModel.php
│   │   ├── CommentModel.php
│   │   └── UserModel.php
│   └── views/
│       ├── articles/
│       │   ├── index.php
│       │   ├── view.php
│       │   ├── create.php
│       │   └── edit.php
│       ├── users/
│       │   ├── login.php
│       │   └── register.php
│       └── layouts/
│           └── default.php
├── config/
│   ├── config.php
│   └── database.php
├── core/
│   ├── Model.php
│   ├── Controller.php
│   ├── View.php
│   └── Router.php
└── public/
    ├── index.php
    ├── css/
    ├── js/
    └── .htaccess

2. Configuration de la redirection avec .htaccess

public/.htaccess

# Activer le moteur de réécriture
RewriteEngine On

# Ne pas appliquer la réécriture aux fichiers existants
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

# Rediriger toutes les requêtes vers index.php
RewriteRule ^(.*)$ index.php [QSA,L]

# Protection contre l'accès aux fichiers .htaccess
<Files .htaccess>
    Order Allow,Deny
    Deny from all
</Files>

3. Créez les fichiers de base du framework

Ajoutez les classes principales comme Router, Controller, Model, View, etc. que nous avons vues précédemment.

4. Ajoutez la gestion des erreurs

app/helpers/ErrorHandler.php

<?php
class ErrorHandler {
    public static function register() {
        // Définir le gestionnaire d'exceptions
        set_exception_handler([self::class, 'handleException']);
        
        // Définir le gestionnaire d'erreurs
        set_error_handler([self::class, 'handleError']);
        
        // Définir le gestionnaire d'arrêt
        register_shutdown_function([self::class, 'handleShutdown']);
    }
    
    public static function handleException($exception) {
        // Journaliser l'exception
        self::logError($exception->getMessage(), $exception->getFile(), $exception->getLine(), $exception->getTraceAsString());
          // Afficher une page d'erreur en fonction du type d'exception
        if ($exception instanceof NotFoundException) {
            header("HTTP/1.0 404 Not Found");
            echo View::renderWithLayout('errors/404', 'default', [
                'title' => 'Page non trouvée',
                'message' => $exception->getMessage()
            ]);
        } else {
            header("HTTP/1.0 500 Internal Server Error");
            echo View::renderWithLayout('errors/500', 'default', [
                'title' => 'Erreur serveur',
                'message' => DEBUG_MODE ? $exception->getMessage() : 'Une erreur interne est survenue.',
                'trace' => DEBUG_MODE ? $exception->getTraceAsString() : null
            ]);
        }
        
        exit;
    }
      public static function handleError($level, $message, $file, $line) {
        if (!(error_reporting() & $level)) {
            // Ce niveau d'erreur n'est pas inclus dans error_reporting
            return;
        }
        
        // Traiter l'erreur comme une exception
        throw new ErrorException($message, 0, $level, $file, $line);
    }
    
    public static function handleShutdown() {
        $error = error_get_last();
        if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
            self::logError($error['message'], $error['file'], $error['line']);
            
            if (!headers_sent()) {
                header("HTTP/1.0 500 Internal Server Error");
            }
            
            echo View::renderWithLayout('errors/500', 'default', [
                'title' => 'Erreur fatale',
                'message' => DEBUG_MODE ? $error['message'] : 'Une erreur fatale est survenue.',
                'file' => DEBUG_MODE ? $error['file'] : null,
                'line' => DEBUG_MODE ? $error['line'] : null
            ]);
        }
    }
    
    private static function logError($message, $file, $line, $trace = '') {
        $logEntry = date('Y-m-d H:i:s') . " | ";
        $logEntry .= "$message in $file on line $line";
        if (!empty($trace)) {
            $logEntry .= "\nStack Trace:\n$trace";
        }
        $logEntry .= "\n" . str_repeat('-', 80) . "\n";
        
        file_put_contents(
            ROOT . '/logs/error.log',
            $logEntry,
            FILE_APPEND
        );
    }
}

// Exceptions personnalisées
class NotFoundException extends Exception {}
class ForbiddenException extends Exception {}
class ValidationException extends Exception {}
?>

Explication : Cette classe gère toutes les erreurs et exceptions de l'application. Elle définit des gestionnaires pour les exceptions, les erreurs et les arrêts fatals. Elle journalise les erreurs dans un fichier et affiche des pages d'erreur adaptées selon le type d'erreur et le mode de débogage.

5. Utilisation dans le Front Controller

public/index.php (complet)

<?php
// Définir le chemin de base
define('ROOT', dirname(__DIR__));

// Chargement de l'autoloader
require_once ROOT . '/vendor/autoload.php';

// Chargement des configurations
require_once ROOT . '/config/config.php';
require_once ROOT . '/config/database.php';

// Charger et configurer le gestionnaire d'erreurs
require_once ROOT . '/app/helpers/ErrorHandler.php';
ErrorHandler::register();

// Charger les classes nécessaires
require_once ROOT . '/app/helpers/View.php';
require_once ROOT . '/app/helpers/Router.php';
require_once ROOT . '/app/models/Model.php';
require_once ROOT . '/app/controllers/Controller.php';

// Initialiser la session
session_start();

try {
    // Créer la connexion à la base de données
    $db = new PDO(
        "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8",
        DB_USER,
        DB_PASS,
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
    
    // Instancier et exécuter le routeur
    $router = new Router($db);
    $router->dispatch();
} catch (Exception $e) {
    // En cas d'erreur, le gestionnaire d'exceptions se chargera de l'affichage
    throw $e;
}
?>

Conseil : Créer votre propre mini-framework est un excellent exercice d'apprentissage, mais pour les projets en production, envisagez d'utiliser un framework établi qui bénéficie de mises à jour régulières, d'une communauté active et d'une documentation solide.

Frameworks MVC PHP populaires :

  • Laravel : Le framework PHP le plus populaire, avec un écosystème riche et une syntaxe élégante.
  • Symfony : Un framework robuste et modulaire, utilisé par de nombreuses entreprises pour des projets complexes.
  • CodeIgniter : Un framework léger et simple à apprendre, idéal pour les débutants.
  • Yii : Un framework performant avec une génération de code intégrée.
  • Slim : Un micro-framework minimaliste, parfait pour les petites applications et les API.

Exemple concret : Blog MVC

Pour illustrer l'architecture MVC, nous allons voir un exemple concret d'un système de blog simple.

Structure de notre blog MVC


blog-mvc/
├── app/
│   ├── controllers/
│   │   ├── ArticleController.php
│   │   ├── CommentController.php
│   │   ├── UserController.php
│   │   └── HomeController.php
│   ├── models/
│   │   ├── ArticleModel.php
│   │   ├── CommentModel.php
│   │   └── UserModel.php
│   └── views/
│       ├── articles/
│       │   ├── index.php
│       │   ├── view.php
│       │   ├── create.php
│       │   └── edit.php
│       ├── users/
│       │   ├── login.php
│       │   └── register.php
│       └── layouts/
│           └── default.php
├── config/
│   ├── config.php
│   └── database.php
├── core/
│   ├── Model.php
│   ├── Controller.php
│   ├── View.php
│   └── Router.php
└── public/
    ├── index.php
    ├── css/
    ├── js/
    └── .htaccess

Création d'un article - Flux complet

Analysons comment la création d'un article fonctionne dans notre architecture MVC :

1. Le routeur (public/index.php)


// Le Front Controller reçoit la requête /article/create
$router = new Router($db);
$router->get('/article/create', ['ArticleController', 'create']);
$router->post('/article/store', ['ArticleController', 'store']);
$router->dispatch();

Explication : Le routeur définit deux routes : une pour afficher le formulaire de création (GET) et une pour traiter la soumission du formulaire (POST).

2. Le contrôleur (app/controllers/ArticleController.php)


class ArticleController extends Controller {
    private $articleModel;
    private $categoryModel;
    
    public function __construct($db) {
        parent::__construct($db);
        $this->articleModel = new ArticleModel($db);        $this->categoryModel = new CategoryModel($db);
        
        // Vérifier l'authentification pour certaines actions
        $this->requireAuth(['create', 'store', 'edit', 'update', 'delete']);
    }
    
    // Afficher le formulaire de création
    public function create() {
        $categories = $this->categoryModel->all();
        
        echo View::renderWithLayout('articles/create', 'default', [
            'title' => 'Créer un article',
            'categories' => $categories
        ]);
    }
    
    // Traiter la soumission du formulaire
    public function store() {
        // Valider et nettoyer les données du formulaire
        $title = trim($_POST['title'] ?? '');
        $content = trim($_POST['content'] ?? '');
        $categoryId = $_POST['category_id'] ?? null;
        $userId = $_SESSION['user_id'];
        
        $errors = [];
        
        // Validation des données
        if (empty($title)) {
            $errors['title'] = "Le titre est obligatoire.";
        } elseif (strlen($title) > 255) {
            $errors["Le titre ne doit pas dépasser 255 caractères.";
        }
        
        if (empty($content)) {
            $errors['content'] = "Le contenu est obligatoire.";
        }
        
        if (empty($categoryId)) {
            $errors['category_id'] = "La catégorie est obligatoire.";
        }
          // S'il y a des erreurs, réafficher le formulaire
        if (!empty($errors)) {
            $categories = $this->categoryModel->all();
            
            echo View::renderWithLayout('articles/create', 'default', [
                'title' => 'Créer un article',
                $categories,
                'errors' => $errors,
                'article' => [
                    'title' => $title,
                    'content' => $content,
                    'category_id' => $categoryId
                ]
            ]);
            return;
        }
          // Création de l'article
        $articleData = [
            'title' => $title,
            'content' => $content,
            'category_id' => $categoryId,
            'user_id' => $userId,
            'created_at' => date('Y-m-d H:i:s')
        ];
        
        // Gérer l'upload d'image si présent
        if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
            $uploadResult = $this->uploadImage($_FILES['image']);
            if ($uploadResult['success']) {
                $articleData['image_path'] = $uploadResult['path'];
            } else {
                $errors['image'] = $uploadResult['error'];
                
                // Réafficher le formulaire avec l'erreur d'image
                $categories = $this->categoryModel->all();
                echo View::renderWithLayout('articles/create', 'default', [
                    'title' => 'Créer un article',
                    $categories,
                    'errors' => $errors,
                    'article' => [
                        'title' => $title,
                        'content' => $content,
                        'category_id' => $categoryId
                    ]
                ]);
                return;
            }
        }
        
        // Sauvegarder l'article
        $articleId = $this->articleModel->create($articleData);
        
        if ($articleId) {
            // Succès : rediriger vers l'article créé
            $_SESSION["Article créé avec succès !";
            header("Location: /article/view/$articleId");
            exit;
        } else {
            // Erreur : réafficher le formulaire
            $categories = $this->categoryModel->all();
            $errors['db'] = "Erreur lors de la création de l'article. Veuillez réessayer.";
            
            echo View::renderWithLayout('articles/create', 'default', [
                'title' => 'Créer un article',
                $categories,
                'errors' => $errors,
                'article' => [
                    'title' => $title,
                    'content' => $content,
                    'category_id' => $categoryId
                ]
            ]);
        }
    }
      // Méthode auxiliaire pour l'upload d'image
    private function uploadImage($file) {
        $targetDir = ROOT . '/public/uploads/articles/';
        $filename = uniqid() . '_' . basename($file['name']);
        $targetPath = $targetDir . $filename;
        $uploadPath = '/uploads/articles/' . $filename;
        
        // Vérifier le type de fichier
        $allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
        if (!in_array($file['type'], $allowedTypes)) {
            return [
                'success' => false, 
                'error' => "Type de fichier non autorisé. Utilisez JPG, PNG ou GIF."
            ];
        }
        
        // Vérifier la taille
        if ($file['size'] > 2000000) { // 2MB
            return [
                'success' => false, 
                'error' => "L'image ne doit pas dépasser 2MB."
            ];
        }
        
        // Déplacer le fichier uploadé
        if (move_uploaded_file($file['tmp_name'], $targetPath)) {
            return ['success' => true, 'path' => $uploadPath];
        } else {
            return [
                'success' => false, 
                'error' => "Erreur lors de l'upload de l'image."
            ];
        }
    }
    
    // Autres méthodes (index, view, edit, update, delete...)
}

Explication : Ce contrôleur montre deux méthodes principales : create() qui affiche le formulaire de création d'article, et store() qui traite la soumission du formulaire. Il gère la validation des données, l'upload d'images, et la création de l'article via le modèle. En cas d'erreur, il réaffiche le formulaire avec les messages d'erreur.

3. Le modèle (app/models/ArticleModel.php)


class ArticleModel extends Model {
    protected $table = 'articles';
    
    // Récupérer un article avec les données associées
    public function getArticleWithDetails($id) {
        $sql = "
            SELECT a.*, u.username as author_name, c.name as category_name 
            FROM {$this->table} a
            JOIN users u ON a.user_id = u.id
            JOIN categories c ON a.category_id = c.id
            WHERE a.id = :id
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->bindParam(':id', $id);
        $stmt->execute();
        
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }
      // Récupérer tous les articles avec pagination
    public function getAllWithPagination($page = 1, $perPage = 10) {
        $offset = ($page - 1) * $perPage;
        
        $sql = "
            SELECT a.*, u.username as author_name, c.name as category_name 
            FROM {$this->table} a
            JOIN users u ON a.user_id = u.id
            JOIN categories c ON a.category_id = c.id
            ORDER BY a.created_at DESC
            LIMIT :offset, :perPage
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
        $stmt->bindParam(':perPage', $perPage, PDO::PARAM_INT);
        $stmt->execute();
          return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
    
    // Compter le nombre total d'articles
    public function countAll() {
        $stmt = $this->db->prepare("SELECT COUNT(*) FROM {$this->table}");
        $stmt->execute();
        return $stmt->fetchColumn();
    }
    
    // Rechercher des articles
    public function search($keyword) {
        $keyword = "%$keyword%";
        
        $sql = "
            SELECT a.*, u.username as author_name, c.name as category_name 
            FROM {$this->table} a
            JOIN users u ON a.user_id = u.id
            JOIN categories c ON a.category_id = c.id
            WHERE a.title LIKE :keyword OR a.content LIKE :keyword
            ORDER BY a.created_at DESC
        ";
        
        $stmt = $this->db->prepare($sql);
        $stmt->bindParam(':keyword', $keyword);
        $stmt->execute();
        
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

Explication : Ce modèle étend le modèle de base et ajoute des méthodes spécifiques aux articles. Il peut récupérer un article avec ses données associées (auteur, catégorie), récupérer une liste paginée d'articles, compter le nombre total d'articles, et effectuer des recherches. Il hérite déjà des méthodes CRUD de base (create, find, update, delete) de la classe parente.

4. La vue (app/views/articles/create.php)


<div class="container">
    <h1><?= $title ?></h1>
    
    <?php if (isset($errors) && !empty($errors)): ?>
        <div class="alert alert-danger">
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= $error ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <form action="/article/store" method="post" enctype="multipart/form-data">
        <div class="form-group">
            <label for="title">Titre</label>
            <input type="text" id="title" name="title" class="form-control" 
                   value="<?= htmlspecialchars(isset($article['title']) ? $article['title'] : '') ?>" required>
        </div>
        
        <div class="form-group">
            <label for="category">Catégorie</label>
            <select id="category" name="category_id" class="form-control" required>
                <option value="">Choisir une catégorie</option>
                <?php if (isset($categories) && is_array($categories)): foreach ($categories as $category): ?>                    <option value="<?= $category['id'] ?? '' ?>" 
                        <?= (isset($article['category_id']) && isset($category['id']) && $article[$category['selected' : '' ?>>                        <?= htmlspecialchars($category['') ?>
                    </option>
            </select>
        </div>
        
        <div class="form-group">
            <label for="content">Contenu</label>
            <textarea id="content" name="content" class="form-control" rows="10" required><?= htmlspecialchars(isset($article['content']) ? $article['content'] : '') ?></textarea>
        </div>
        
        <div class="form-group">
            <label for="image">Image (optionnelle)</label>
            <input type="file" id="image" name="image" class="form-control-file">
            <small class="form-text text-muted">Formats acceptés : JPG, PNG, GIF. Max 2MB.</small>
        </div>          <div class="navigation">
            <button type="submit" class="nav-button">Publier l'article</button>
            <a href="/articles" class="nav-button">Annuler</a>
        </div>
    </form>
</div>

<script>
    // JavaScript pour initialiser un éditeur de texte riche (optionnel)
    document.addEventListener('DOMContentLoaded', function() {
        // Si vous utilisez un éditeur comme TinyMCE ou CKEditor
        // tinymce.init({ selector: '#content' });
    });
</script>

Explication : Cette vue affiche un formulaire de création d'article avec des champs pour le titre, la catégorie, le contenu et une image optionnelle. Elle gère l'affichage des erreurs de validation et prérempli les champs avec les valeurs précédemment soumises en cas d'erreur. Elle inclut également un espace pour initialiser un éditeur de texte riche si nécessaire.

Avantages de cette architecture

  • Séparation des préoccupations : Chaque partie a une responsabilité claire
  • Organisation : Code structuré et facile à naviguer
  • Sécurité : Validation centralisée, préparation des requêtes SQL
  • Évolutivité : Facile d'ajouter de nouvelles fonctionnalités
  • Maintenabilité : Modification d'une partie sans affecter les autres

Cet exemple montre comment les trois composants MVC travaillent ensemble pour créer une fonctionnalité complète. Le modèle gère les données, le contrôleur coordonne le processus, et la vue présente l'interface utilisateur.

Conclusion

L'architecture MVC est un pilier du développement web moderne, particulièrement pour les applications PHP complexes. En séparant clairement les responsabilités entre :

  • Le Modèle qui gère les données et la logique métier
  • La Vue qui s'occupe de la présentation
  • Le Contrôleur qui coordonne l'ensemble

Elle vous permet de développer des applications plus structurées, plus maintenables et plus évolutives. Les concepts que vous avez appris dans ce module sont utilisés dans tous les frameworks PHP populaires comme Laravel, Symfony, CodeIgniter, etc.

À mesure que vous progresserez, vous pourrez explorer des concepts plus avancés comme :

  • L'injection de dépendances
  • Les services et repositories
  • L'architecture en couches
  • Les tests unitaires et fonctionnels

Mais les bases solides du MVC que vous avez acquises vous serviront tout au long de votre parcours de développeur PHP.

Pour aller plus loin : Essayez de construire votre propre petit framework MVC, ou explorez des frameworks comme Laravel ou Symfony qui implémentent l'architecture MVC de manière professionnelle et offrent de nombreuses fonctionnalités avancées.