Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 329 minIntermédiaire

MVC en PHP sans framework, avec PDO

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • organiser un projet PHP en dossiers public, src et templates ;
  • écrire un contrôleur frontal qui aiguille les requêtes ;
  • isoler le SQL dans des modèles PDO à requêtes préparées ;
  • écrire des vues qui échappent toutes les données affichées.

4 exercices corrigés

Faire du MVC en PHP sans framework, c'est construire soi-même les quatre pièces qu'un framework fournit : un contrôleur frontal qui reçoit toutes les requêtes, un routage qui choisit l'action, des contrôleurs qui coordonnent, des modèles qui exécutent le SQL avec PDO, et des vues qui affichent. Ce chapitre construit la partie « salles » de ResaSalles en PHP natif : la liste des salles, la page d'une salle et le formulaire de réservation. Il suppose que tu sais te connecter à MySQL avec PDO (PDO et requêtes préparées). Le cours PHP et MySQL applique la même organisation à toute une application, avec la connexion et les sessions (MVC en PHP sans framework).

L'arborescence du projet

text
resasalles/
├── config.php                 ← paramètres de connexion, hors de Git
├── public/                    ← le seul dossier visible depuis le web
│   ├── index.php              ← le contrôleur frontal
│   └── style.css
├── src/
│   ├── Database.php
│   ├── Model/
│   │   ├── SalleModel.php
│   │   └── ReservationModel.php
│   └── Controller/
│       └── SalleController.php
└── templates/                 ← les vues
    ├── layout.php
    ├── erreur.php
    └── salles/
        ├── liste.php
        └── detail.php

Le dossier le plus important est public/. C'est la racine web : le serveur (Apache, Nginx ou le serveur intégré de PHP) ne sert que ce qu'il contient. Le code des modèles, des contrôleurs, des vues et surtout config.php, qui contient le mot de passe de la base, est en dehors : aucune adresse ne permet de les télécharger ou de les exécuter directement.

La configuration et la connexion

php
<?php
// config.php : hors du dossier public et ajouté au .gitignore
return [
    'dsn' => 'mysql:host=localhost;dbname=resasalles;charset=utf8mb4',
    'utilisateur' => 'resasalles_app',
    'mot_de_passe' => 'change-moi',
];
php
<?php
// src/Database.php
declare(strict_types=1);

final class Database
{
    public static function connexion(array $config): PDO
    {
        return new PDO($config['dsn'], $config['utilisateur'], $config['mot_de_passe'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
    }
}

Le compte resasalles_app n'a que les droits dont l'application a besoin (SELECT, INSERT, UPDATE, DELETE sur la base resasalles), jamais root (droits et moindre privilège). Les trois options PDO lèvent une exception en cas d'erreur SQL, renvoient les lignes sous forme de tableaux associatifs, et font préparer les requêtes par le serveur MySQL.

Le contrôleur frontal et le routage

Toutes les requêtes arrivent dans public/index.php. Il charge les classes, assemble les objets, puis regarde la méthode et le chemin pour appeler la bonne action.

php
<?php
// public/index.php : le contrôleur frontal
declare(strict_types=1);

// Avec le serveur intégré de PHP, laisser passer les fichiers qui existent (CSS, images).
if (PHP_SAPI === 'cli-server' && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
    return false;
}

session_start();

require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Model/SalleModel.php';
require __DIR__ . '/../src/Model/ReservationModel.php';
require __DIR__ . '/../src/Controller/SalleController.php';

// On assemble les objets ici, une fois pour toutes.
$pdo = Database::connexion(require __DIR__ . '/../config.php');
$controleur = new SalleController(new SalleModel($pdo), new ReservationModel($pdo));

// Le routage : méthode HTTP + chemin → action du contrôleur.
$methode = $_SERVER['REQUEST_METHOD'];
$chemin = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($methode === 'GET' && $chemin === '/salles') {
    $controleur->liste();
} elseif ($methode === 'GET' && preg_match('#^/salles/(\d+)$#', $chemin, $m)) {
    $controleur->detail((int) $m[1]);
} elseif ($methode === 'POST' && preg_match('#^/salles/(\d+)/reservations$#', $chemin, $m)) {
    $controleur->reserver((int) $m[1]);
} else {
    $controleur->introuvable();
}

parse_url(..., PHP_URL_PATH) retire la chaîne de requête : /salles/2?tri=nom donne /salles/2. L'expression régulière #^/salles/(\d+)$# n'accepte qu'un nombre après /salles/ et le capture dans $m[1] ; (int) le convertit. Une adresse comme /salles/abc ou /salles/2/supprimer ne correspond à aucune route et finit en 404.

Pour lancer l'application en développement, le serveur intégré de PHP suffit. Le dernier argument désigne index.php comme script routeur : toutes les requêtes passent par lui.

bash
php -S localhost:8000 -t public public/index.php

En production, on configure le serveur web pour envoyer vers index.php toute adresse qui ne correspond pas à un fichier : FallbackResource /index.php dans la configuration d'Apache, try_files $uri /index.php$is_args$args; dans celle de Nginx.

Les modèles : le SQL et rien d'autre

php
<?php
// src/Model/SalleModel.php
declare(strict_types=1);

final class SalleModel
{
    public function __construct(private PDO $pdo) {}

    public function lister(): array
    {
        return $this->pdo->query('SELECT id, nom, capacite, visio FROM salle ORDER BY nom')->fetchAll();
    }

    public function trouverParId(int $id): ?array
    {
        $requete = $this->pdo->prepare('SELECT id, nom, capacite, visio FROM salle WHERE id = ?');
        $requete->execute([$id]);
        return $requete->fetch() ?: null;
    }
}
php
<?php
// src/Model/ReservationModel.php
declare(strict_types=1);

final class ReservationModel
{
    public function __construct(private PDO $pdo) {}

    public function listerParSalle(int $salleId): array
    {
        $requete = $this->pdo->prepare(
            'SELECT debut, fin, objet FROM reservation WHERE salle_id = ? ORDER BY debut'
        );
        $requete->execute([$salleId]);
        return $requete->fetchAll();
    }

    public function existeChevauchement(int $salleId, string $debut, string $fin): bool
    {
        $requete = $this->pdo->prepare(
            'SELECT COUNT(*) FROM reservation WHERE salle_id = ? AND debut < ? AND fin > ?'
        );
        $requete->execute([$salleId, $fin, $debut]);
        return (int) $requete->fetchColumn() > 0;
    }

    public function ajouter(int $salleId, int $employeId, string $debut, string $fin, string $objet, int $participants): int
    {
        $requete = $this->pdo->prepare(
            'INSERT INTO reservation (salle_id, employe_id, debut, fin, objet, participants)
             VALUES (:salle, :employe, :debut, :fin, :objet, :participants)'
        );
        $requete->execute([
            'salle' => $salleId,
            'employe' => $employeId,
            'debut' => $debut,
            'fin' => $fin,
            'objet' => $objet,
            'participants' => $participants,
        ]);
        return (int) $this->pdo->lastInsertId();
    }
}

Trois points à remarquer :

  • public function __construct(private PDO $pdo) {} est la promotion de propriété de PHP 8 : le paramètre devient directement une propriété privée. Le modèle reçoit sa connexion, il ne la crée pas : c'est déjà de l'injection de dépendances (chapitre 7).
  • Toutes les valeurs venues de l'extérieur passent par des requêtes préparées (? ou :nom). Comme tout le SQL est dans les modèles, c'est le seul dossier à relire pour vérifier qu'il n'y a pas d'injection SQL.
  • La condition de chevauchement debut < :fin AND fin > :debut est la formule classique : deux intervalles se chevauchent si chacun commence avant la fin de l'autre. Une réservation de 9 h à 10 h 30 et une autre de 10 h 30 à 11 h 30 ne se chevauchent pas : elles se touchent.

Le contrôleur : coordonner

php
<?php
// src/Controller/SalleController.php
declare(strict_types=1);

final class SalleController
{
    public function __construct(
        private SalleModel $salles,
        private ReservationModel $reservations,
    ) {}

    public function liste(): void
    {
        $this->afficher('salles/liste', ['titre' => 'Les salles', 'salles' => $this->salles->lister()]);
    }

    public function detail(int $id, array $erreurs = []): void
    {
        $salle = $this->salles->trouverParId($id);
        if ($salle === null) {
            $this->introuvable();
            return;
        }
        $this->afficher('salles/detail', [
            'titre' => $salle['nom'],
            'salle' => $salle,
            'reservations' => $this->reservations->listerParSalle($id),
            'erreurs' => $erreurs,
        ]);
    }

    public function reserver(int $salleId): void
    {
        $salle = $this->salles->trouverParId($salleId);
        if ($salle === null) {
            $this->introuvable();
            return;
        }
        $employeId = $_SESSION['employe_id'] ?? null;
        if ($employeId === null) {
            http_response_code(401);
            $this->afficher('erreur', ['titre' => 'Connexion requise', 'message' => 'Connecte-toi pour réserver.']);
            return;
        }

        // Lire et convertir la saisie (le « ! » remet à zéro les secondes non saisies).
        $debut = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i', $_POST['debut'] ?? '');
        $fin = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i', $_POST['fin'] ?? '');
        $objet = trim($_POST['objet'] ?? '');
        $participants = (int) ($_POST['participants'] ?? 0);

        // Les règles de gestion : au chapitre 6, elles partiront dans un service.
        $erreurs = [];
        if ($debut === false || $fin === false || $objet === '') {
            $erreurs[] = 'Remplis toutes les zones.';
        } else {
            if ($fin <= $debut) {
                $erreurs[] = 'La fin doit être après le début.';
            }
            if ($debut < new DateTimeImmutable()) {
                $erreurs[] = 'Impossible de réserver dans le passé.';
            }
            if ($fin->getTimestamp() - $debut->getTimestamp() > 4 * 3600) {
                $erreurs[] = 'Une réservation dure 4 heures au plus.';
            }
            if ($participants < 1) {
                $erreurs[] = 'Indique au moins 1 participant.';
            } elseif ($participants > $salle['capacite']) {
                $erreurs[] = "La salle {$salle['nom']} accueille {$salle['capacite']} personnes au plus.";
            }
            if ($erreurs === [] && $this->reservations->existeChevauchement(
                $salleId, $debut->format('Y-m-d H:i:s'), $fin->format('Y-m-d H:i:s'))) {
                $erreurs[] = 'La salle est déjà réservée sur ce créneau.';
            }
        }

        if ($erreurs !== []) {
            http_response_code(422);
            $this->detail($salleId, $erreurs);
            return;
        }

        $this->reservations->ajouter($salleId, (int) $employeId, $debut->format('Y-m-d H:i:s'),
            $fin->format('Y-m-d H:i:s'), $objet, $participants);
        header('Location: /salles/' . $salleId, true, 303);   // Post/Redirect/Get
    }

    public function introuvable(): void
    {
        http_response_code(404);
        $this->afficher('erreur', ['titre' => 'Page introuvable', 'message' => "Cette page n'existe pas."]);
    }

    private function afficher(string $vue, array $donnees): void
    {
        extract($donnees, EXTR_SKIP);   // $titre, $salles… deviennent des variables pour la vue
        require __DIR__ . '/../../templates/layout.php';
    }
}

Le contrôleur ne contient ni SQL ni HTML. Il lit la requête ($_POST, $_SESSION), appelle les modèles, choisit le code de statut et la vue. Les actions liste et detail sont minces ; l'action reserver l'est moins, parce qu'elle porte encore les règles de gestion. C'est volontaire : le chapitre 6 les déplace dans un service, et tu verras ce que le contrôleur y gagne.

La méthode privée afficher est le seul endroit qui sait où sont les vues. extract() transforme le tableau ['titre' => …, 'salles' => …] en variables $titre et $salles ; EXTR_SKIP empêche une clé d'écraser une variable existante comme $vue.

Les vues : afficher, et échapper

php
<?php // templates/layout.php ?>
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title><?= htmlspecialchars($titre) ?> · ResaSalles</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <header><a href="/salles">ResaSalles</a></header>
  <main>
    <?php require __DIR__ . '/' . $vue . '.php'; ?>
  </main>
</body>
</html>
php
<?php // templates/salles/liste.php ?>
<h1>Les salles</h1>
<ul>
  <?php foreach ($salles as $salle): ?>
    <li>
      <a href="/salles/<?= $salle['id'] ?>"><?= htmlspecialchars($salle['nom']) ?></a>
      (<?= $salle['capacite'] ?> places<?= $salle['visio'] ? ', visio' : '' ?>)
    </li>
  <?php endforeach; ?>
</ul>
php
<?php // templates/salles/detail.php ?>
<h1><?= htmlspecialchars($salle['nom']) ?></h1>
<p><?= $salle['capacite'] ?> places<?= $salle['visio'] ? ', équipée pour la visioconférence' : '' ?></p>

<h2>Réservations</h2>
<?php if ($reservations === []): ?>
  <p>Aucune réservation.</p>
<?php else: ?>
  <ul>
    <?php foreach ($reservations as $r): ?>
      <li>
        <?= (new DateTimeImmutable($r['debut']))->format('d/m/Y H:i') ?>
        → <?= (new DateTimeImmutable($r['fin']))->format('H:i') ?> :
        <?= htmlspecialchars($r['objet']) ?>
      </li>
    <?php endforeach; ?>
  </ul>
<?php endif; ?>

<h2>Réserver</h2>
<?php foreach ($erreurs as $erreur): ?>
  <p class="erreur"><?= htmlspecialchars($erreur) ?></p>
<?php endforeach; ?>
<form method="post" action="/salles/<?= $salle['id'] ?>/reservations">
  <label>Début <input type="datetime-local" name="debut" required></label>
  <label>Fin <input type="datetime-local" name="fin" required></label>
  <label>Objet <input name="objet" maxlength="100" required></label>
  <label>Participants <input type="number" name="participants" min="1" value="1" required></label>
  <button>Réserver</button>
</form>

Les vues utilisent la syntaxe alternative de PHP (foreach (...): … endforeach;), plus lisible au milieu du HTML que les accolades, et la balise courte <?=, qui affiche une valeur. Chaque texte qui vient de la base ou de l'utilisateur passe par htmlspecialchars(), qui transforme <, >, & et les guillemets en entités HTML. Un nom de salle comme <script>…</script> s'affiche alors comme du texte au lieu de s'exécuter. Les nombres (id, capacite) viennent de colonnes INT : ce sont des entiers, il n'y a rien à échapper.

Ce qui se passe quand on réserve

  1. Le navigateur envoie POST /salles/1/reservations avec debut=2026-12-03T14:00, fin=2026-12-03T15:00, objet=Revue de projet, participants=6.
  2. index.php reconnaît la route et appelle $controleur->reserver(1).
  3. Le contrôleur lit la salle par le modèle, vérifie la session, convertit la saisie et applique les règles. Le modèle vérifie le chevauchement en base.
  4. Si tout va bien, le modèle insère la ligne, et le contrôleur répond 303 See Other avec l'en-tête Location: /salles/1.
  5. Le navigateur suit la redirection : GET /salles/1 affiche la salle avec la nouvelle réservation.
  6. En cas d'erreur, le contrôleur répond 422 et réaffiche la page de la salle avec la liste des erreurs au-dessus du formulaire.

Ajouter une fonctionnalité : la méthode

C'est la situation de l'heure de réalisation de l'E6, où tu fais évoluer ta propre application. Ici, la direction demande d'afficher, sur la page d'une salle, le nom de la personne qui a fait chaque réservation. Avec MVC, le chemin est toujours le même, de la base vers l'écran :

  1. Modèle. Modifier listerParSalle : jointure avec employe, colonnes prenom et nom en plus.
  2. Contrôleur. Rien à changer : il passe déjà la liste à la vue.
  3. Vue. Ajouter htmlspecialchars($r['prenom'] . ' ' . $r['nom']) dans la ligne de la liste.
  4. Test. Afficher la page d'une salle qui a des réservations, et d'une salle qui n'en a pas.
php
$requete = $this->pdo->prepare(
    'SELECT r.debut, r.fin, r.objet, e.prenom, e.nom
     FROM reservation r
     JOIN employe e ON e.id = r.employe_id
     WHERE r.salle_id = ?
     ORDER BY r.debut'
);

Exercices

Exercice 1 sur 4 Ajouter une route Réussi À revoir

On veut une page GET /mes-reservations qui liste les réservations de l'employé connecté, de la plus proche à la plus lointaine. Indique, fichier par fichier, ce qu'il faut ajouter : index.php, le modèle, le contrôleur et la vue. Écris la méthode du modèle.

Voir la réponseMasquer la réponse de l'exercice 1
  • public/index.php : une route de plus, avant le else : elseif ($methode === 'GET' && $chemin === '/mes-reservations') { $controleur->mesReservations(); }. On pourrait aussi créer un ReservationController, plus cohérent quand les actions sur les réservations se multiplient.
  • Modèle (ReservationModel) :
php
public function listerParEmploye(int $employeId): array
{
    $requete = $this->pdo->prepare(
        'SELECT r.id, r.debut, r.fin, r.objet, s.nom AS salle
         FROM reservation r
         JOIN salle s ON s.id = r.salle_id
         WHERE r.employe_id = ? AND r.fin >= NOW()
         ORDER BY r.debut'
    );
    $requete->execute([$employeId]);
    return $requete->fetchAll();
}
  • Contrôleur : une action mesReservations() qui lit $_SESSION['employe_id'] (401 si absent), appelle listerParEmploye et affiche la vue reservations/mes-reservations avec un titre.
  • Vue : une boucle qui affiche la date, la salle et l'objet, en échappant les textes avec htmlspecialchars.

La condition r.fin >= NOW() garde les réservations en cours et à venir. Le modèle ne lit pas la session : il reçoit l'identifiant en paramètre, ce qui le rend réutilisable (un administrateur pourra afficher les réservations de n'importe qui).

Tu avais trouvé ?

Exercice 2 sur 4 Trouver les failles d'une vue Réussi À revoir

Un collègue a écrit cette vue. Trouve les deux problèmes de sécurité et le problème d'architecture.

php
<h1>Salle <?= $_GET['nom'] ?></h1>
<?php $resas = $pdo->query('SELECT * FROM reservation WHERE salle_id = ' . $_GET['id'])->fetchAll(); ?>
<?php foreach ($resas as $r): ?>
  <p><?= $r['objet'] ?></p>
<?php endforeach; ?>
Voir la réponseMasquer la réponse de l'exercice 2
  • XSS : $_GET['nom'] et $r['objet'] sont affichés sans htmlspecialchars. Une adresse piégée ou un objet de réservation contenant du JavaScript s'exécuterait dans le navigateur des autres salariés.
  • Injection SQL : $_GET['id'] est concaténé dans la requête.
  • Architecture : la vue lit la requête HTTP ($_GET) et exécute du SQL. Le contrôleur doit lire et convertir l'identifiant, le modèle exécuter une requête préparée, et la vue recevoir $salle et $reservations tout prêts. Une fois remis en MVC, les deux failles disparaissent presque d'elles-mêmes : la requête préparée est dans le modèle, l'échappement dans la vue.

Tu avais trouvé ?

Exercice 3 sur 4 Pourquoi un dossier public ? Réussi À revoir

Un étudiant a placé tout son projet dans htdocs/resasalles/, y compris config.php et le dossier src/. Il affirme que « personne ne connaît l'adresse de ces fichiers ». Donne deux raisons pour lesquelles c'est un risque, et la correction.

Voir la réponseMasquer la réponse de l'exercice 3
  • Les noms comme config.php, src/, .git/ ou vendor/ sont devinables : des robots les testent en permanence sur tous les sites.
  • Si l'interpréteur PHP est mal configuré ou désactivé lors d'une mise à jour, le serveur renvoie le contenu des fichiers .php en texte, mot de passe compris. Un dossier .git accessible permet même de reconstruire tout le code.

Correction : faire pointer la racine du site sur resasalles/public/, qui ne contient que index.php et les fichiers statiques (CSS, images). Tout le reste est hors de portée d'une adresse web.

Tu avais trouvé ?

Exercice 4 sur 4 Lire un routage Réussi À revoir

Avec le fichier index.php du chapitre, quelle action est appelée, et avec quel paramètre, pour chacune de ces requêtes ?

  1. GET /salles
  2. GET /salles/12?onglet=planning
  3. POST /salles/12
  4. GET /salles/12/reservations
  5. POST /salles/12/reservations
Voir la réponseMasquer la réponse de l'exercice 4
  1. liste().
  2. detail(12) : parse_url a retiré ?onglet=planning avant le test du chemin.
  3. introuvable() : aucune route n'associe POST à /salles/12. Un routeur plus complet répondrait 405 Method Not Allowed, plus précis que 404.
  4. introuvable() : la route des réservations n'existe qu'en POST.
  5. reserver(12).

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : MVC avec Node.js et ExpressRevoir le sommaire du cours