Aller au contenu

Décomposition en classes

Ce guide applique à un cas concret les notions avancées de programmation orientée objet avec PHP : composition, injection par le constructeur, espaces de noms et chargement automatique.

Nous reprenons le cas de l’Érablière tel que présenté dans le guide sur les opérations CRUD.

La page produits.php écrite dans le guide précédent fonctionne parfaitement et permet bien de lister les produits, en ajouter, en retirer, rediriger, afficher des messages.
Cependant, tout cela se trouve dans un seul fichier, que tu as peut-être trouvé lourd à écrire et comprendre en un clin d’oeil.

Examinons-le de plus près.

produits.php, ligne par ligne
Choisissez une responsabilité pour isoler ses lignes.
8 responsabilités, 54 lignes
Chaque couleur correspond à une raison différente de modifier ce fichier.
<?php require_once __DIR__ . '/includes/db.php';require_once __DIR__ . '/includes/validation.php';session_start(); $categories = require __DIR__ . '/includes/categories.php';$erreurs    = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') {    $action = $_POST['action'] ?? '';     if ($action === 'creer') {        [$erreurs, $valeurs] = validerProduit($_POST, $categories);         if (!$erreurs) {            $insertion = $pdo->prepare(                'INSERT INTO produits (nom, categorie, prix, stock)                 VALUES (:nom, :categorie, :prix, :stock)'            );            $insertion->execute($valeurs);             $_SESSION['message'] = 'Produit « ' . $valeurs['nom'] . ' » ajouté.';            header('Location: produits.php', true, 303);            exit;        }    }     if ($action === 'supprimer') {        $id = filter_var($_POST['id'] ?? '', FILTER_VALIDATE_INT);         if ($id !== false) {            $suppression = $pdo->prepare('DELETE FROM produits WHERE id = :id');            $suppression->execute(['id' => $id]);             $_SESSION['message'] = $suppression->rowCount() === 1                ? 'Produit supprimé.'                : "Ce produit n'existait plus.";        }         header('Location: produits.php', true, 303);        exit;    }} $produits = $pdo    ->query('SELECT id, nom, categorie, prix, stock FROM produits ORDER BY nom')    ->fetchAll(); $message = $_SESSION['message'] ?? null;unset($_SESSION['message']); include __DIR__ . '/includes/header.php';?> <?php if ($message !== null): ?>  <p class="message"><?php echo htmlspecialchars($message); ?></p><?php endif; ?> <?php foreach ($produits as $produit): ?>  <tr>    <td><?php echo htmlspecialchars($produit['nom']); ?></td>    <td><?php echo number_format((float) $produit['prix'], 2, ',', ' '); ?> $</td>    <td>      <a href="produit-modifier.php?id=<?php echo (int) $produit['id']; ?>">Modifier</a>      <form method="post" action="produits.php">        <input type="hidden" name="action" value="supprimer">        <input type="hidden" name="id" value="<?php echo (int) $produit['id']; ?>">        <button type="submit">Supprimer</button>      </form>    </td>  </tr><?php endforeach; ?> <input type="text" name="nom" value="<?php echo valeurSoumise('nom'); ?>"><?php messageErreur($erreurs, 'nom'); ?> <?php include __DIR__ . '/includes/footer.php'; ?>
La validation a déjà quitté le fichier pour une fonction : c'est un premier découpage. Il reste pourtant 7 autres raisons de rouvrir cette page, et chacune oblige à relire les autres pour ne rien casser.

Huit couleurs ont été nécessaires ici, donc huit raisons différentes d’ouvrir ce fichier pour le modifier.

  • Changer le texte d’un message oblige à relire le SQL qui l’entoure.
  • Modifier la structure des données utilisées par la page oblige à retrouver les requêtes SQL dispersées dans le fichier.

Rien de tout cela n’est très grave sur un fichier de soixante lignes. La difficulté apparaît quand les pages se multiplient et que les mêmes requêtes y sont recopiées.

VérificationUne application mobile a besoin de la liste des produits au format JSON. Quelles lignes de produits.php pouvez-vous réutiliser telles quelles ?

Aucune, en pratique. La lecture de la liste est prise entre le traitement du formulaire et le HTML ; pour l’obtenir ailleurs, il faudrait recopier la requête SQL et la conversion des lignes. Dès que la table change, les deux copies doivent changer ensemble, et rien ne garantit qu’on pense aux deux. C’est le symptôme typique d’une responsabilité qui n’a pas de place à elle.

Une classe devrait n’avoir qu’une seule raison de changer.

Ce principe, formulé par Robert C. Martin, ne dit pas qu’une classe ne doit faire qu’une seule chose ni contenir qu’une seule méthode. Un dépôt de produits peut lister, ajouter et supprimer : ces trois opérations changent pour la même raison, une modification de la table. Le principe demande plutôt que deux préoccupations sans rapport, comme le SQL et la mise en page, n’obligent pas à ouvrir le même fichier.

Appliqué à produits.php, le principe répartit les responsabilités ainsi.

Responsabilité Responsable Encapsulation
Accès aux données ProduitRepository le SQL, les noms de colonnes
Règles de validation ValidateurProduit le détail des contrôles de chaque champ
État entre requêtes MessageFlash l’usage de $_SESSION
Forme d’un produit Produit la représentation des données en mémoire
Connexion à la base includes/db.php les identifiants
Lecture de la requête, navigation, présentation restent dans la page traités à la page sur les services et MVC
erabliere/
├── src/
│ ├── autoload.php
│ ├── Modele/
│ │ ├── Produit.php
│ │ ├── ProduitRepository.php
│ │ └── ValidateurProduit.php
│ └── Web/
│ └── MessageFlash.php
├── includes/ db.php, categories.php, header.php, footer.php
├── produits.php
└── produit-modifier.php
src/Modele/Produit.php
<?php
namespace App\Modele;
class Produit
{
public function __construct(
private ?int $id,
private string $nom,
private string $categorie,
private float $prix,
private int $stock
) {}
public function getId(): ?int { return $this->id; }
public function getNom(): string { return $this->nom; }
public function getCategorie(): string { return $this->categorie; }
public function getPrix(): float { return $this->prix; }
public function getStock(): int { return $this->stock; }
public function estDisponible(): bool
{
return $this->stock > 0;
}
}

Le point d’interrogation de ?int indique que l’identifiant peut valoir null : c’est le cas d’un produit qui vient d’être saisi et que la base n’a pas encore numéroté.

src/Modele/ProduitRepository.php

Le seul endroit du programme qui connaît la table des produits.

La spécification
  • Seul point d’accès à la table des produits.
  • Reçoit une connexion déjà ouverte.
  • Liste les produits, triés par nom.
  • Trouve un produit par identifiant ; signale l’absence sans erreur.
  • Ajoute un produit et rend l’identifiant attribué.
  • Modifie un produit existant.
  • Retire un produit et indique si une ligne a été retirée.
  • Reçoit et rend des objets produit, jamais des lignes brutes.
src/Modele/ValidateurProduit.php

Les exigences qu'un produit doit respecter, et sa construction à partir d'un formulaire.

La spécification
  • Règles communes à l’ajout et à la correction d’un produit.
  • Reçoit la liste des catégories admises à la construction.
  • Nom : obligatoire, 100 caractères au plus.
  • Catégorie : doit figurer dans la liste admise.
  • Prix : nombre décimal positif ou nul.
  • Stock : nombre entier positif ou nul.
  • Rend les anomalies, rattachées à leur champ.
src/Web/MessageFlash.php
<?php
namespace App\Web;
class MessageFlash
{
public function deposer(string $texte): void
{
$_SESSION['message'] = $texte;
}
public function relever(): ?string
{
$texte = $_SESSION['message'] ?? null;
unset($_SESSION['message']);
return $texte;
}
}

La classe est courte, mais elle est désormais le seul endroit qui connaît la clé 'message' et la règle « lire puis effacer ». Remplacer la session par un autre mécanisme ne toucherait qu’elle.

produits.php
│
├── ValidateurProduit
│
├── ProduitRepository ──> PDO
│ │
│ └──> Produit
│
└── MessageFlash ──> session

La page utilise maintenant ces classes au lieu de faire leur travail.

<?php
use App\Modele\ProduitRepository;
use App\Modele\ValidateurProduit;
use App\Web\MessageFlash;
require_once __DIR__ . '/src/autoload.php';
require_once __DIR__ . '/includes/db.php';
session_start();
$depot = new ProduitRepository($pdo);
$validateur = new ValidateurProduit(require __DIR__ . '/includes/categories.php');
$flash = new MessageFlash();
$erreurs = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'creer') {
$erreurs = $validateur->valider($_POST);
if (!$erreurs) {
$produit = new Produit(
null,
trim($_POST['nom']),
trim($_POST['categorie']),
(float) $_POST['prix'],
(int) $_POST['stock']
);
$depot->ajouter($produit);
$flash->deposer('Produit « ' . $produit->getNom() . ' » ajouté.');
header('Location: produits.php', true, 303);
exit;
}
}
if ($action === 'supprimer') {
$id = filter_var($_POST['id'] ?? '', FILTER_VALIDATE_INT);
if ($id !== false) {
$flash->deposer($depot->supprimer($id) ? 'Produit supprimé.' : "Ce produit n'existait plus.");
}
header('Location: produits.php', true, 303);
exit;
}
}
$produits = $depot->tous();
$message = $flash->relever();
// La partie HTML suit, avec $produit->getNom(), $produit->getPrix(), etc.

Il n’y a plus une ligne de SQL ni une seule mention de $_SESSION dans la page. Les règles de validation vivent dans une classe que produit-modifier.php peut réutiliser telle quelle.

Un futur point d’accès API destiné à l’application mobile peut maintenant obtenir les mêmes produits avec $depot->tous(), puis les convertir en JSON, sans recopier le SQL.

La page garde pourtant trois rôles qui ne lui reviennent pas vraiment :

  • elle assemble elle-même les objets dont elle a besoin, en choisissant les paramètres de chacun ;
  • elle connaît l’enchaînement de chaque opération : valider, construire, enregistrer, confirmer, dans cet ordre, sans rien oublier ;
  • elle mêle encore ce traitement au HTML qui suit.

La page sur les services et le modèle MVC confie l’enchaînement des opérations à des services, puis sépare traitement et présentation.

  • Une page qui mêle connexion, lecture de la requête, validation, SQL, redirection et HTML a autant de raisons de changer que de responsabilités, et chacune oblige à relire les autres.
  • Le principe de responsabilité unique demande qu’une classe n’ait qu’une seule raison de changer, ce qui n’implique pas qu’elle ne fasse qu’une seule chose.
  • Une entité représente une chose du domaine ; un dépôt est le seul à la lire et à l’écrire dans la base.
  • Le validateur regroupe les règles métier, réutilisables par l’ajout comme par la correction ; il vérifie, puis construit l’entité.
  • Isoler l’accès à la session dans une classe dédiée fait de celle-ci le seul endroit à modifier si le mécanisme change.
  • Après extraction, la page assemble encore les objets, enchaîne les opérations et produit le HTML : trois rôles qui relèvent des services et du modèle MVC.

Quiz. Selon le principe de responsabilité unique, une classe devrait…

Quiz. La colonne stock est renommée quantite. Avec la nouvelle organisation, quel fichier faut-il modifier en priorité ?

Quiz. La page appelle construire() sur des données qui n'ont pas été validées. Que se passe-t-il ?