Architecture MVVM Multi-Entités : Utilisateurs Clients
Sommaire
- Objectif
- Qu'est-ce que le MVVM ?
- MVC vs MVVM : la différence clé
- MVC vs MVVM : la différence clé
- Structure du Projet
- Rôle de chaque dossier & fichiers attendus
- Configuration & Noyau (Core)
- Les ViewModels
- Les Modèles
- views/layouts/header.php
- views/utilisateur/index.php
- Exercice : Gestion des Produits en MVVM PHP
Architecture MVVM Multi-Entités : Utilisateurs & Clients
-
Objectif
- Dans ce tutoriel, vous allez :
- Comprendre la différence entre MVC et MVVM et pourquoi passer à MVVM.
- Remplacer les Controllers par des ViewModels qui encapsulent la logique de présentation.
- Exposer un objet
$vmentier à la vue, au lieu de variables extraites viaextract(). - Interagir avec une base de données MySQL via une classe Model centralisée.
-
Qu’est-ce que le MVVM ?
- MVVM = Model – View – ViewModel
- Pattern architectural créé par Microsoft (WPF/Silverlight) pour les interfaces riches. Il vise à dissocier totalement la logique métier de l’interface graphique.
-
MVC vs MVVM : la différence clé
- Le Concept : Le Buffet avec Assistant (MVVM)
- Imaginez que vous êtes dans un hôtel de luxe. Vous ne passez pas commande à un serveur qui disparaît en cuisine. Au lieu de cela, vous avez un Assistant (le ViewModel) qui se tient devant un Buffet (le Modèle) et qui prépare votre assiette en temps réel selon vos envies.
- 1. La Vue (Le Client)
- C’est vous. Vous regardez ce qui est disponible. Vous ne touchez pas directement à la nourriture dans les grands plats en argent. Vous interagissez avec l’assistant. Si vous dites « Je veux que ma viande soit plus cuite », vous ne parlez pas au chef, vous changez simplement votre préférence sur votre menu digital.
- 2. Le Modèle (La Cuisine / Les Stocks)
- C’est la source brute. Les ingrédients, les frigos, les recettes de base. Le Modèle ne sait pas qui vous êtes, il sait juste qu’il contient des steaks, des légumes et des sauces.
- 3. Le ViewModel (L’Assistant de table)
- C’est là que la magie opère. L’assistant fait le lien entre le buffet et vous de manière « automatique » :
- Data Binding (Liaison de données) : Si le chef ajoute un nouveau plat au buffet, l’assistant le met immédiatement à jour sur votre menu sans que vous ayez à demander « Quoi de neuf ? ».
- Transformation : Le buffet propose du poisson entier (Donnée brute), mais l’assistant vous le présente en filets prêts à manger (Donnée formatée pour la Vue).
- État : Si vous cochez « Végétarien » sur votre menu, l’assistant masque instantanément tous les plats de viande pour vous.
-
MVC vs MVVM : la différence clé
- En MVC, le Controller reçoit la requête, appelle le Model, prépare les données et les pousse vers la View via
extract(). Il mélange logique HTTP et logique de présentation. - En MVVM, le ViewModel se charge uniquement de préparer et exposer les données pour la View. Le Router gère la requête HTTP. La View accède à un objet
$vmavec des propriétés typées et des méthodes utilitaires. - En MVC, le Controller orchestre et met à jour la Vue manuellement.
- En MVVM, le ViewModel utilise le Data Binding (souvent bidirectionnel) : la Vue se met à jour automatiquement quand le ViewModel change, et inversement, sans manipulation directe du DOM.
-
Structure du Projet
- Voici l’organisation des fichiers pour ce projet MVVM :
-
Rôle de chaque dossier & fichiers attendus
-
Bonnes pratiques essentielles
-
Point d’entrée unique & Sécurité
- Seul
public/index.phpest accessible via le navigateur. - Dans une architecture MVVM,
public/index.phpreste le point d’entrée unique. Son rôle : charger les dépendances, définir les constantes, et lancer le Router. - Détail rôle par rôle
- Le fichier
public/.htaccess .htaccess(Apache) doit réécrire toutes les requêtes versindex.php.- Contenu de public/.htaccess
- Rôle ligne par ligne
-
core/BaseViewModel.php et core/Model.php
- core/BaseViewModel.php – Classe mère des ViewModels
- core/Model.php – Classe mère des modèles (PDO sécurisé)
-
Fichier core/Router.php
- Rôle du Router dans le cycle MVVM
- Le routeur a 3 missions principales :
- Parser l’URL demandée et identifier le ViewModel correspondant (ex: /client →
ClientViewModel) - Instancier le ViewModel et appeler sa méthode
load()pour préparer les données - Inclure la View en lui passant l’objet
$vmentier (pas deextract()) -
Configuration & Noyau (Core)
-
1. Connexion Base de Données (config/database.php)
- Ce fichier est identique à la version MVC. Le changement de pattern n’affecte pas la couche de connexion.
-
2. Le ViewModel Parent (core/BaseViewModel.php)
- Rôle de core/BaseViewModel.php dans l’architecture MVVM
-
3. Le Contrôleur est supprimé
- En MVVM, le fichier
core/Controller.phpn’existe plus. Son rôle est réparti entre : - Le Router : gère la requête HTTP et le rendu de la View
- Le ViewModel : prépare et expose les données
-
Les ViewModels
- ViewModel (viewmodels/ClientViewModel.php) :
- ViewModel (viewmodels/UtilisateurViewModel.php) :
-
Les Modèles
- En MVVM, le Model est encore plus épuré qu’en MVC : il retourne des données brutes. Toute logique de présentation a migré vers le ViewModel.
- Modèle (models/Client.php) :
- Modèle (models/Utilisateur.php) :
-
views/layouts/header.php
- views/layouts/header.php
-
views/layouts/footer.php
-
views/utilisateur/index.php
- views/utilisateur/index.php
- Différence clé : la View accède à
$vmau lieu de variables extraites. Plus aucune variable « magique ». -
views/client/index.php
-
Script SQL
- Le script SQL est identique à la version MVC — le changement de pattern n’affecte pas la structure de la base de données.
- Le mot de passe en clair correspondant à ce hash bcrypt (
$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi) est tout simplement : password. -
Exercice : Gestion des Produits en MVVM PHP
- Objectifs pédagogiques
- À la fin de cet exercice, l’apprenant sera capable de :
- Comprendre l’architecture MVVM (Model – ViewModel – View) en PHP
- Créer une table MySQL
produits - Implémenter un Model pour interagir avec la base de données
- Créer un ViewModel pour préparer et formater les données
- Créer une View qui accède uniquement à l’objet
$vm - Afficher la liste des produits depuis la base de données
- Contexte de l’exercice
- Vous travaillez dans une application web développée en PHP avec l’architecture MVVM.
- On vous demande de créer un module Produits permettant d’afficher une liste de produits stockés dans une base de données MySQL.
- Création de la base de données
- Créer une table
produitsavec les champs suivants : idINT, PK, AI — Identifiant du produit |nomVARCHAR(150) — Nom du produit |descriptionTEXT — Description |prixDECIMAL(10,2) — Prix |stockINT — Quantité en stock |date_creationDATETIME — Date d’insertion- La table doit utiliser InnoDB et l’encodage UTF8.
- Insérer au moins 5 produits dans la table
produitspour effectuer vos tests. - Structure MVVM
- Vous disposez de l’architecture suivante :
- Création du Model
- Fichier :
models/Produit.php - Créer un Model
Produitqui hérite deModelet retourne des données brutes. - Le Model doit contenir au minimum une méthode :
getAll() - Création du ViewModel
- Fichier :
viewmodels/ProduitViewModel.php - Créer un ViewModel
ProduitViewModelqui : - Hérite de
BaseViewModel - Définit
protected string $viewPath = 'produit/index' - Implémente la méthode
load()qui appelle le Model et formate les données - Expose les propriétés publiques :
$produits,$totalCount,$isEmpty,$pageTitle - Contient une méthode publique
formatPrix(float $prix): stringretournant le prix formaté en TND - Création de la View
- Fichier :
views/produit/index.php - Créer une vue qui :
- N’utilise que
$vm— aucune variable libre - Affiche le titre via
$vm->pageTitle - Affiche les produits sous forme de table HTML
- Affiche les colonnes :
- Nom
- Description
- Prix (via
$vm->formatPrix($produit['prix'])) - Stock
- Date de création
- Routage
- Configurer le routage pour que l’URL suivante fonctionne :
http://localhost/votre-projet/public/produit- Cette URL doit charger automatiquement :
ProduitViewModel(méthodeload())- Vue
produit/index.php
| Couche | Rôle |
|---|---|
| Model | Données & logique métier (BDD, API, règles de validation) |
| View | Interface utilisateur (HTML/CSS, composants UI). Ne contient aucune logique. |
| ViewModel | Intermédiaire synchronisé. Expose les données formatées pour la Vue, gère l’état de l’UI, et traduit les actions utilisateur en commandes métier. |
| Aspect | MVC (avant) | MVVM (après) |
|---|---|---|
| Classe centrale | Controller |
BaseViewModel |
| Dossier applicatif | controllers/ |
viewmodels/ |
| Méthode principale | index(), show()… |
load(array $params) |
| Données vers la View | extract($data) → variables libres |
Objet $vm avec propriétés typées |
| Formatage des données | Dans la View ou le Controller | Dans le ViewModel uniquement |
| Rendu HTML | render() dans le Controller |
dispatch() dans le Router |
mon-projet-mvvm/
├── config/
│ └── database.php # Paramètres de connexion (inchangé)
├── core/
│ ├── BaseViewModel.php # Classe mère des ViewModels (remplace Controller.php)
│ ├── Model.php # Connexion PDO partagée (inchangé)
│ └── Router.php # Instancie les ViewModels au lieu des Controllers
├── viewmodels/ # Remplace controllers/
│ ├── UtilisateurViewModel.php
│ └── ClientViewModel.php
├── models/
│ ├── Utilisateur.php
│ └── Client.php
├── views/
│ ├── layouts/
│ │ ├── header.php # En-tête HTML commun
│ │ └── footer.php # Pied de page commun
│ ├── utilisateur/
│ │ └── index.php # Accède à $vm directement
│ └── client/
│ └── index.php # Accède à $vm directement
├── public/
│ ├── index.php # Point d'entrée unique
│ └── .htaccess # Réécriture d'URL
| Dossier | Rôle | Fichiers types | Remarques |
|---|---|---|---|
| public/ | Racine web (seul dossier accessible via navigateur) | index.php, .htaccess, assets/css/, assets/js/ | Configurez votre serveur web pour pointer uniquement ici. Bloquez l’accès direct aux autres dossiers. |
| config/ | Configuration de l’application | database.php, .env | Ne doit jamais être exposé. Inchangé par rapport au MVC. |
| core/ | Classes fondatrices du framework MVVM | BaseViewModel.php, Model.php, Router.php | Contient la logique transversale. BaseViewModel remplace Controller. |
| viewmodels/ | Préparation et exposition des données pour les Views | UtilisateurViewModel.php, ClientViewModel.php | Un ViewModel par entité. Appelle le Model, transforme les données, expose des propriétés typées. Aucun HTML. |
| models/ | Accès aux données — données brutes uniquement | Utilisateur.php, Client.php | Plus épuré qu’en MVC : aucun formatage, aucune logique de présentation. Hérite de core/Model.php. |
| views/ | Couche présentation — liée au ViewModel | layouts/header.php, layouts/footer.php, client/index.php | Accède uniquement à $vm. Jamais de logique métier ni de extract(). |
<?php
// Mode développement : afficher les erreurs
ini_set('display_errors', 1);
error_reporting(E_ALL);
// Constantes de chemins
define('ROOT_PATH', dirname(__DIR__) . '/');
define('BASE_URL', ''); // Ex: '/mon-projet-mvvm' si dans un sous-dossier
// Chargement des couches core
require_once ROOT_PATH . 'config/database.php';
require_once ROOT_PATH . 'core/Model.php';
require_once ROOT_PATH . 'core/BaseViewModel.php';
require_once ROOT_PATH . 'core/Router.php';
// Lancement du router MVVM
$router = new Router(
ROOT_PATH . 'views',
ROOT_PATH . 'viewmodels'
);
$router->dispatch();
| Bloc | Pourquoi ? | Bonne pratique |
|---|---|---|
| define(‘ROOT_PATH’, …) | Évite les chemins relatifs fragiles (../../) qui peuvent casser lors de l’inclusion de fichiers. | Utilisez __DIR__ et DIRECTORY_SEPARATOR pour garantir la portabilité entre Windows et Linux. |
| require_once BaseViewModel.php | Charge la classe mère avant d’instancier un ViewModel concret dans le Router. | Indispensable : le Router fait new $vmClass(), qui hérite de BaseViewModel. |
| new Router(…) | On passe les chemins vers views/ et viewmodels/ pour que le Router soit découplé de la structure de dossiers. |
Facilite le déplacement ou renommage des dossiers sans modifier le Router. |
| $router->dispatch() | Délègue toute la logique de routage au Router : parse l’URL, instancie le ViewModel, inclut la View. | Le fichier index.php reste léger : il ne route pas, ne se connecte pas à la DB directement. |
RewriteEngine On
RewriteBase /
# Bloquer les fichiers cachés (.env, .git...)
RewriteRule (^\.|/\.) - [F]
# Servir directement les assets statiques (CSS, JS, images)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Tout le reste → index.php
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]
Options -Indexes
| Directive | Utilité |
|---|---|
| RewriteEngine On | Active le module de réécriture d’URL d’Apache, indispensable pour transformer les URLs virtuelles en requêtes traitables par PHP. |
| RewriteCond !-f / !-d | Vérifie que la requête ne correspond pas à un fichier (-f) ou un dossier (-d) existant. Cela permet d’accéder directement aux images, CSS et JS sans passer par le routeur. |
| RewriteRule ^(.*)$ index.php?url=$1 [QSA,L] | Redirige toutes les requêtes vers index.php. QSA préserve les paramètres URL, et L indique qu’il s’agit de la dernière règle à appliquer. |
| Options -Indexes | Mesure de sécurité majeure : empêche Apache d’afficher la liste des fichiers d’un dossier si le fichier d’index est absent. |
| RewriteRule (^.|/.) – [F] | Interdit l’accès (Forbidden) aux fichiers cachés commençant par un point (comme .env ou .git). |
<?php
/**
* Classe mère abstraite de tous les ViewModels.
*
* En MVVM, le ViewModel est le "pont" entre le Model (données)
* et la View (présentation). Il expose des propriétés typées
* et des méthodes de formatage — JAMAIS de HTML.
*/
abstract class BaseViewModel
{
/**
* Chemin relatif de la vue dans views/
* Exemple : "client/index"
*/
protected string $viewPath = '';
/**
* Charge les données nécessaires à la vue.
* Appelé par le Router avant le rendu.
* Peut recevoir des paramètres d'URL (ex: id).
*/
abstract public function load(array $params = []): void;
/**
* Retourne le chemin de la vue associée à ce ViewModel.
*/
public function getViewPath(): string
{
if (empty($this->viewPath)) {
throw new RuntimeException(
get_class($this) . ' doit définir $viewPath.'
);
}
return $this->viewPath;
}
}
<?php
require_once __DIR__ . '/../config/database.php';
abstract class Model {
protected PDO $db;
protected string $table;
public function __construct() {
$this->db = Database::getInstance();
}
protected function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt;
}
protected function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
protected function fetch(string $sql, array $params = []): array|false
{
return $this->query($sql, $params)->fetch();
}
}
<?php
class Router
{
private string $viewsPath;
private string $viewModelsPath;
public function __construct(string $viewsPath, string $viewModelsPath)
{
$this->viewsPath = $viewsPath;
$this->viewModelsPath = $viewModelsPath;
}
public function dispatch(): void
{
// 1. Parser l'URL
$url = trim($_GET['url'] ?? 'client', '/');
$parts = explode('/', $url);
$resource = ucfirst(strtolower($parts[0])); // "client" → "Client"
$params = array_slice($parts, 1); // ["5"] pour /client/5
// 2. Résoudre le ViewModel
$vmClass = $resource . 'ViewModel'; // "ClientViewModel"
$vmFile = $this->viewModelsPath . '/' . $vmClass . '.php';
if (!file_exists($vmFile) || !preg_match('/^[A-Za-z]+$/', $resource)) {
http_response_code(404);
echo "<p style='color:red'>❌ ViewModel '<strong>$vmClass</strong>' introuvable.</p>";
return;
}
require_once $vmFile;
// 3. Instancier & charger le ViewModel
$vm = new $vmClass();
$vm->load($params);
// 4. Rendre la vue en lui passant $vm
$viewFile = $this->viewsPath . '/' . $vm->getViewPath() . '.php';
if (!file_exists($viewFile)) {
http_response_code(404);
echo "<p style='color:red'>❌ Vue '<strong>$viewFile</strong>' introuvable.</p>";
return;
}
require_once ROOT_PATH . 'views/layouts/header.php';
require_once $viewFile; // $vm est disponible ici
require_once ROOT_PATH . 'views/layouts/footer.php';
}
}
-
Application PHP
↓
config/database.php ← « Où est la BDD ? Quels sont les accès ? »
↓
core/Model.php ← « Comment se connecter et exécuter des requêtes ? »
↓
PDO (PHP Data Objects) ← Driver de connexion réel
↓
MySQL / MariaDB
<?php
class Database {
private static ?PDO $instance = null;
private function __construct() {
$host = '127.0.0.1';
$db = 'base_test';
$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 {
self::$instance = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
error_log($e->getMessage());
die('Erreur de connexion à la base de données.');
}
}
public static function getInstance(): PDO {
if (self::$instance === null) {
new self();
}
return self::$instance;
}
}
-
Router (dispatch)
↓
BaseViewModel (load + getViewPath) ← « Prépare les données, expose $vm »
↓
ViewModel concret (ClientViewModel) ← « Appelle le Model, formate les données »
↓
Model (Client.php) ← « Requêtes SQL pures »
↓
PDO → MySQL
<?php
require_once __DIR__ . '/../core/BaseViewModel.php';
require_once __DIR__ . '/../models/Client.php';
class ClientViewModel extends BaseViewModel
{
// ── Propriétés publiques exposées à la View ──────────────
/** @var array Liste des clients formatés */
public array $clients = [];
public int $totalCount = 0;
public string $pageTitle = 'Liste des Clients';
public bool $isEmpty = false;
protected string $viewPath = 'client/index';
public function load(array $params = []): void
{
$model = new Client();
// Récupération des données brutes
$rawClients = $model->getAll();
// Transformation : on enrichit chaque client dans le ViewModel
$this->clients = array_map(
fn(array $c) => $this->formatClient($c),
$rawClients
);
$this->totalCount = count($this->clients);
$this->isEmpty = $this->totalCount === 0;
}
private function formatClient(array $client): array
{
return [
...$client,
// Catégorie d'âge calculée ici, pas dans la View
'age_label' => $this->getAgeLabel((int)$client['age']),
// Nom complet formaté
'full_name' => htmlspecialchars($client['name'] . ' ' . $client['family']),
// Initiales pour un avatar CSS
'initials' => strtoupper(mb_substr($client['name'], 0, 1)
. mb_substr($client['family'], 0, 1)),
];
}
// Méthode publique appelable depuis la View : $vm->getAgeLabel(25)
public function getAgeLabel(int $age): string
{
return match(true) {
$age < 18 => 'Mineur',
$age < 30 => 'Jeune adulte',
$age < 50 => 'Adulte',
default => 'Senior',
};
}
// Méthode publique : $vm->getAverageAge()
public function getAverageAge(): float
{
if ($this->isEmpty) return 0.0;
$total = array_sum(array_column($this->clients, 'age'));
return round($total / $this->totalCount, 1);
}
}
<?php
require_once __DIR__ . '/../core/BaseViewModel.php';
require_once __DIR__ . '/../models/Utilisateur.php';
class UtilisateurViewModel extends BaseViewModel
{
public array $utilisateurs = [];
public int $totalCount = 0;
public bool $isEmpty = false;
public string $pageTitle = 'Liste des Utilisateurs';
protected string $viewPath = 'utilisateur/index';
public function load(array $params = []): void
{
$model = new Utilisateur();
$raw = $model->getAll();
$this->utilisateurs = array_map(
fn(array $u) => $this->formatUtilisateur($u),
$raw
);
$this->totalCount = count($this->utilisateurs);
$this->isEmpty = $this->totalCount === 0;
}
private function formatUtilisateur(array $u): array
{
// Le décodage JSON des rôles est fait ICI, pas dans la View
$roles = json_decode($u['roles'] ?? '[]', true) ?? [];
return [
...$u,
'roles_list' => $roles,
'roles_label' => implode(', ', $roles),
'is_admin' => in_array('ROLE_ADMIN', $roles),
'email_safe' => htmlspecialchars($u['email'] ?? ''),
];
}
// Méthode publique : $vm->countAdmins()
public function countAdmins(): int
{
return count(array_filter(
$this->utilisateurs,
fn(array $u) => $u['is_admin']
));
}
}
<?php
require_once __DIR__ . '/../core/Model.php';
/**
* Model Client — Données brutes uniquement.
* Pas de décodage JSON, pas de formatage : c'est le rôle du ViewModel.
*/
class Client extends Model {
protected string $table = 'client';
public function getAll(): array {
return $this->fetchAll(
"SELECT id, name, family, age FROM {$this->table} ORDER BY id DESC"
);
}
public function findById(int $id): array|false {
return $this->fetch(
"SELECT * FROM {$this->table} WHERE id = ?",
[$id]
);
}
}
<?php
require_once __DIR__ . '/../core/Model.php';
class Utilisateur extends Model {
protected string $table = 'utilisateurs';
public function getAll(): array {
return $this->fetchAll(
"SELECT id, email, roles, password FROM {$this->table} ORDER BY id DESC"
);
}
public function findByEmail(string $email): array|false {
return $this->fetch(
"SELECT * FROM {$this->table} WHERE email = ?",
[$email]
);
}
}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MVVM - Base Test</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f8f9fa; }
nav { background: #212529; padding: 12px; margin-bottom: 25px; border-radius: 8px; }
nav a { color: #fff; text-decoration: none; margin-right: 18px; font-weight: 500; }
nav a:hover { text-decoration: underline; color: #adb5bd; }
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 6px; overflow: hidden; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #e9ecef; }
th { background: #0d6efd; color: white; }
.empty { text-align: center; padding: 20px; color: #6c757d; }
code { background: #f1f3f5; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
</style>
</head>
<body>
<nav>
<a href="<?= BASE_URL ?>/client">👥 Clients</a>
<a href="<?= BASE_URL ?>/utilisateur">🔐 Utilisateurs</a>
</nav>
<main style="max-width: 1000px; margin: 0 auto;">
</main>
<footer style="text-align: center; margin-top: 40px; color: #868e96; font-size: 0.9em;">
© 2026 - Architecture MVVM PHP/MySQL
</footer>
</body>
</html>
<?php /** @var UtilisateurViewModel $vm */ ?>
<h2>📋 <?= htmlspecialchars($vm->pageTitle) ?></h2>
<p>Total : <strong><?= $vm->totalCount ?></strong> utilisateur(s) —
Admins : <strong><?= $vm->countAdmins() ?></strong></p>
<table>
<thead>
<tr><th>ID</th><th>Email</th><th>Rôles</th><th>Admin ?</th></tr>
</thead>
<tbody>
<?php if ($vm->isEmpty): ?>
<tr><td colspan="4" class="empty">Aucun utilisateur enregistré.</td></tr>
<?php else: ?>
<?php foreach ($vm->utilisateurs as $u): ?>
<tr>
<td><?= htmlspecialchars((string)$u['id']) ?></td>
<td><?= $u['email_safe'] ?></td>
<td><code><?= htmlspecialchars($u['roles_label']) ?></code></td>
<td><?= $u['is_admin'] ? '✅ Oui' : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php /** @var ClientViewModel $vm */ ?>
<h2>📋 <?= htmlspecialchars($vm->pageTitle) ?></h2>
<p>Total : <strong><?= $vm->totalCount ?></strong> client(s) —
Âge moyen : <strong><?= $vm->getAverageAge() ?> ans</strong></p>
<table>
<thead>
<tr><th>ID</th><th>Nom complet</th><th>Âge</th><th>Catégorie</th></tr>
</thead>
<tbody>
<?php if ($vm->isEmpty): ?>
<tr><td colspan="4" class="empty">Aucun client enregistré.</td></tr>
<?php else: ?>
<?php foreach ($vm->clients as $client): ?>
<tr>
<td><?= htmlspecialchars((string)$client['id']) ?></td>
<td><?= $client['full_name'] ?></td>
<td><?= htmlspecialchars((string)$client['age']) ?> ans</td>
<td><?= htmlspecialchars($client['age_label']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
-- ==========================================
-- 1. CRÉATION DE LA BASE DE DONNÉES
-- ==========================================
CREATE DATABASE IF NOT EXISTS `base_test`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `base_test`;
-- ==========================================
-- 2. CRÉATION DES TABLES
-- ==========================================
-- Table : utilisateurs
CREATE TABLE IF NOT EXISTS `utilisateurs` (
`id` INT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(180) NOT NULL,
`roles` JSON NOT NULL,
`password` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UNIQ_EMAIL` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table : client
CREATE TABLE IF NOT EXISTS `client` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`family` VARCHAR(100) NOT NULL,
`age` INT NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci AUTO_INCREMENT=9;
-- ==========================================
-- 3. DONNÉES DE TEST
-- ==========================================
-- Utilisateurs (mots de passe hashés avec bcrypt, valeur test = 'password')
INSERT INTO `utilisateurs` (`email`, `roles`, `password`) VALUES
('admin@exemple.com', '["ROLE_ADMIN", "ROLE_USER"]', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'),
('user@exemple.com', '["ROLE_USER"]', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
-- Clients (8 entrées pour que le prochain AUTO_INCREMENT soit bien 9)
INSERT INTO `client` (`name`, `family`, `age`) VALUES
('Jean', 'Dupont', 34),
('Marie', 'Lefèvre', 28),
('Karim', 'Benali', 41),
('Sophie', 'Martin', 25),
('Lucas', 'Bernard', 30),
('Emma', 'Petit', 22),
('Hugo', 'Robert', 45),
('Léa', 'Moreau', 37);
Créer la table produits
mon-projet-mvvm/
│
├── viewmodels/
│ └── ProduitViewModel.php
│
├── models/
│ └── Produit.php
│
├── views/
│ └── produit/
│ └── index.php
│
├── core/
│ └── BaseViewModel.php
│
└── public/
└── index.php
