Chapitre 1227 minIntermédiaire
MVC en PHP sans framework : front controller et routeur
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- faire passer toutes les requêtes par un contrôleur frontal ;
- écrire un routeur qui associe méthode et chemin à une action ;
- séparer modèle, vue et contrôleur dans une application PHP ;
- configurer la racine du site sur le dossier public.
4 exercices corrigés2 schémas
Organiser une application PHP sans framework, c'est faire passer toutes les requêtes par un point d'entrée unique, le contrôleur frontal (public/index.php), qui confie chaque requête à un routeur ; celui-ci appelle le contrôleur prévu pour l'adresse demandée, qui lit les données avec le modèle et produit la page avec une vue. Ce chapitre réorganise le CRUD de Prêtmat selon ce modèle MVC, en PHP natif : arborescence, contrôleur frontal, routeur, contrôleurs et vues. La théorie (rôle de chaque couche, variantes de MVC, comparaison avec d'autres architectures) est dans le cours Architecture MVC (le modèle MVC, MVC en PHP sans framework).
Pourquoi réorganiser
La version « une page PHP par écran » du chapitre 10 fonctionne, mais elle montre vite ses limites :
- Chaque page répète les mêmes inclusions et les mêmes contrôles. Une page où l'on a oublié
exigerRole()ouverifierCsrf()est une faille, et rien ne la signale. - Le code et le HTML sont mélangés dans les mêmes fichiers : difficile de relire la logique, difficile de changer l'apparence.
- Les adresses exposent la technique (
/modifier.php?id=4) et changent si l'on renomme un fichier. - Tous les fichiers de
public/sont exécutables depuis le navigateur, y compris ceux qu'on a oublié de protéger.
Avec un contrôleur frontal, il n'y a plus qu'un fichier PHP accessible (public/index.php), les contrôles communs sont faits une fois pour toutes les requêtes, et chaque morceau de code a sa place.
L'arborescence du projet
pretmat/
├── public/ ← racine du site (document root) : la seule partie accessible
│ ├── index.php ← le contrôleur frontal
│ ├── .htaccess ← redirige toutes les requêtes vers index.php (Apache)
│ ├── css/style.css
│ └── js/
├── src/ ← le code PHP, espace de noms App\
│ ├── Controleur/
│ │ ├── AuthControleur.php
│ │ └── MaterielControleur.php
│ ├── Depot/
│ │ └── MaterielDepot.php
│ ├── Modele/
│ │ ├── Categorie.php
│ │ ├── EtatMateriel.php
│ │ └── Materiel.php
│ ├── Routeur.php
│ ├── bootstrap.php
│ └── fonctions.php ← e(), flash(), rediriger(), rendre(), CSRF, authentification
├── templates/ ← les vues
│ ├── gabarit.php
│ └── materiels/
│ ├── liste.php
│ ├── fiche.php
│ └── formulaire.php
├── config/
│ └── config.php ← lit la configuration (chapitre 15)
├── var/
│ ├── log/ ← journaux de l'application
│ └── uploads/ ← photos déposées (chapitre 13)
├── sql/pretmat.sql
├── vendor/ ← généré par Composer, non versionné
├── .env ← secrets, jamais versionné
├── composer.json
└── compose.yaml
La règle d'or : la racine du site est le dossier public/, pas le dossier du projet. Si le serveur web servait la racine du projet, un visiteur pourrait télécharger .env (et ses mots de passe), composer.json, ou appeler directement un fichier de src/ ou de templates/ et le voir s'exécuter hors de tout contrôle.
Le contrôleur frontal
Le contrôleur frontal reçoit toutes les requêtes. Il prépare l'environnement, applique les contrôles communs, déclare les routes et laisse le routeur choisir le contrôleur.
<?php
declare(strict_types=1);
// public/index.php
use App\Controleur\AuthControleur;
use App\Controleur\MaterielControleur;
use App\Depot\MaterielDepot;
use App\Routeur;
// Serveur intégré de PHP : laisser servir directement les fichiers existants (CSS, images)
if (PHP_SAPI === 'cli-server' && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
return false;
}
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../src/bootstrap.php'; // configuration, gestion des erreurs, session, fonctions
// En-têtes de sécurité, pour toutes les réponses (chapitre 5)
header("Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
$methode = $_SERVER['REQUEST_METHOD'];
$chemin = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';
// Toutes les requêtes POST des pages web vérifient le jeton CSRF, ici et nulle part ailleurs.
// L'API JSON (chapitre 14) s'authentifie autrement.
if ($methode === 'POST' && !str_starts_with($chemin, '/api/')) {
verifierCsrf();
}
$materiels = new MaterielControleur(new MaterielDepot(bdd()));
$auth = new AuthControleur(bdd());
$routeur = new Routeur();
$routeur->get('/', fn() => rediriger('/materiels'));
$routeur->get('/connexion', $auth->formulaire(...));
$routeur->post('/connexion', $auth->connecter(...));
$routeur->post('/deconnexion', $auth->deconnecter(...));
$routeur->get('/materiels', $materiels->liste(...));
$routeur->get('/materiels/nouveau', $materiels->formulaireAjout(...));
$routeur->post('/materiels', $materiels->ajouter(...));
$routeur->get('/materiels/{id}', $materiels->fiche(...));
$routeur->get('/materiels/{id}/modifier', $materiels->formulaireModification(...));
$routeur->post('/materiels/{id}/modifier', $materiels->modifier(...));
$routeur->post('/materiels/{id}/supprimer', $materiels->supprimer(...));
$routeur->traiter($methode, $chemin);
$materiels->liste(...) crée une fonction qui appelle la méthode liste() de l'objet $materiels : le routeur l'exécutera seulement si la route correspond. La liste des routes est devenue la carte de l'application : en la lisant, on sait tout ce qu'elle sait faire.
Faire arriver toutes les requêtes sur index.php
Le serveur web doit envoyer à index.php toutes les requêtes qui ne correspondent pas à un fichier existant de public/ :
| Serveur | Configuration |
|---|---|
| Serveur intégré de PHP | php -S localhost:8000 -t public public/index.php : le dernier argument est le script qui reçoit chaque requête (d'où le return false pour les fichiers statiques) |
| Apache | Un fichier public/.htaccess contenant FallbackResource /index.php, et la racine du site réglée sur public/ |
| Nginx | root /var/www/pretmat/public; et, dans location /, try_files $uri /index.php$is_args$args; |
Avec Laragon ou XAMPP, règle la racine du site (virtual host) sur le dossier public du projet plutôt que d'ouvrir http://localhost/pretmat/public/ : les adresses de l'application commencent alors toutes par /, comme en production.
Le routeur
Le routeur associe une méthode HTTP et un chemin à une action. Les chemins peuvent contenir un paramètre, comme {id} dans /materiels/{id}. Une trentaine de lignes suffisent :
<?php
declare(strict_types=1);
// src/Routeur.php
namespace App;
use Closure;
final class Routeur
{
/** @var list<array{string, string, Closure}> */
private array $routes = [];
public function get(string $motif, Closure $action): void
{
$this->routes[] = ['GET', $motif, $action];
}
public function post(string $motif, Closure $action): void
{
$this->routes[] = ['POST', $motif, $action];
}
public function traiter(string $methode, string $chemin): void
{
$methodesPermises = [];
foreach ($this->routes as [$methodeRoute, $motif, $action]) {
// '/materiels/{id}' devient '#^/materiels/(?<id>\d+)$#'
$regex = '#^' . preg_replace('#\{(\w+)\}#', '(?<$1>\d+)', $motif) . '$#';
if (preg_match($regex, $chemin, $correspondances) !== 1) {
continue;
}
if ($methodeRoute !== $methode) {
$methodesPermises[] = $methodeRoute;
continue;
}
// Ne garder que les groupes nommés, convertis en entiers : ['id' => 4]
$parametres = array_map(intval(...), array_filter($correspondances, is_string(...), ARRAY_FILTER_USE_KEY));
$action(...$parametres);
return;
}
if ($methodesPermises !== []) {
http_response_code(405);
header('Allow: ' . implode(', ', $methodesPermises));
echo rendre('erreur', ['titre' => 'Méthode non autorisée']);
return;
}
http_response_code(404);
echo rendre('erreur', ['titre' => 'Page introuvable']);
}
}
| Requête | Résultat |
|---|---|
GET /materiels | liste() |
GET /materiels/4 | fiche(id: 4) : le paramètre nommé id est passé en argument nommé |
GET /materiels/nouveau | formulaireAjout() : nouveau n'est pas un nombre, la route {id} ne correspond pas |
POST /materiels/4/modifier | modifier(id: 4) |
DELETE /materiels | 405, avec l'en-tête Allow: GET, POST |
GET /materiels/abc ou /materiels/4/ | 404 |
Les paramètres de route ne sont que des nombres (\d+), convertis en entiers : un identifiant arrive dans le contrôleur déjà validé comme entier. Les ancres ^ et $ de l'expression régulière sont indispensables : sans elles, /x/materiels/4/y correspondrait aussi.
Les contrôleurs
Un contrôleur regroupe les actions qui concernent une même ressource. Chaque action vérifie les droits, lit les paramètres, appelle le modèle et choisit la vue ou la redirection. Elle ne contient ni SQL ni HTML.
<?php
declare(strict_types=1);
// src/Controleur/MaterielControleur.php
namespace App\Controleur;
use App\Depot\MaterielDepot;
use PDOException;
final class MaterielControleur
{
public function __construct(private MaterielDepot $depot)
{
}
public function fiche(int $id): void
{
$utilisateur = exigerConnexion();
$materiel = $this->depot->trouver($id);
if ($materiel === null) {
http_response_code(404);
echo rendre('erreur', ['titre' => 'Matériel introuvable']);
return;
}
echo rendre('materiels/fiche', [
'titre' => $materiel->reference,
'materiel' => $materiel,
'historique' => $this->depot->historique($id),
'estGestionnaire' => $utilisateur['role'] === 'gestionnaire',
]);
}
public function formulaireAjout(): void
{
exigerRole('gestionnaire');
echo rendre('materiels/formulaire', [
'titre' => 'Ajouter un matériel',
'action' => '/materiels',
'saisie' => [],
'erreurs' => [],
]);
}
public function ajouter(): void
{
exigerRole('gestionnaire');
['valeurs' => $valeurs, 'erreurs' => $erreurs] = validerMateriel($_POST);
if ($erreurs === []) {
try {
$id = $this->depot->ajouter($valeurs);
flash('succes', "Le matériel {$valeurs['reference']} a été ajouté.");
rediriger("/materiels/$id");
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) !== 1062) {
throw $e;
}
$erreurs['reference'] = 'Cette référence est déjà utilisée.';
}
}
echo rendre('materiels/formulaire', [
'titre' => 'Ajouter un matériel',
'action' => '/materiels',
'saisie' => $_POST,
'erreurs' => $erreurs,
]);
}
// liste(), formulaireModification(), modifier(), supprimer() : même principe
}
Les fonctions exigerConnexion(), flash(), validerMateriel() ou rendre() sont des fonctions globales, définies dans src/fonctions.php : depuis l'espace de noms App\Controleur, PHP les trouve sans use, car un nom de fonction inconnu dans un espace de noms est cherché ensuite dans l'espace global (ce n'est pas le cas pour les classes). Le dépôt gagne deux méthodes, ajouter() et historique(), sur le modèle de celles du chapitre 11.
rediriger() envoie la redirection et arrête le script ; son type de retour never dit à PHP (et à ton éditeur) que la fonction ne revient jamais :
<?php
function rediriger(string $url): never
{
header("Location: $url", true, 303);
exit;
}
Les vues et le gabarit commun
Une vue est un fichier de templates/ qui ne fait qu'afficher les données qu'on lui passe. La fonction rendre() produit la page : elle exécute la vue en capturant sa sortie avec ob_start(), puis place ce contenu dans le gabarit commun.
<?php
declare(strict_types=1);
// src/fonctions.php (extrait)
function rendre(string $vue, array $donnees = []): string
{
extract($donnees, EXTR_SKIP); // ['titre' => …] devient la variable $titre
ob_start();
require __DIR__ . "/../templates/$vue.php";
$contenu = ob_get_clean();
ob_start();
require __DIR__ . '/../templates/gabarit.php';
return ob_get_clean();
}
<?php
// templates/gabarit.php : attend $titre et $contenu
$connecte = utilisateurConnecte();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($titre) ?> | Prêtmat</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<a href="/materiels">Prêtmat</a>
<?php if ($connecte !== null): ?>
<span><?= e($connecte['nom']) ?></span>
<form method="post" action="/deconnexion"><?= champCsrf() ?><button>Se déconnecter</button></form>
<?php endif; ?>
</header>
<main>
<?php foreach (flashes() as $f): ?>
<p class="alerte alerte-<?= e($f['type']) ?>" role="status"><?= e($f['message']) ?></p>
<?php endforeach; ?>
<?= $contenu ?>
</main>
</body>
</html>
<?php
// templates/materiels/fiche.php : attend $materiel, $historique, $estGestionnaire
?>
<h1><?= e($materiel->reference) ?> : <?= e($materiel->libelle) ?></h1>
<p>Catégorie : <?= e($materiel->categorie->libelle()) ?></p>
<p>Achat : <?= e($materiel->dateAchat?->format('d/m/Y') ?? 'date inconnue') ?></p>
<?php if ($estGestionnaire): ?>
<a href="/materiels/<?= e($materiel->id) ?>/modifier">Modifier</a>
<form method="post" action="/materiels/<?= e($materiel->id) ?>/supprimer">
<?= champCsrf() ?>
<button>Supprimer</button>
</form>
<?php endif; ?>
Deux remarques de sécurité :
<?= $contenu ?>n'est pas échappé, et c'est voulu :$contenuest le HTML produit par la vue, dont chaque donnée a déjà été échappée avece(). C'est l'une des rares exceptions à la règle, et elle doit rester visible.- Le nom de la vue passé à
rendre()vient toujours du code, jamais de la requête. Unrendre($_GET['page'])permettrait d'inclure n'importe quel fichier PHP du serveur : c'est une inclusion de fichier local (Local File Inclusion), une faille grave.
Le parcours d'une requête
| Couche | Fichiers de Prêtmat | Rôle | Ce qu'elle ne fait pas |
|---|---|---|---|
| Modèle | src/Modele/, src/Depot/ | Les données et les règles métier : classes Materiel, Categorie, accès à MySQL | Pas de HTML, pas de $_POST |
| Vue | templates/ | L'affichage HTML, avec e() | Pas de SQL, pas de décision |
| Contrôleur | src/Controleur/ | Reçoit la requête, vérifie les droits, appelle le modèle, choisit la vue | Pas de SQL, pas de HTML |
| Contrôleur frontal et routeur | public/index.php, src/Routeur.php | Point d'entrée unique, contrôles communs, aiguillage |
À l'oral de l'E6, savoir montrer ce découpage dans ton propre code, et expliquer où tu ajouterais une nouvelle fonctionnalité, compte autant que le code lui-même (choisir l'architecture de ton projet E6).
Exercices
Exercice 1 sur 4 Ajouter une page « Mes emprunts » Réussi À revoir
Ajoute à l'application organisée en MVC la page GET /mes-emprunts, qui affiche les emprunts en cours de l'utilisateur connecté. Indique ce que tu écris dans chaque fichier : contrôleur frontal, contrôleur, dépôt, vue.
Voir la réponseMasquer la réponse de l'exercice 1
- Dépôt (
src/Depot/EmpruntDepot.php, nouvelle classe sur le modèle deMaterielDepot) : une méthodeenCoursPour(int $idUtilisateur): arrayqui exécute la requête préparéeSELECT m.reference, m.libelle, e.date_retour_prevue FROM emprunt e JOIN materiel m ON m.id = e.id_materiel WHERE e.id_utilisateur = :id AND e.date_retour IS NULL ORDER BY e.date_retour_prevue. - Contrôleur (
src/Controleur/EmpruntControleur.php) :
<?php
public function mesEmprunts(): void
{
$utilisateur = exigerConnexion();
echo rendre('emprunts/mes-emprunts', [
'titre' => 'Mes emprunts',
'emprunts' => $this->depot->enCoursPour($utilisateur['id']),
]);
}
- Contrôleur frontal :
$emprunts = new EmpruntControleur(new EmpruntDepot(bdd()));puis$routeur->get('/mes-emprunts', $emprunts->mesEmprunts(...));. - Vue (
templates/emprunts/mes-emprunts.php) : une boucleforeach ($emprunts as $e)qui affiche chaque ligne avece(), et un message « Aucun emprunt en cours » si la liste est vide.
L'identifiant de l'utilisateur vient de la session, pas de l'URL : aucun risque d'IDOR. Pour Julie, la page affiche la caméra CAM-0001, à rendre le 30 octobre.
Tu avais trouvé ?
Exercice 2 sur 4 La racine du site Réussi À revoir
Un étudiant a configuré Apache avec la racine du site sur le dossier du projet (/var/www/pretmat) au lieu de /var/www/pretmat/public. Son application fonctionne à l'adresse http://serveur/public/. Cite trois adresses qu'un visiteur pourrait ouvrir et ce qu'il obtiendrait.
Voir la réponseMasquer la réponse de l'exercice 2
http://serveur/.env: le fichier des secrets (mot de passe de la base, clés). Selon la configuration d'Apache, il est envoyé tel quel, en texte. C'est la fuite la plus grave.http://serveur/sql/pretmat.sql: le script de la base, avec la structure et les empreintes des comptes de démonstration.http://serveur/templates/materiels/fiche.phpouhttp://serveur/src/Depot/MaterielDepot.php: le fichier PHP est exécuté hors du contrôleur frontal, donc sans session, sans contrôle d'accès ni jeton CSRF. Au mieux une erreur qui révèle des chemins ; au pire une action exécutée sans contrôle.
On pourrait aussi citer composer.json, compose.yaml ou var/log/ (les journaux). Correction : DocumentRoot /var/www/pretmat/public. Un .htaccess qui interdit ces fichiers est un pansement : la bonne pratique est qu'ils ne soient jamais sous la racine du site (chapitre 15).
Tu avais trouvé ?
Exercice 3 sur 4 Classer les fichiers Réussi À revoir
Pour chaque extrait, dis à quelle couche il appartient (modèle, vue, contrôleur) et s'il est à sa place. Corrige ceux qui ne le sont pas.
- Dans
templates/materiels/liste.php:<?php $stmt = bdd()->query('SELECT * FROM materiel'); ?> - Dans
src/Depot/MaterielDepot.php:public function compterParCategorie(): arrayqui exécute unGROUP BY. - Dans
src/Controleur/MaterielControleur.php:echo '<h1>' . e($materiel->reference) . '</h1>'; - Dans
src/Modele/Materiel.php:public function peutEtrePrete(): bool. - Dans
src/Depot/MaterielDepot.php:if (!isset($_SESSION['utilisateur'])) { header('Location: /connexion'); exit; }
Voir la réponseMasquer la réponse de l'exercice 3
- Vue qui fait du SQL : mal placé. La requête va dans une méthode du dépôt (
$depot->tous()), appelée par le contrôleur, qui passe le résultat à la vue. - Modèle (dépôt) : à sa place.
- Contrôleur qui produit du HTML : mal placé. Le HTML va dans une vue, rendue par
rendre('materiels/fiche', [...]). - Modèle : à sa place. C'est une règle métier, qui ne dépend ni de l'affichage ni de la requête.
- Dépôt qui gère la session et la redirection : mal placé. Le dépôt ne connaît ni HTTP ni la session : il pourrait servir dans un script en ligne de commande ou dans l'API. Le contrôle d'accès va dans le contrôleur (
exigerConnexion()).
Tu avais trouvé ?
Exercice 4 sur 4 Un routeur défaillant Réussi À revoir
Une version du routeur construit l'expression régulière ainsi : '#' . preg_replace('#\{(\w+)\}#', '(?<$1>.+)', $motif) . '#'. Donne deux requêtes qui produisent un résultat inattendu avec les routes de Prêtmat, et corrige.
Voir la réponseMasquer la réponse de l'exercice 4
Sans les ancres ^ et $, le motif peut correspondre à une partie du chemin ; avec .+, le paramètre accepte n'importe quoi, y compris des barres obliques.
GET /materiels/4/modifiercorrespond aussi à la route/materiels/{id}, avecid=4/modifier: selon l'ordre des routes, c'est la fiche qui est appelée, et la conversion en entier donne 4 sans erreur. Une page s'affiche à la place d'une autre.GET /n-importe-quoi/materielscorrespond à/materiels: la liste s'affiche pour une adresse qui devrait donner une 404.GET /materiels/abcappelle la fiche avecid=abc, converti en 0 parintval().
Correction : '#^' . preg_replace('#\{(\w+)\}#', '(?<$1>\d+)', $motif) . '$#', avec des ancres et un paramètre limité aux chiffres.
Tu avais trouvé ?