Lesson 13/13 20 min

Project 13: the pattern shop (5 design patterns that earn their keep)

Build an e-commerce cart with AI, one pattern at a time: Strategy, Decorator, State, Observer, Facade, each called in by a real need.

Le projet : un panier qui grandit sans se casser

Jusqu'ici, chaque projet ajoutait une capacité : recevoir (projet 10), stocker (projet 11), protéger (projet 12). Celui-ci s'attaque à autre chose : la façon dont ton code accueille le changement. Parce qu'une boutique, ce n'est jamais fini : la patronne veut un nouveau transporteur lundi, une promo mardi, et mercredi « quand c'est payé, préviens aussi la compta ». Chaque demande est minuscule. C'est leur accumulation qui tue les projets.

On va construire le tunnel de commande d'une petite boutique en PHP, avec l'IA, et on va le faire un besoin à la fois. À chaque besoin, on verra le code naïf craquer, et la réponse qui tient la route portera un nom : un design pattern (un patron de conception : une solution nommée à un problème qui revient sans cesse). Cinq patterns vont gagner leur place. Et deux que l'IA voulait ajouter seront refusés : c'est aussi ça, concevoir.

Ce projet est la version « les mains dans le code » de deux fiches de la bibliothèque : Design Patterns (la source, 1994) et Head First Design Patterns (le manuel illustré). Tu n'as pas besoin de les avoir lues : tout est réexpliqué ici. Il te faut juste les bases de la POO PHP (classes, interfaces), vues dans le cours PHP orienté objet.

Ce que tu vas savoir faire
  • Poser la question qui précède tout pattern : « qu'est-ce qui va changer ? », et localiser l'axe de variation.
  • Implémenter Strategy (frais de port interchangeables), Decorator (promo empilable), State (commande à étapes), Observer (événements) et Facade (un point d'entrée).
  • Assembler le tout à la racine du programme, par injection, sans variable globale.
  • Reconnaître un pattern de trop, et le refuser avec un argument.
Tu as réussi quand...
  • ajouter un transporteur = créer une seule classe, sans toucher au panier, au checkout ni aux autres transporteurs ;
  • la promo « offert dès 50 € » marche avec n'importe quel transporteur, y compris celui de demain ;
  • le bouton Annuler fait trois choses différentes selon l'étape de la commande, et la méthode annuler() ne contient aucun if.

Le journal de bord (la vraie vie)

Comme toujours, on te montre la boucle telle qu'elle s'est passée : le premier jet de l'IA, l'endroit exact où il craque, et le recadrage. La différence avec les projets précédents : ici, le premier jet marche. C'est sa capacité à encaisser la prochaine demande qu'on va juger.

Prompt 1, on demande large

En PHP objet, fais-moi le tunnel de commande d'une boutique : un panier, des frais de port (Colissimo 4,99 € ou retrait gratuit), une promo « livraison offerte dès 50 € », et une commande qu'on peut payer puis annuler.

L'IA sort une classe Boutique de 200 lignes qui fait tout. Et au cœur, cette méthode :

public function fraisDePort(): float {
    $frais = 0;
    if ($this->transporteur === 'colissimo') {
        $frais = 4.99;
    } elseif ($this->transporteur === 'retrait') {
        $frais = 0;
    }
    if ($this->promo && $this->total() >= 50) {
        $frais = 0;
    }
    return $frais;
}

Ça marche. Je teste, les totaux sont bons. Puis j'envoie la demande de la patronne : « ajoute Chronopost, 9,99 € ». Et c'est là que je découvre que ce if/elseif sur le transporteur n'existe pas qu'ici : il est aussi dans la méthode qui affiche le récap, et dans celle qui génère l'email. Trois copies de la même liste.

Prédis avant de lire la suite

J'ajoute Chronopost dans le fraisDePort() ci-dessus, mais j'oublie de l'ajouter dans la méthode du récapitulatif. À ton avis : est-ce que ça plante avec une erreur, ou est-ce que ça fait quelque chose de pire ?

Vérifier ma prédiction

Quelque chose de pire : ça ne plante pas du tout. Le client choisit Chronopost, est facturé 9,99 €... et son récapitulatif tombe dans le cas « inconnu » du deuxième if/elseif, qui affiche 0 € ou « Colissimo » selon la version. Aucune erreur, aucun log : juste un client facturé une chose et informé d'une autre. C'est le bug silencieux, le plus cher de tous, et il vient d'une seule cause : la même connaissance (la liste des transporteurs) est dupliquée à trois endroits qui peuvent diverger.

Besoin n°1 : les frais varient → Strategy

La liste des transporteurs est dupliquée dans 3 méthodes. Extrais une interface FraisDePort avec une méthode calculer(Panier) : une classe par transporteur, et le reste du code ne dépend que de l'interface.
interface FraisDePort {
    public function calculer(Panier $p): float;
}
class Colissimo implements FraisDePort {
    public function calculer(Panier $p): float { return 4.99; }
}
class Chronopost implements FraisDePort {
    public function calculer(Panier $p): float { return 9.99; }
}
class RetraitMagasin implements FraisDePort {
    public function calculer(Panier $p): float { return 0.0; }
}

Ce geste a un nom depuis 1994 : Strategy, une famille de calculs interchangeables derrière la même interface. Ajouter Mondial Relay demain = écrire une classe. La connaissance « combien coûte chaque transporteur » vit à un seul endroit, et le bug silencieux de tout à l'heure devient structurellement impossible.

Besoin n°2 : la promo s'empile → Decorator

Demande suivante de la patronne : « livraison offerte dès 50 € d'achat ». Le premier réflexe de l'IA : ajouter le if ($total >= 50)... dans chacune des trois classes transporteur. On retombe dans la duplication qu'on vient de payer. Le recadrage :

N'ajoute le seuil dans aucun transporteur. Fais une classe FraisOffertsDes qui IMPLÉMENTE FraisDePort et qui en ENVELOPPE un autre : elle renvoie 0 si le panier dépasse le seuil, sinon elle délègue au transporteur enveloppé.
class FraisOffertsDes implements FraisDePort {
    public function __construct(
        private FraisDePort $base,   // le transporteur enveloppé, peu importe lequel
        private float $seuil,
    ) {}
    public function calculer(Panier $p): float {
        return $p->total() >= $this->seuil ? 0.0 : $this->base->calculer($p);
    }
}

// la promo s'empile sur N'IMPORTE quel transporteur, présent ou futur :
$livraison = new FraisOffertsDes(new Chronopost(), 50);

C'est un Decorator : une enveloppe qui a la même interface que ce qu'elle enveloppe. Pour le reste du code, un transporteur décoré EST un transporteur. C'est exactement le mécanisme de FraisOffertsDes(new MondialRelay(), 50) le jour où Mondial Relay existera : zéro ligne à changer.

Besoin n°3 : annuler dépend de l'étape → State

Troisième demande : « on doit pouvoir annuler une commande ». Sauf qu'« annuler » ne veut pas dire la même chose selon le moment : au panier, on vide ; payée, on rembourse ; expédiée, on déclenche un retour transporteur. Le premier jet de l'IA : un if/elseif sur $this->statut dans annuler(). Puis un deuxième dans modifier(). Puis un troisième... Le même motif que les transporteurs : un axe de variation (l'étape de vie) éparpillé en conditions.

// chaque état sait ce qu'« annuler » veut dire POUR LUI
interface EtatCommande {
    public function annuler(Commande $c): string;
}
class AuPanier implements EtatCommande {
    public function annuler(Commande $c): string { $c->panier()->vider(); return 'panier vidé'; }
}
class Payee implements EtatCommande {
    public function annuler(Commande $c): string { /* rembourser */ return 'remboursée'; }
}
class Expediee implements EtatCommande {
    public function annuler(Commande $c): string { /* retour */ return 'retour demandé'; }
}

// la commande PORTE son état : un objet, remplacé à chaque étape de sa vie
class Commande {
    private EtatCommande $etat;
    public function __construct(private Panier $panier) { $this->etat = new AuPanier(); }
    public function payer(): void    { $this->etat = new Payee(); }      // changer d'état = changer l'objet
    public function expedier(): void { $this->etat = new Expediee(); }
    public function annuler(): string { return $this->etat->annuler($this); }  // délègue. Zéro if.
}

C'est State : l'objet change de comportement comme s'il changeait de classe, parce que c'est littéralement ce qu'il fait : il remplace son objet-état. La quatrième étape de vie de demain (« litige » ?) sera une classe de plus, pas un elseif de plus dans quatre méthodes.

Besoin n°4 : payer doit prévenir tout le monde → Observer

Quatrième demande : « quand c'est payé : email de confirmation, réservation du stock, et préviens la compta ». L'IA colle les trois appels dans payer(). Ça marche... et la méthode payer() connaît maintenant l'emailing, le stock ET la compta.

Prédis avant de lire la suite

Avec les trois appels en dur dans payer() : qu'est-ce qui doit être modifié (et re-testé) le mois prochain, quand le marketing demandera d'ajouter un 4ᵉ effet, « envoie aussi un SMS » ? Et qu'est-ce que ça implique pour tes tests de paiement ?

Vérifier ma prédiction

Il faut rouvrir et modifier payer(), c'est-à-dire la méthode la plus critique de la boutique, celle qui touche à l'argent, pour une raison qui n'a rien à voir avec le paiement. Et chaque test de paiement embarque maintenant l'emailing, le stock, la compta et le SMS : pour tester « payer marche », il faut simuler quatre systèmes. Le remède : payer() ne doit faire qu'une chose, annoncer que c'est payé. Qui réagit, et comment, c'est le problème des abonnés.

// une liste d'abonnés + une boucle : c'est TOUT le pattern
class Evenements {
    private array $abonnes = [];
    public function abonner(string $evt, callable $fn): void { $this->abonnes[$evt][] = $fn; }
    public function publier(string $evt, $donnees = null): void {
        foreach ($this->abonnes[$evt] ?? [] as $fn) { $fn($donnees); }
    }
}

$evenements->abonner('commande.payee', $envoyerEmail);
$evenements->abonner('commande.payee', $reserverStock);
$evenements->abonner('commande.payee', $prevenirCompta);
// le SMS du mois prochain : une ligne ICI, zéro changement dans payer()

C'est Observer : un objet prévient une liste d'abonnés sans les connaître. Tu l'utilises déjà tous les jours : addEventListener en JavaScript, c'est exactement cette mécanique, et les événements Symfony aussi.

Besoin n°5 : un seul bouton → Facade, et l'assemblage à la racine

Dernière pièce : le « contrôleur » (le code qui reçoit le POST du formulaire) n'a pas envie de connaître les stratégies, les décorateurs et les événements. Il veut un bouton. On lui donne une Facade : un objet qui assemble tout derrière une méthode simple.

class Checkout {
    public function __construct(
        private FraisDePort $livraison,   // la Strategy (éventuellement décorée)
        private Evenements  $evenements,  // l'Observer
    ) {}
    public function commander(Commande $c): void {
        $c->payer();
        $this->evenements->publier('commande.payee', $c);
    }
}

// L'ASSEMBLAGE : le seul endroit du fichier qui connaît les classes concrètes.
$checkout = new Checkout(
    new FraisOffertsDes(new Colissimo(), 50),
    $evenements,
);

Remarque où se prennent les décisions : à la racine, au moment d'assembler, jamais à l'intérieur des classes. Checkout reçoit des interfaces : il ne sait pas si la livraison est décorée, ni qui est abonné. C'est aussi, au passage, la règle de dépendance de Clean Architecture : les deux livres convergent sur le même geste.

L'anti-leçon : les deux patterns refusés

En route, l'IA a proposé deux « améliorations » que j'ai refusées, et c'est aussi important que le reste :

  • un Singleton (Panier::getInstance()) « pour accéder au panier partout ». Refusé : c'est une variable globale déguisée, intestable, et l'assemblage à la racine rend déjà le panier accessible à qui en a besoin, par injection ;
  • un Command avec historique undo/redo « au cas où ». Refusé : personne n'a demandé d'annuler pas à pas. Un pattern installé « au cas où », c'est de la complexité payée d'avance pour un besoin imaginaire.

C'est la maladie classique après la découverte des patterns : la fièvre des patterns, où chaque bout de code devient l'occasion d'en placer un. Les auteurs du livre fondateur préviennent eux-mêmes dès la page 31 : un pattern ne s'applique que lorsque la flexibilité qu'il apporte est réellement nécessaire. La question n'est jamais « quel pattern puis-je mettre ici ? » mais « qu'est-ce qui va changer ? ».

Les réflexes « conception » à retenir

1. Le pattern répond à un problème nommable, jamais l'inverse

Chacun des cinq patterns de ce projet est arrivé après une demande réelle : les frais varient (Strategy), la promo s'empile (Decorator), l'étape change le comportement (State), payer déclenche l'inconnu (Observer), le contrôleur veut un bouton (Facade). Si tu ne sais pas nommer le problème qu'un pattern résout dans ton code, c'est qu'il n'a rien à y faire.

2. La classe la plus centrale est la plus bête

Relis le code final : Panier, la classe au centre de tout, n'utilise aucun pattern. Elle additionne des lignes. Les patterns vivent autour d'elle, aux endroits qui changent. Une « classe Panier ultime » qui empilerait les cinq serait exactement la fièvre des patterns ci-dessus.

3. Le test du changement

Pour juger une conception, prends la prochaine demande probable de la patronne et compte combien de classes elle traverse. « Ajouter Mondial Relay » : une classe à créer, une ligne à l'assemblage. « Ajouter un SMS au paiement » : une ligne d'abonnement. Si une demande banale traverse cinq fichiers, ton axe de variation est mal enfermé.

Tester (comme une patronne pressée)

  • Ajoute un clavier (79 €), choisis Chronopost : 9,99 € de frais. Coche la promo : les frais passent à 0 et l'ancien prix s'affiche barré. Décoche : ils reviennent. Le décorateur s'empile et se retire sans rien casser.
  • Vide tout, ajoute juste les stickers (4 €) et coche la promo : les frais restent pleins, le seuil de 50 € n'est pas atteint. Le décorateur délègue bien au transporteur enveloppé.
  • Paie, puis regarde le journal : trois abonnés se réveillent (email, stock, compta) alors que tu n'as cliqué qu'un bouton.
  • Annule à chaque étape : au panier (ça vide), payée (ça rembourse), expédiée (ça demande un retour). Trois comportements, un seul bouton, et annuler() n'a aucun if.

Le rendu final

La boutique tourne ici, pour de vrai (état en session PHP). Chaque bouton traverse un pattern, et le journal du bas te dit lesquels se réveillent.

Ouvrir le projet en plein écran

Le code complet

Le fichier PHP entier, exactement celui qui tourne au-dessus, avec un commentaire au-dessus de chaque pattern. C'est un fichier serveur : copie-le sur un hébergement PHP pour le faire tourner chez toi.

Voir le code complet (442 lignes)
<?php
// ============================================================================
// LA BOUTIQUE AUX PATTERNS — un seul fichier PHP, et 5 design patterns
// qui gagnent chacun leur place.
//
// Ce fichier est l'artefact du projet « Construire la boutique, un pattern à
// la fois ». Chaque pattern y répond à un besoin NOMMABLE de la boutique :
//
//   STRATEGY   → les frais de port varient (Colissimo, Chrono, retrait)
//   DECORATOR  → la promo « offert dès 50 € » s'empile sur n'importe quel
//                transporteur, sans modifier aucun transporteur
//   STATE      → la commande change de comportement selon son étape de vie
//                (au panier / payée / expédiée), zéro if dans annuler()
//   OBSERVER   → payer déclenche l'email, le stock, la compta... sans que la
//                commande connaisse aucun d'eux
//   FACADE     → le « contrôleur » (le POST tout en bas) n'appelle qu'UN objet
//
// Et aussi important : les patterns ABSENTS. Pas de Singleton (tout est
// assemblé et injecté à la racine), pas de Command (personne n'a demandé
// d'annuler/rejouer pas à pas), pas d'Abstract Factory (un seul thème).
// Un bon design se reconnaît autant à ses patterns absents que présents.
//
// PLAN DU FICHIER :
//   1) Les classes : Panier, Strategy, Decorator, State, Observer, Facade
//   2) L'état de la démo (session) + l'assemblage À LA RACINE
//   3) Traitement du POST (le « contrôleur » : il ne connaît que la façade)
//   4) Le HTML
// ============================================================================

session_start();

// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';

// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }

// ============================================================================
// 1) LES CLASSES
// ============================================================================

// --- Le panier : la classe la plus centrale est la plus bête. AUCUN pattern.
class Panier {
    /** @var array<string,int> nom du produit => quantité */
    private array $lignes = [];
    public function __construct(private array $catalogue) {}
    public function ajouter(string $produit): void {
        if (isset($this->catalogue[$produit])) {
            $this->lignes[$produit] = ($this->lignes[$produit] ?? 0) + 1;
        }
    }
    public function vider(): void { $this->lignes = []; }
    public function lignes(): array { return $this->lignes; }
    public function estVide(): bool { return $this->lignes === []; }
    public function total(): float {
        $t = 0.0;
        foreach ($this->lignes as $produit => $qte) {
            $t += $this->catalogue[$produit] * $qte;
        }
        return $t;
    }
}

// --- STRATEGY : les frais de port varient -----------------------------------
// Une interface, des calculs interchangeables. Ajouter un transporteur demain
// = ajouter UNE classe. Ni le panier, ni le checkout, ni le HTML ne bougent.
interface FraisDePort {
    public function calculer(Panier $p): float;
}
class Colissimo implements FraisDePort {
    public function calculer(Panier $p): float { return 4.99; }
}
class Chronopost implements FraisDePort {
    public function calculer(Panier $p): float { return 9.99; }
}
class RetraitMagasin implements FraisDePort {
    public function calculer(Panier $p): float { return 0.0; }
}

// --- DECORATOR : la promo s'empile sur N'IMPORTE quel transporteur ----------
// Même interface que ce qu'il enveloppe : pour le reste du code, un transporteur
// décoré EST un transporteur. C'est ça qui rend la promo empilable.
class FraisOffertsDes implements FraisDePort {
    public function __construct(private FraisDePort $base, private float $seuil) {}
    public function calculer(Panier $p): float {
        return $p->total() >= $this->seuil ? 0.0 : $this->base->calculer($p);
    }
}

// --- STATE : la commande change de comportement selon son étape -------------
// Chaque état sait ce qu'« annuler » veut dire POUR LUI. La commande remplace
// son objet-état à chaque étape, et annuler() délègue. Zéro if, pour toujours.
interface EtatCommande {
    public function annuler(Commande $c): string;  // renvoie ce qui s'est passé
    public function libelle(string $lang): string;
}
class AuPanier implements EtatCommande {
    public function annuler(Commande $c): string {
        $c->panier()->vider();
        return $c->lang() === 'en' ? 'Cart emptied. Nothing to refund: nothing was paid.' : 'Panier vidé. Rien à rembourser : rien n\'était payé.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'in cart' : 'au panier'; }
}
class Payee implements EtatCommande {
    public function annuler(Commande $c): string {
        $c->changerEtat(new AuPanier());
        return $c->lang() === 'en' ? 'Order refunded. Items are back in your cart.' : 'Commande remboursée. Les articles sont de retour dans le panier.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'paid' : 'payée'; }
}
class Expediee implements EtatCommande {
    public function annuler(Commande $c): string {
        return $c->lang() === 'en' ? 'Too late to refund directly: a carrier return has been requested.' : 'Trop tard pour rembourser directement : un retour transporteur est demandé.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'shipped' : 'expédiée'; }
}
class Commande {
    private EtatCommande $etat;
    public function __construct(private Panier $panier, private string $lang) {
        $this->etat = new AuPanier();   // état de départ
    }
    public function payer(): void    { $this->etat = new Payee(); }
    public function expedier(): void { $this->etat = new Expediee(); }
    public function annuler(): string { return $this->etat->annuler($this); }
    public function changerEtat(EtatCommande $e): void { $this->etat = $e; }
    public function etat(): EtatCommande { return $this->etat; }
    public function panier(): Panier { return $this->panier; }
    public function lang(): string { return $this->lang; }
}

// --- OBSERVER : payer déclenche l'inconnu ------------------------------------
// Une liste d'abonnés + une boucle : c'est TOUT le pattern. La commande publie,
// elle ne connaît ni l'email, ni le stock, ni les abonnés de demain.
class Evenements {
    /** @var array<string, callable[]> */
    private array $abonnes = [];
    public function abonner(string $evt, callable $fn): void {
        $this->abonnes[$evt][] = $fn;
    }
    public function publier(string $evt, $donnees = null): void {
        foreach ($this->abonnes[$evt] ?? [] as $fn) { $fn($donnees); }
    }
}

// --- FACADE : un seul point d'entrée -----------------------------------------
// Le contrôleur (le POST plus bas) ne connaît que cet objet. Les dépendances
// arrivent en INTERFACES : c'est aussi l'inversion de dépendance de Clean
// Architecture. Les deux livres convergent sur le même geste.
class Checkout {
    public function __construct(
        private FraisDePort $livraison,   // la Strategy (éventuellement décorée)
        private Evenements  $evenements,  // l'Observer
    ) {}
    public function totalAPayer(Panier $p): float {
        return $p->total() + $this->livraison->calculer($p);
    }
    public function fraisDePort(Panier $p): float {
        return $this->livraison->calculer($p);
    }
    public function commander(Commande $c): void {
        $c->payer();
        $this->evenements->publier('commande.payee', $c);
    }
}

// ============================================================================
// 2) L'ÉTAT DE LA DÉMO (session) + L'ASSEMBLAGE À LA RACINE
// ============================================================================

$catalogue = [
    'Clavier mécanique' => 79.00,
    'Tasse « it works »' => 12.50,
    'Stickers (lot de 10)' => 4.00,
];
$catalogueEn = [
    'Clavier mécanique' => 'Mechanical keyboard',
    'Tasse « it works »' => '"It works" mug',
    'Stickers (lot de 10)' => 'Stickers (pack of 10)',
];

// L'état persistant de la démo : lignes du panier, choix, statut, journal.
$_SESSION['boutique'] ??= [
    'lignes' => [], 'transporteur' => 'colissimo', 'promo' => false,
    'statut' => 'panier', 'journal' => [],
];
$etatDemo = &$_SESSION['boutique'];

// On reconstruit les objets depuis la session (une démo HTTP est sans mémoire :
// chaque requête repart de zéro, la session est notre disque dur).
$panier = new Panier($catalogue);
foreach ($etatDemo['lignes'] as $produit => $qte) {
    for ($i = 0; $i < $qte; $i++) { $panier->ajouter($produit); }
}
$commande = new Commande($panier, $lang);
if ($etatDemo['statut'] === 'payee')    { $commande->payer(); }
if ($etatDemo['statut'] === 'expediee') { $commande->expedier(); }

// --- Les abonnés Observer : chacun ignore l'existence des autres ------------
$evenements = new Evenements();
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '📧 Email subscriber: confirmation sent' : '📧 Abonné email : confirmation envoyée';
});
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '📦 Stock subscriber: items reserved' : '📦 Abonné stock : articles réservés';
});
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '🧾 Accounting subscriber: invoice drafted' : '🧾 Abonné compta : facture préparée';
});

// --- L'ASSEMBLAGE : tous les choix concrets se font ICI, à la racine ---------
// C'est le seul endroit du fichier qui connaît les classes concrètes.
$transporteurs = [
    'colissimo' => new Colissimo(),
    'chrono'    => new Chronopost(),
    'retrait'   => new RetraitMagasin(),
];
$livraison = $transporteurs[$etatDemo['transporteur']] ?? $transporteurs['colissimo'];
if ($etatDemo['promo']) {
    $livraison = new FraisOffertsDes($livraison, 50.00);   // le Decorator s'empile
}
$checkout = new Checkout($livraison, $evenements);

// ============================================================================
// 3) LE « CONTRÔLEUR » : il ne connaît que la façade (et la session)
// ============================================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action = $_POST['action'] ?? '';

    if ($action === 'ajouter' && $etatDemo['statut'] === 'panier') {
        $produit = (string)($_POST['produit'] ?? '');
        if (isset($catalogue[$produit])) {
            $etatDemo['lignes'][$produit] = ($etatDemo['lignes'][$produit] ?? 0) + 1;
        }
    }
    if ($action === 'options' && $etatDemo['statut'] === 'panier') {
        $t = (string)($_POST['transporteur'] ?? 'colissimo');
        $etatDemo['transporteur'] = isset($transporteurs[$t]) ? $t : 'colissimo';
        $etatDemo['promo'] = isset($_POST['promo']);
    }
    if ($action === 'payer' && $etatDemo['statut'] === 'panier' && !$panier->estVide()) {
        $checkout->commander($commande);            // la façade orchestre
        $etatDemo['statut'] = 'payee';
    }
    if ($action === 'expedier' && $etatDemo['statut'] === 'payee') {
        $commande->expedier();
        $etatDemo['statut'] = 'expediee';
        $etatDemo['journal'][] = $lang === 'en' ? '🚚 Order handed to the carrier' : '🚚 Commande remise au transporteur';
    }
    if ($action === 'annuler') {
        $resultat = $commande->annuler();           // STATE décide tout seul
        $etatDemo['journal'][] = '↩️ ' . $resultat;
        // on resynchronise la session avec ce que l'état a décidé
        $etatDemo['statut'] = $commande->etat()->libelle('fr') === 'payée' ? 'payee'
            : ($commande->etat()->libelle('fr') === 'expédiée' ? 'expediee' : 'panier');
        if ($etatDemo['statut'] === 'panier' && $panier->estVide()) { $etatDemo['lignes'] = []; }
    }
    if ($action === 'reset') {
        $etatDemo = ['lignes' => [], 'transporteur' => 'colissimo', 'promo' => false, 'statut' => 'panier', 'journal' => []];
    }

    // PRG : on redirige pour éviter le re-POST au F5.
    header('Location: ?lang=' . $lang);
    exit;
}

// ============================================================================
// 4) LE HTML
// ============================================================================
$S = [
  'fr' => [
    'title' => 'La boutique aux patterns', 'sub' => 'Chaque bouton de cette page traverse un design pattern. Le journal en bas raconte lesquels.',
    'cat' => 'Catalogue', 'add' => 'Ajouter',
    'cart' => 'Panier', 'empty' => 'Panier vide. Ajoute un article ci-dessus.',
    'ship' => 'Livraison (Strategy)', 'promo' => 'Code promo : offert dès 50 € (Decorator)',
    'colissimo' => 'Colissimo · 4,99 €', 'chrono' => 'Chronopost · 9,99 €', 'retrait' => 'Retrait magasin · 0 €',
    'apply' => 'Appliquer',
    'subtotal' => 'Sous-total', 'fees' => 'Frais de port', 'total' => 'Total à payer',
    'status' => 'Statut de la commande (State)', 'pay' => 'Payer', 'shipit' => 'Expédier', 'cancel' => 'Annuler',
    'log' => 'Journal des événements (Observer)', 'logEmpty' => 'Rien pour l\'instant. Paie la commande et regarde qui se réveille.',
    'reset' => 'Réinitialiser la démo',
    'note' => 'Annuler ne contient aucun if : c\'est l\'objet-état courant qui décide (vider, rembourser ou demander un retour).',
    'credit' => '5 design patterns dans un fichier PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
    'statuts' => ['panier' => 'au panier', 'payee' => 'payée', 'expediee' => 'expédiée'],
  ],
  'en' => [
    'title' => 'The pattern shop', 'sub' => 'Every button on this page goes through a design pattern. The log below tells you which.',
    'cat' => 'Catalog', 'add' => 'Add',
    'cart' => 'Cart', 'empty' => 'Empty cart. Add an item above.',
    'ship' => 'Shipping (Strategy)', 'promo' => 'Promo code: free over €50 (Decorator)',
    'colissimo' => 'Standard post · €4.99', 'chrono' => 'Express · €9.99', 'retrait' => 'Store pickup · €0',
    'apply' => 'Apply',
    'subtotal' => 'Subtotal', 'fees' => 'Shipping fee', 'total' => 'Total to pay',
    'status' => 'Order status (State)', 'pay' => 'Pay', 'shipit' => 'Ship', 'cancel' => 'Cancel',
    'log' => 'Event log (Observer)', 'logEmpty' => 'Nothing yet. Pay the order and watch who wakes up.',
    'reset' => 'Reset the demo',
    'note' => 'Cancel contains no if: the current state object decides (empty, refund, or request a return).',
    'credit' => '5 design patterns in one PHP file · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
    'statuts' => ['panier' => 'in cart', 'payee' => 'paid', 'expediee' => 'shipped'],
  ],
][$lang];

$sousTotal = $panier->total();
// panier vide : pas de frais à afficher (on ne livre pas du vide)
$frais = $panier->estVide() ? 0.0 : $checkout->fraisDePort($panier);
$total = $sousTotal + $frais;
$statut = $etatDemo['statut'];
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title><?= e($S['title']) ?></title>
<style>
  :root { --accent:#329e5a; --ink:#23262b; --muted:#6a7178; --bg:#f6f4ef; --card:#fff; --line:#e4e1d8; --warn:#a84f2d; }
  * { box-sizing:border-box; }
  body { margin:0; font-family:system-ui,-apple-system,'Segoe UI',sans-serif; background:var(--bg); color:var(--ink); padding:18px 14px 30px; }
  .wrap { max-width:660px; margin:0 auto; }
  h1 { font-size:1.35rem; margin:0 0 4px; }
  .sub { color:var(--muted); font-size:.9rem; margin:0 0 18px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:14px; }
  .card h2 { font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent); margin:0 0 10px; }
  .prod { display:flex; justify-content:space-between; align-items:center; padding:7px 0; border-bottom:1px dashed var(--line); gap:8px; }
  .prod:last-child { border-bottom:0; }
  .price { color:var(--muted); white-space:nowrap; }
  button { font:inherit; border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:8px; padding:7px 14px; cursor:pointer; }
  button.ghost { background:transparent; color:var(--accent); }
  button.warn { background:transparent; color:var(--warn); border-color:var(--warn); }
  button:disabled { opacity:.4; cursor:not-allowed; }
  label { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:pointer; }
  .totaux { border-top:2px solid var(--ink); margin-top:10px; padding-top:10px; }
  .row { display:flex; justify-content:space-between; padding:3px 0; }
  .row.big { font-weight:700; font-size:1.05rem; }
  .statut { display:inline-block; padding:3px 12px; border-radius:999px; font-weight:700; font-size:.85rem; }
  .st-panier   { background:#eef3f7; color:#456; }
  .st-payee    { background:#e6f3ec; color:#267d42; }
  .st-expediee { background:#fdf0e7; color:#a84f2d; }
  .actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
  .log { list-style:none; margin:0; padding:0; font-size:.9rem; }
  .log li { padding:5px 0; border-bottom:1px dashed var(--line); }
  .log li:last-child { border-bottom:0; }
  .muted { color:var(--muted); font-size:.85rem; }
  .note { font-size:.82rem; color:var(--muted); margin-top:10px; }
  .credit { text-align:center; font-size:.78rem; color:var(--muted); margin-top:16px; }
  .credit a { color:var(--accent); }
  .resetline { text-align:center; margin-top:10px; }
  .barre { text-decoration:line-through; color:var(--muted); font-weight:400; margin-right:6px; }
</style>
</head>
<body>
<div class="wrap">
  <h1><?= e($S['title']) ?></h1>
  <p class="sub"><?= e($S['sub']) ?></p>

  <div class="card">
    <h2><?= e($S['cat']) ?></h2>
    <?php foreach ($catalogue as $produit => $prix): ?>
    <div class="prod">
      <span><?= e($lang === 'en' ? $catalogueEn[$produit] : $produit) ?></span>
      <span class="price"><?= number_format($prix, 2, ',', ' ') ?> €</span>
      <form method="post" style="margin:0">
        <input type="hidden" name="action" value="ajouter">
        <input type="hidden" name="produit" value="<?= e($produit) ?>">
        <input type="hidden" name="lang" value="<?= e($lang) ?>">
        <button class="ghost" <?= $statut !== 'panier' ? 'disabled' : '' ?>>+ <?= e($S['add']) ?></button>
      </form>
    </div>
    <?php endforeach; ?>
  </div>

  <div class="card">
    <h2><?= e($S['cart']) ?></h2>
    <?php if ($panier->estVide()): ?>
      <p class="muted"><?= e($S['empty']) ?></p>
    <?php else: ?>
      <?php foreach ($panier->lignes() as $produit => $qte): ?>
      <div class="prod">
        <span><?= e($lang === 'en' ? $catalogueEn[$produit] : $produit) ?> × <?= (int)$qte ?></span>
        <span class="price"><?= number_format($catalogue[$produit] * $qte, 2, ',', ' ') ?> €</span>
      </div>
      <?php endforeach; ?>
    <?php endif; ?>

    <form method="post" style="margin-top:12px">
      <input type="hidden" name="action" value="options">
      <input type="hidden" name="lang" value="<?= e($lang) ?>">
      <h2 style="margin-top:6px"><?= e($S['ship']) ?></h2>
      <label><input type="radio" name="transporteur" value="colissimo" <?= $etatDemo['transporteur'] === 'colissimo' ? 'checked' : '' ?>> <?= e($S['colissimo']) ?></label>
      <label><input type="radio" name="transporteur" value="chrono" <?= $etatDemo['transporteur'] === 'chrono' ? 'checked' : '' ?>> <?= e($S['chrono']) ?></label>
      <label><input type="radio" name="transporteur" value="retrait" <?= $etatDemo['transporteur'] === 'retrait' ? 'checked' : '' ?>> <?= e($S['retrait']) ?></label>
      <label style="margin-top:4px"><input type="checkbox" name="promo" <?= $etatDemo['promo'] ? 'checked' : '' ?>> <?= e($S['promo']) ?></label>
      <button class="ghost" <?= $statut !== 'panier' ? 'disabled' : '' ?>><?= e($S['apply']) ?></button>
    </form>

    <div class="totaux">
      <div class="row"><span><?= e($S['subtotal']) ?></span><span><?= number_format($sousTotal, 2, ',', ' ') ?> €</span></div>
      <div class="row"><span><?= e($S['fees']) ?></span>
        <span>
          <?php if ($etatDemo['promo'] && $frais == 0 && $sousTotal >= 50): ?>
            <span class="barre"><?= number_format($transporteurs[$etatDemo['transporteur']]->calculer($panier), 2, ',', ' ') ?> €</span>
          <?php endif; ?>
          <?= number_format($frais, 2, ',', ' ') ?> €
        </span>
      </div>
      <div class="row big"><span><?= e($S['total']) ?></span><span><?= number_format($total, 2, ',', ' ') ?> €</span></div>
    </div>
  </div>

  <div class="card">
    <h2><?= e($S['status']) ?></h2>
    <span class="statut st-<?= e($statut) ?>"><?= e($S['statuts'][$statut]) ?></span>
    <div class="actions">
      <form method="post"><input type="hidden" name="action" value="payer"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button <?= ($statut !== 'panier' || $panier->estVide()) ? 'disabled' : '' ?>><?= e($S['pay']) ?></button></form>
      <form method="post"><input type="hidden" name="action" value="expedier"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button <?= $statut !== 'payee' ? 'disabled' : '' ?>><?= e($S['shipit']) ?></button></form>
      <form method="post"><input type="hidden" name="action" value="annuler"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button class="warn" <?= ($statut === 'panier' && $panier->estVide()) ? 'disabled' : '' ?>><?= e($S['cancel']) ?></button></form>
    </div>
    <p class="note"><?= e($S['note']) ?></p>
  </div>

  <div class="card">
    <h2><?= e($S['log']) ?></h2>
    <?php if (empty($etatDemo['journal'])): ?>
      <p class="muted"><?= e($S['logEmpty']) ?></p>
    <?php else: ?>
      <ul class="log">
        <?php foreach (array_reverse($etatDemo['journal']) as $evt): ?>
        <li><?= e($evt) ?></li>
        <?php endforeach; ?>
      </ul>
    <?php endif; ?>
  </div>

  <div class="resetline">
    <form method="post" style="display:inline"><input type="hidden" name="action" value="reset"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button class="ghost"><?= e($S['reset']) ?></button></form>
  </div>

  <p class="credit"><?= $S['credit'] ?></p>
</div>
</body>
</html>

À toi de jouer

Le but du jeu, maintenant, c'est d'encaisser les prochaines demandes de la patronne sans rouvrir les classes existantes :

Premier palier, en échauffement : le transporteur Mondial Relay (3,50 €), avec le squelette à trous. Complète les deux /* ??? */ de tête, sans regarder plus haut :

class MondialRelay implements /* ??? */ {
    public function calculer(Panier $p): float {
        return /* ??? */;
    }
}
// ... et UNE ligne à l'assemblage. Aucune autre classe ne bouge : c'est le test.
  • Ajoute un abonné « 🎁 fidélité : points crédités » au paiement : une ligne d'abonnement, zéro changement ailleurs.
  • Plus dur : un état Litige (après expédition, le client conteste) où « annuler » devient « ouvrir un dossier ».
Défi : une deuxième promo, empilée sur la première

La patronne lance « -2 € de frais de port pour les clients newsletter », cumulable avec « offert dès 50 € ». Code une classe RemiseFraisDePort et branche-la. Tente-le seul d'abord, et ne déplie le corrigé qu'après avoir essayé.

Tu as réussi si :

  • aucune classe existante n'est modifiée : ni les transporteurs, ni FraisOffertsDes, ni Checkout ;
  • les deux promos s'empilent dans n'importe quel ordre et les frais ne descendent jamais sous 0 € ;
  • tout se décide à l'assemblage, à la racine.
Voir une piste de solution
// Même interface, même geste : une enveloppe de plus.
class RemiseFraisDePort implements FraisDePort {
    public function __construct(
        private FraisDePort $base,
        private float $remise,
    ) {}
    public function calculer(Panier $p): float {
        // max(0, ...) : une remise ne crée jamais des frais négatifs
        return max(0.0, $this->base->calculer($p) - $this->remise);
    }
}

// À l'assemblage, les enveloppes s'emboîtent comme des poupées russes :
$livraison = new RemiseFraisDePort(
    new FraisOffertsDes(new Chronopost(), 50),
    2.00,
);
// Chronopost ne sait pas qu'il est décoré. FraisOffertsDes non plus.
// C'est ça, la composition : chaque pièce ignore l'existence des autres.

Si tu as modifié une classe existante, relis le Decorator : la promo n'est pas une propriété des transporteurs, c'est une enveloppe autour d'eux. Et le max(0.0, ...) est le genre de détail qu'on n'attrape qu'en testant : promo + retrait magasin = 0 - 2 = -2 € sans lui.

Accepter ou rejeter le code de l'IA

Pour « accéder au panier depuis n'importe où », l'IA te propose ce classique. Ton rôle de relecteur : accepter tel quel, ou rejeter, et dire pourquoi.

class Panier {
    private static ?Panier $instance = null;
    public static function getInstance(): Panier {
        return self::$instance ??= new Panier();
    }
    private function __construct() {}
}
// ... partout dans le code :
Panier::getInstance()->ajouter($produit);
Rejeter. C'est le pattern Singleton, et ici c'est une variable globale déguisée : n'importe quel code, n'importe où, peut modifier le panier sans que rien ne le trace. Les tests deviennent dépendants les uns des autres, et on ne peut plus avoir deux paniers. Le besoin réel est déjà couvert par l'injection à la racine : on PASSE le panier à qui en a besoin.
Rappel libre

Sans remonter dans la leçon : quelle question pose-t-on AVANT de choisir un pattern ? Et cite deux patterns de ce projet, chacun avec le besoin de la boutique qui l'a appelé.

La question : « qu'est-ce qui va changer ? ». On trouve l'axe de variation, on l'enferme dans un objet. Frais qui varient → Strategy ; promo qui s'empile → Decorator ; étape qui change le comportement → State ; payer qui déclenche l'inconnu → Observer ; un seul point d'entrée → Facade.
La suite, c'est dans les livres

Tu viens de pratiquer cinq patterns appelés chacun par un vrai besoin, et d'en refuser deux. Le catalogue complet (23 patterns, et surtout les deux principes derrière) est raconté dans la fiche Design Patterns de la bibliothèque, et sa version illustrée dans la fiche Head First Design Patterns.

Lire la fiche Design Patterns →

The project: a cart that grows without breaking

Until now, each project added a capability: receiving (project 10), storing (project 11), protecting (project 12). This one tackles something else: how your code welcomes change. Because a shop is never finished: the owner wants a new carrier on Monday, a promo on Tuesday, and on Wednesday "when it's paid, tell accounting too". Each request is tiny. It's their accumulation that kills projects.

We'll build a small shop's checkout flow in PHP, with AI, and we'll do it one need at a time. At each need, we'll watch the naive code crack, and the answer that holds will have a name: a design pattern (a named solution to a problem that keeps coming back). Five patterns will earn their place. And two the AI wanted to add will be refused: that, too, is design.

This project is the hands-on version of two notes from the library: Design Patterns (the 1994 source) and Head First Design Patterns (the illustrated textbook). You don't need to have read them: everything is re-explained here. You just need PHP OOP basics (classes, interfaces).

What you'll be able to do
  • Ask the question that precedes every pattern: "what is going to change?", and locate the axis of variation.
  • Implement Strategy (interchangeable shipping fees), Decorator (stackable promo), State (staged order), Observer (events) and Facade (one entry point).
  • Assemble everything at the root of the program, by injection, with no global variable.
  • Recognize a pattern too many, and refuse it with an argument.
You've succeeded when…
  • adding a carrier = creating one single class, without touching the cart, the checkout or the other carriers;
  • the "free over €50" promo works with any carrier, including tomorrow's;
  • the Cancel button does three different things depending on the order's stage, and cancel() contains no if.

The logbook (real life)

As always, we show you the loop as it happened: the AI's first draft, the exact spot where it cracks, and the reframe. The difference with previous projects: here the first draft works. It's its ability to absorb the next request we'll be judging.

Prompt 1: ask broadly

In OOP PHP, build me a shop checkout: a cart, shipping fees (standard 4.99 or free store pickup), a "free shipping over 50" promo, and an order you can pay then cancel.

The AI produces a 200-line Shop class that does everything. At its heart, this method:

public function shippingFee(): float {
    $fee = 0;
    if ($this->carrier === 'standard') {
        $fee = 4.99;
    } elseif ($this->carrier === 'pickup') {
        $fee = 0;
    }
    if ($this->promo && $this->total() >= 50) {
        $fee = 0;
    }
    return $fee;
}

It works. I test, the totals are right. Then I pass on the owner's request: "add Express, 9.99". And that's when I discover this carrier if/elseif doesn't only live here: it's also in the method that renders the summary, and in the one that builds the email. Three copies of the same list.

Predict before reading on

I add Express to shippingFee() above, but forget to add it to the summary method. Your call: does it crash with an error, or does it do something worse?

Check my prediction

Something worse: it doesn't crash at all. The customer picks Express, gets charged 9.99... and their summary falls into the "unknown" branch of the second if/elseif, displaying 0 or "Standard" depending on the version. No error, no log: just a customer charged one thing and told another. That's the silent bug, the most expensive kind, and it has a single cause: the same knowledge (the carrier list) is duplicated in three places that can drift apart.

Need #1: fees vary → Strategy

The carrier list is duplicated across 3 methods. Extract a ShippingFee interface with a compute(Cart) method: one class per carrier, and the rest of the code depends only on the interface.
interface ShippingFee {
    public function compute(Cart $c): float;
}
class StandardPost implements ShippingFee {
    public function compute(Cart $c): float { return 4.99; }
}
class Express implements ShippingFee {
    public function compute(Cart $c): float { return 9.99; }
}
class StorePickup implements ShippingFee {
    public function compute(Cart $c): float { return 0.0; }
}

This move has had a name since 1994: Strategy, a family of interchangeable calculations behind one interface. Adding a new carrier tomorrow = writing one class. The knowledge "what each carrier costs" lives in one place, and the earlier silent bug becomes structurally impossible.

Need #2: the promo stacks → Decorator

Next request: "free shipping over €50". The AI's first reflex: add the if ($total >= 50)... inside each of the three carrier classes. Back to the duplication we just paid for. The reframe:

Don't add the threshold to any carrier. Write a FreeShippingOver class that IMPLEMENTS ShippingFee and WRAPS another one: it returns 0 if the cart passes the threshold, otherwise it delegates to the wrapped carrier.
class FreeShippingOver implements ShippingFee {
    public function __construct(
        private ShippingFee $base,   // the wrapped carrier, whichever it is
        private float $threshold,
    ) {}
    public function compute(Cart $c): float {
        return $c->total() >= $this->threshold ? 0.0 : $this->base->compute($c);
    }
}

// the promo stacks on ANY carrier, present or future:
$shipping = new FreeShippingOver(new Express(), 50);

That's a Decorator: a wrapper with the same interface as what it wraps. To the rest of the code, a decorated carrier IS a carrier. Zero lines to change the day a new carrier exists.

Need #3: cancelling depends on the stage → State

Third request: "orders must be cancellable". Except "cancel" doesn't mean the same thing at every moment: in the cart, you empty; paid, you refund; shipped, you trigger a carrier return. The AI's first draft: an if/elseif on $this->status inside cancel(). Then a second one in update(). Then a third... The same motif as the carriers: an axis of variation (the life stage) scattered across conditions.

// each state knows what "cancel" means FOR ITSELF
interface OrderState {
    public function cancel(Order $o): string;
}
class InCart implements OrderState {
    public function cancel(Order $o): string { $o->cart()->empty(); return 'cart emptied'; }
}
class Paid implements OrderState {
    public function cancel(Order $o): string { /* refund */ return 'refunded'; }
}
class Shipped implements OrderState {
    public function cancel(Order $o): string { /* return */ return 'return requested'; }
}

// the order CARRIES its state: one object, swapped at each stage of its life
class Order {
    private OrderState $state;
    public function __construct(private Cart $cart) { $this->state = new InCart(); }
    public function pay(): void  { $this->state = new Paid(); }      // changing state = swapping the object
    public function ship(): void { $this->state = new Shipped(); }
    public function cancel(): string { return $this->state->cancel($this); }  // delegates. Zero if.
}

That's State: the object changes behavior as if it changed class, because that's literally what it does: it swaps its state object. Tomorrow's fourth life stage ("dispute"?) will be one more class, not one more elseif in four methods.

Need #4: paying must notify everyone → Observer

Fourth request: "when it's paid: confirmation email, stock reservation, and tell accounting". The AI glues the three calls inside pay(). It works... and pay() now knows about emailing, stock AND accounting.

Predict before reading on

With the three calls hard-wired in pay(): what must be modified (and re-tested) next month, when marketing asks for a 4th effect, "send an SMS too"? And what does that imply for your payment tests?

Check my prediction

You have to reopen and modify pay(), that is, the most critical method of the shop, the one touching money, for a reason that has nothing to do with payment. And every payment test now drags emailing, stock, accounting and SMS along: to test "paying works", you must fake four systems. The cure: pay() must do one thing, announce that payment happened. Who reacts, and how, is the subscribers' problem.

// a list of subscribers + one loop: that is THE WHOLE pattern
class Events {
    private array $subscribers = [];
    public function subscribe(string $evt, callable $fn): void { $this->subscribers[$evt][] = $fn; }
    public function publish(string $evt, $data = null): void {
        foreach ($this->subscribers[$evt] ?? [] as $fn) { $fn($data); }
    }
}

$events->subscribe('order.paid', $sendEmail);
$events->subscribe('order.paid', $reserveStock);
$events->subscribe('order.paid', $tellAccounting);
// next month's SMS: one line HERE, zero change inside pay()

That's Observer: an object notifies a list of subscribers without knowing them. You already use it daily: addEventListener in JavaScript is exactly this machinery, and so are Symfony events.

Need #5: one button → Facade, and root assembly

Last piece: the "controller" (the code receiving the form POST) has no desire to know about strategies, decorators and events. It wants a button. We give it a Facade: an object that assembles everything behind one simple method.

class Checkout {
    public function __construct(
        private ShippingFee $shipping,   // the Strategy (possibly decorated)
        private Events      $events,     // the Observer
    ) {}
    public function placeOrder(Order $o): void {
        $o->pay();
        $this->events->publish('order.paid', $o);
    }
}

// THE ASSEMBLY: the only place in the file that knows the concrete classes.
$checkout = new Checkout(
    new FreeShippingOver(new StandardPost(), 50),
    $events,
);

Notice where decisions are made: at the root, at assembly time, never inside the classes. Checkout receives interfaces: it doesn't know whether shipping is decorated, nor who subscribed. This is also, in passing, Clean Architecture's dependency rule: both books converge on the same move.

The anti-lesson: the two refused patterns

Along the way, the AI offered two "improvements" I refused, and that matters as much as the rest:

  • a Singleton (Cart::getInstance()) "to access the cart from anywhere". Refused: it's a global variable in disguise, untestable, and root assembly already hands the cart to whoever needs it, by injection;
  • a Command with an undo/redo history "just in case". Refused: nobody asked to cancel step by step. A pattern installed "just in case" is complexity paid upfront for an imaginary need.

This is the classic disease after discovering patterns: pattern fever, where every piece of code becomes an excuse to place one. The founding book's authors warn about it themselves as early as page 31: a pattern should only be applied when the flexibility it brings is actually needed. The question is never "which pattern can I put here?" but "what is going to change?".

The "design" reflexes to keep

1. The pattern answers a nameable problem, never the reverse

Each of this project's five patterns arrived after a real request: fees vary (Strategy), the promo stacks (Decorator), the stage changes behavior (State), paying triggers the unknown (Observer), the controller wants one button (Facade). If you can't name the problem a pattern solves in your code, it has no business being there.

2. The most central class is the dumbest

Re-read the final code: Cart, the class at the center of everything, uses no pattern. It adds up lines. The patterns live around it, at the places that change. An "ultimate Cart class" stacking all five would be exactly the pattern fever above.

3. The change test

To judge a design, take the owner's next likely request and count how many classes it crosses. "Add a new carrier": one class to create, one line at the assembly. "Add an SMS on payment": one subscription line. If a mundane request crosses five files, your axis of variation is poorly contained.

Test it (like an owner in a hurry)

  • Add a keyboard (€79), pick Express: €9.99 fee. Tick the promo: the fee drops to 0 and the old price shows struck through. Untick: it comes back. The decorator stacks and unstacks without breaking anything.
  • Reset, add just the stickers (€4) and tick the promo: the fee stays full, the €50 threshold isn't met. The decorator properly delegates to the wrapped carrier.
  • Pay, then watch the log: three subscribers wake up (email, stock, accounting) though you clicked one button.
  • Cancel at each stage: in cart (empties), paid (refunds), shipped (requests a return). Three behaviors, one button, and cancel() has no if.

The final result

The shop runs here, for real (PHP session state). Every button goes through a pattern, and the log at the bottom tells you which ones wake up.

Open the project full screen

The complete code

The whole PHP file, exactly the one running above, with a comment over each pattern. It's a server file: copy it to a PHP host to run it yourself.

See the complete code (442 lines)
<?php
// ============================================================================
// LA BOUTIQUE AUX PATTERNS — un seul fichier PHP, et 5 design patterns
// qui gagnent chacun leur place.
//
// Ce fichier est l'artefact du projet « Construire la boutique, un pattern à
// la fois ». Chaque pattern y répond à un besoin NOMMABLE de la boutique :
//
//   STRATEGY   → les frais de port varient (Colissimo, Chrono, retrait)
//   DECORATOR  → la promo « offert dès 50 € » s'empile sur n'importe quel
//                transporteur, sans modifier aucun transporteur
//   STATE      → la commande change de comportement selon son étape de vie
//                (au panier / payée / expédiée), zéro if dans annuler()
//   OBSERVER   → payer déclenche l'email, le stock, la compta... sans que la
//                commande connaisse aucun d'eux
//   FACADE     → le « contrôleur » (le POST tout en bas) n'appelle qu'UN objet
//
// Et aussi important : les patterns ABSENTS. Pas de Singleton (tout est
// assemblé et injecté à la racine), pas de Command (personne n'a demandé
// d'annuler/rejouer pas à pas), pas d'Abstract Factory (un seul thème).
// Un bon design se reconnaît autant à ses patterns absents que présents.
//
// PLAN DU FICHIER :
//   1) Les classes : Panier, Strategy, Decorator, State, Observer, Facade
//   2) L'état de la démo (session) + l'assemblage À LA RACINE
//   3) Traitement du POST (le « contrôleur » : il ne connaît que la façade)
//   4) Le HTML
// ============================================================================

session_start();

// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';

// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }

// ============================================================================
// 1) LES CLASSES
// ============================================================================

// --- Le panier : la classe la plus centrale est la plus bête. AUCUN pattern.
class Panier {
    /** @var array<string,int> nom du produit => quantité */
    private array $lignes = [];
    public function __construct(private array $catalogue) {}
    public function ajouter(string $produit): void {
        if (isset($this->catalogue[$produit])) {
            $this->lignes[$produit] = ($this->lignes[$produit] ?? 0) + 1;
        }
    }
    public function vider(): void { $this->lignes = []; }
    public function lignes(): array { return $this->lignes; }
    public function estVide(): bool { return $this->lignes === []; }
    public function total(): float {
        $t = 0.0;
        foreach ($this->lignes as $produit => $qte) {
            $t += $this->catalogue[$produit] * $qte;
        }
        return $t;
    }
}

// --- STRATEGY : les frais de port varient -----------------------------------
// Une interface, des calculs interchangeables. Ajouter un transporteur demain
// = ajouter UNE classe. Ni le panier, ni le checkout, ni le HTML ne bougent.
interface FraisDePort {
    public function calculer(Panier $p): float;
}
class Colissimo implements FraisDePort {
    public function calculer(Panier $p): float { return 4.99; }
}
class Chronopost implements FraisDePort {
    public function calculer(Panier $p): float { return 9.99; }
}
class RetraitMagasin implements FraisDePort {
    public function calculer(Panier $p): float { return 0.0; }
}

// --- DECORATOR : la promo s'empile sur N'IMPORTE quel transporteur ----------
// Même interface que ce qu'il enveloppe : pour le reste du code, un transporteur
// décoré EST un transporteur. C'est ça qui rend la promo empilable.
class FraisOffertsDes implements FraisDePort {
    public function __construct(private FraisDePort $base, private float $seuil) {}
    public function calculer(Panier $p): float {
        return $p->total() >= $this->seuil ? 0.0 : $this->base->calculer($p);
    }
}

// --- STATE : la commande change de comportement selon son étape -------------
// Chaque état sait ce qu'« annuler » veut dire POUR LUI. La commande remplace
// son objet-état à chaque étape, et annuler() délègue. Zéro if, pour toujours.
interface EtatCommande {
    public function annuler(Commande $c): string;  // renvoie ce qui s'est passé
    public function libelle(string $lang): string;
}
class AuPanier implements EtatCommande {
    public function annuler(Commande $c): string {
        $c->panier()->vider();
        return $c->lang() === 'en' ? 'Cart emptied. Nothing to refund: nothing was paid.' : 'Panier vidé. Rien à rembourser : rien n\'était payé.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'in cart' : 'au panier'; }
}
class Payee implements EtatCommande {
    public function annuler(Commande $c): string {
        $c->changerEtat(new AuPanier());
        return $c->lang() === 'en' ? 'Order refunded. Items are back in your cart.' : 'Commande remboursée. Les articles sont de retour dans le panier.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'paid' : 'payée'; }
}
class Expediee implements EtatCommande {
    public function annuler(Commande $c): string {
        return $c->lang() === 'en' ? 'Too late to refund directly: a carrier return has been requested.' : 'Trop tard pour rembourser directement : un retour transporteur est demandé.';
    }
    public function libelle(string $lang): string { return $lang === 'en' ? 'shipped' : 'expédiée'; }
}
class Commande {
    private EtatCommande $etat;
    public function __construct(private Panier $panier, private string $lang) {
        $this->etat = new AuPanier();   // état de départ
    }
    public function payer(): void    { $this->etat = new Payee(); }
    public function expedier(): void { $this->etat = new Expediee(); }
    public function annuler(): string { return $this->etat->annuler($this); }
    public function changerEtat(EtatCommande $e): void { $this->etat = $e; }
    public function etat(): EtatCommande { return $this->etat; }
    public function panier(): Panier { return $this->panier; }
    public function lang(): string { return $this->lang; }
}

// --- OBSERVER : payer déclenche l'inconnu ------------------------------------
// Une liste d'abonnés + une boucle : c'est TOUT le pattern. La commande publie,
// elle ne connaît ni l'email, ni le stock, ni les abonnés de demain.
class Evenements {
    /** @var array<string, callable[]> */
    private array $abonnes = [];
    public function abonner(string $evt, callable $fn): void {
        $this->abonnes[$evt][] = $fn;
    }
    public function publier(string $evt, $donnees = null): void {
        foreach ($this->abonnes[$evt] ?? [] as $fn) { $fn($donnees); }
    }
}

// --- FACADE : un seul point d'entrée -----------------------------------------
// Le contrôleur (le POST plus bas) ne connaît que cet objet. Les dépendances
// arrivent en INTERFACES : c'est aussi l'inversion de dépendance de Clean
// Architecture. Les deux livres convergent sur le même geste.
class Checkout {
    public function __construct(
        private FraisDePort $livraison,   // la Strategy (éventuellement décorée)
        private Evenements  $evenements,  // l'Observer
    ) {}
    public function totalAPayer(Panier $p): float {
        return $p->total() + $this->livraison->calculer($p);
    }
    public function fraisDePort(Panier $p): float {
        return $this->livraison->calculer($p);
    }
    public function commander(Commande $c): void {
        $c->payer();
        $this->evenements->publier('commande.payee', $c);
    }
}

// ============================================================================
// 2) L'ÉTAT DE LA DÉMO (session) + L'ASSEMBLAGE À LA RACINE
// ============================================================================

$catalogue = [
    'Clavier mécanique' => 79.00,
    'Tasse « it works »' => 12.50,
    'Stickers (lot de 10)' => 4.00,
];
$catalogueEn = [
    'Clavier mécanique' => 'Mechanical keyboard',
    'Tasse « it works »' => '"It works" mug',
    'Stickers (lot de 10)' => 'Stickers (pack of 10)',
];

// L'état persistant de la démo : lignes du panier, choix, statut, journal.
$_SESSION['boutique'] ??= [
    'lignes' => [], 'transporteur' => 'colissimo', 'promo' => false,
    'statut' => 'panier', 'journal' => [],
];
$etatDemo = &$_SESSION['boutique'];

// On reconstruit les objets depuis la session (une démo HTTP est sans mémoire :
// chaque requête repart de zéro, la session est notre disque dur).
$panier = new Panier($catalogue);
foreach ($etatDemo['lignes'] as $produit => $qte) {
    for ($i = 0; $i < $qte; $i++) { $panier->ajouter($produit); }
}
$commande = new Commande($panier, $lang);
if ($etatDemo['statut'] === 'payee')    { $commande->payer(); }
if ($etatDemo['statut'] === 'expediee') { $commande->expedier(); }

// --- Les abonnés Observer : chacun ignore l'existence des autres ------------
$evenements = new Evenements();
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '📧 Email subscriber: confirmation sent' : '📧 Abonné email : confirmation envoyée';
});
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '📦 Stock subscriber: items reserved' : '📦 Abonné stock : articles réservés';
});
$evenements->abonner('commande.payee', function () use (&$etatDemo, $lang) {
    $etatDemo['journal'][] = $lang === 'en' ? '🧾 Accounting subscriber: invoice drafted' : '🧾 Abonné compta : facture préparée';
});

// --- L'ASSEMBLAGE : tous les choix concrets se font ICI, à la racine ---------
// C'est le seul endroit du fichier qui connaît les classes concrètes.
$transporteurs = [
    'colissimo' => new Colissimo(),
    'chrono'    => new Chronopost(),
    'retrait'   => new RetraitMagasin(),
];
$livraison = $transporteurs[$etatDemo['transporteur']] ?? $transporteurs['colissimo'];
if ($etatDemo['promo']) {
    $livraison = new FraisOffertsDes($livraison, 50.00);   // le Decorator s'empile
}
$checkout = new Checkout($livraison, $evenements);

// ============================================================================
// 3) LE « CONTRÔLEUR » : il ne connaît que la façade (et la session)
// ============================================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action = $_POST['action'] ?? '';

    if ($action === 'ajouter' && $etatDemo['statut'] === 'panier') {
        $produit = (string)($_POST['produit'] ?? '');
        if (isset($catalogue[$produit])) {
            $etatDemo['lignes'][$produit] = ($etatDemo['lignes'][$produit] ?? 0) + 1;
        }
    }
    if ($action === 'options' && $etatDemo['statut'] === 'panier') {
        $t = (string)($_POST['transporteur'] ?? 'colissimo');
        $etatDemo['transporteur'] = isset($transporteurs[$t]) ? $t : 'colissimo';
        $etatDemo['promo'] = isset($_POST['promo']);
    }
    if ($action === 'payer' && $etatDemo['statut'] === 'panier' && !$panier->estVide()) {
        $checkout->commander($commande);            // la façade orchestre
        $etatDemo['statut'] = 'payee';
    }
    if ($action === 'expedier' && $etatDemo['statut'] === 'payee') {
        $commande->expedier();
        $etatDemo['statut'] = 'expediee';
        $etatDemo['journal'][] = $lang === 'en' ? '🚚 Order handed to the carrier' : '🚚 Commande remise au transporteur';
    }
    if ($action === 'annuler') {
        $resultat = $commande->annuler();           // STATE décide tout seul
        $etatDemo['journal'][] = '↩️ ' . $resultat;
        // on resynchronise la session avec ce que l'état a décidé
        $etatDemo['statut'] = $commande->etat()->libelle('fr') === 'payée' ? 'payee'
            : ($commande->etat()->libelle('fr') === 'expédiée' ? 'expediee' : 'panier');
        if ($etatDemo['statut'] === 'panier' && $panier->estVide()) { $etatDemo['lignes'] = []; }
    }
    if ($action === 'reset') {
        $etatDemo = ['lignes' => [], 'transporteur' => 'colissimo', 'promo' => false, 'statut' => 'panier', 'journal' => []];
    }

    // PRG : on redirige pour éviter le re-POST au F5.
    header('Location: ?lang=' . $lang);
    exit;
}

// ============================================================================
// 4) LE HTML
// ============================================================================
$S = [
  'fr' => [
    'title' => 'La boutique aux patterns', 'sub' => 'Chaque bouton de cette page traverse un design pattern. Le journal en bas raconte lesquels.',
    'cat' => 'Catalogue', 'add' => 'Ajouter',
    'cart' => 'Panier', 'empty' => 'Panier vide. Ajoute un article ci-dessus.',
    'ship' => 'Livraison (Strategy)', 'promo' => 'Code promo : offert dès 50 € (Decorator)',
    'colissimo' => 'Colissimo · 4,99 €', 'chrono' => 'Chronopost · 9,99 €', 'retrait' => 'Retrait magasin · 0 €',
    'apply' => 'Appliquer',
    'subtotal' => 'Sous-total', 'fees' => 'Frais de port', 'total' => 'Total à payer',
    'status' => 'Statut de la commande (State)', 'pay' => 'Payer', 'shipit' => 'Expédier', 'cancel' => 'Annuler',
    'log' => 'Journal des événements (Observer)', 'logEmpty' => 'Rien pour l\'instant. Paie la commande et regarde qui se réveille.',
    'reset' => 'Réinitialiser la démo',
    'note' => 'Annuler ne contient aucun if : c\'est l\'objet-état courant qui décide (vider, rembourser ou demander un retour).',
    'credit' => '5 design patterns dans un fichier PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
    'statuts' => ['panier' => 'au panier', 'payee' => 'payée', 'expediee' => 'expédiée'],
  ],
  'en' => [
    'title' => 'The pattern shop', 'sub' => 'Every button on this page goes through a design pattern. The log below tells you which.',
    'cat' => 'Catalog', 'add' => 'Add',
    'cart' => 'Cart', 'empty' => 'Empty cart. Add an item above.',
    'ship' => 'Shipping (Strategy)', 'promo' => 'Promo code: free over €50 (Decorator)',
    'colissimo' => 'Standard post · €4.99', 'chrono' => 'Express · €9.99', 'retrait' => 'Store pickup · €0',
    'apply' => 'Apply',
    'subtotal' => 'Subtotal', 'fees' => 'Shipping fee', 'total' => 'Total to pay',
    'status' => 'Order status (State)', 'pay' => 'Pay', 'shipit' => 'Ship', 'cancel' => 'Cancel',
    'log' => 'Event log (Observer)', 'logEmpty' => 'Nothing yet. Pay the order and watch who wakes up.',
    'reset' => 'Reset the demo',
    'note' => 'Cancel contains no if: the current state object decides (empty, refund, or request a return).',
    'credit' => '5 design patterns in one PHP file · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
    'statuts' => ['panier' => 'in cart', 'payee' => 'paid', 'expediee' => 'shipped'],
  ],
][$lang];

$sousTotal = $panier->total();
// panier vide : pas de frais à afficher (on ne livre pas du vide)
$frais = $panier->estVide() ? 0.0 : $checkout->fraisDePort($panier);
$total = $sousTotal + $frais;
$statut = $etatDemo['statut'];
?>
<!DOCTYPE html>
<html lang="<?= $lang ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title><?= e($S['title']) ?></title>
<style>
  :root { --accent:#329e5a; --ink:#23262b; --muted:#6a7178; --bg:#f6f4ef; --card:#fff; --line:#e4e1d8; --warn:#a84f2d; }
  * { box-sizing:border-box; }
  body { margin:0; font-family:system-ui,-apple-system,'Segoe UI',sans-serif; background:var(--bg); color:var(--ink); padding:18px 14px 30px; }
  .wrap { max-width:660px; margin:0 auto; }
  h1 { font-size:1.35rem; margin:0 0 4px; }
  .sub { color:var(--muted); font-size:.9rem; margin:0 0 18px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px; margin-bottom:14px; }
  .card h2 { font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent); margin:0 0 10px; }
  .prod { display:flex; justify-content:space-between; align-items:center; padding:7px 0; border-bottom:1px dashed var(--line); gap:8px; }
  .prod:last-child { border-bottom:0; }
  .price { color:var(--muted); white-space:nowrap; }
  button { font:inherit; border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:8px; padding:7px 14px; cursor:pointer; }
  button.ghost { background:transparent; color:var(--accent); }
  button.warn { background:transparent; color:var(--warn); border-color:var(--warn); }
  button:disabled { opacity:.4; cursor:not-allowed; }
  label { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:pointer; }
  .totaux { border-top:2px solid var(--ink); margin-top:10px; padding-top:10px; }
  .row { display:flex; justify-content:space-between; padding:3px 0; }
  .row.big { font-weight:700; font-size:1.05rem; }
  .statut { display:inline-block; padding:3px 12px; border-radius:999px; font-weight:700; font-size:.85rem; }
  .st-panier   { background:#eef3f7; color:#456; }
  .st-payee    { background:#e6f3ec; color:#267d42; }
  .st-expediee { background:#fdf0e7; color:#a84f2d; }
  .actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
  .log { list-style:none; margin:0; padding:0; font-size:.9rem; }
  .log li { padding:5px 0; border-bottom:1px dashed var(--line); }
  .log li:last-child { border-bottom:0; }
  .muted { color:var(--muted); font-size:.85rem; }
  .note { font-size:.82rem; color:var(--muted); margin-top:10px; }
  .credit { text-align:center; font-size:.78rem; color:var(--muted); margin-top:16px; }
  .credit a { color:var(--accent); }
  .resetline { text-align:center; margin-top:10px; }
  .barre { text-decoration:line-through; color:var(--muted); font-weight:400; margin-right:6px; }
</style>
</head>
<body>
<div class="wrap">
  <h1><?= e($S['title']) ?></h1>
  <p class="sub"><?= e($S['sub']) ?></p>

  <div class="card">
    <h2><?= e($S['cat']) ?></h2>
    <?php foreach ($catalogue as $produit => $prix): ?>
    <div class="prod">
      <span><?= e($lang === 'en' ? $catalogueEn[$produit] : $produit) ?></span>
      <span class="price"><?= number_format($prix, 2, ',', ' ') ?> €</span>
      <form method="post" style="margin:0">
        <input type="hidden" name="action" value="ajouter">
        <input type="hidden" name="produit" value="<?= e($produit) ?>">
        <input type="hidden" name="lang" value="<?= e($lang) ?>">
        <button class="ghost" <?= $statut !== 'panier' ? 'disabled' : '' ?>>+ <?= e($S['add']) ?></button>
      </form>
    </div>
    <?php endforeach; ?>
  </div>

  <div class="card">
    <h2><?= e($S['cart']) ?></h2>
    <?php if ($panier->estVide()): ?>
      <p class="muted"><?= e($S['empty']) ?></p>
    <?php else: ?>
      <?php foreach ($panier->lignes() as $produit => $qte): ?>
      <div class="prod">
        <span><?= e($lang === 'en' ? $catalogueEn[$produit] : $produit) ?> × <?= (int)$qte ?></span>
        <span class="price"><?= number_format($catalogue[$produit] * $qte, 2, ',', ' ') ?> €</span>
      </div>
      <?php endforeach; ?>
    <?php endif; ?>

    <form method="post" style="margin-top:12px">
      <input type="hidden" name="action" value="options">
      <input type="hidden" name="lang" value="<?= e($lang) ?>">
      <h2 style="margin-top:6px"><?= e($S['ship']) ?></h2>
      <label><input type="radio" name="transporteur" value="colissimo" <?= $etatDemo['transporteur'] === 'colissimo' ? 'checked' : '' ?>> <?= e($S['colissimo']) ?></label>
      <label><input type="radio" name="transporteur" value="chrono" <?= $etatDemo['transporteur'] === 'chrono' ? 'checked' : '' ?>> <?= e($S['chrono']) ?></label>
      <label><input type="radio" name="transporteur" value="retrait" <?= $etatDemo['transporteur'] === 'retrait' ? 'checked' : '' ?>> <?= e($S['retrait']) ?></label>
      <label style="margin-top:4px"><input type="checkbox" name="promo" <?= $etatDemo['promo'] ? 'checked' : '' ?>> <?= e($S['promo']) ?></label>
      <button class="ghost" <?= $statut !== 'panier' ? 'disabled' : '' ?>><?= e($S['apply']) ?></button>
    </form>

    <div class="totaux">
      <div class="row"><span><?= e($S['subtotal']) ?></span><span><?= number_format($sousTotal, 2, ',', ' ') ?> €</span></div>
      <div class="row"><span><?= e($S['fees']) ?></span>
        <span>
          <?php if ($etatDemo['promo'] && $frais == 0 && $sousTotal >= 50): ?>
            <span class="barre"><?= number_format($transporteurs[$etatDemo['transporteur']]->calculer($panier), 2, ',', ' ') ?> €</span>
          <?php endif; ?>
          <?= number_format($frais, 2, ',', ' ') ?> €
        </span>
      </div>
      <div class="row big"><span><?= e($S['total']) ?></span><span><?= number_format($total, 2, ',', ' ') ?> €</span></div>
    </div>
  </div>

  <div class="card">
    <h2><?= e($S['status']) ?></h2>
    <span class="statut st-<?= e($statut) ?>"><?= e($S['statuts'][$statut]) ?></span>
    <div class="actions">
      <form method="post"><input type="hidden" name="action" value="payer"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button <?= ($statut !== 'panier' || $panier->estVide()) ? 'disabled' : '' ?>><?= e($S['pay']) ?></button></form>
      <form method="post"><input type="hidden" name="action" value="expedier"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button <?= $statut !== 'payee' ? 'disabled' : '' ?>><?= e($S['shipit']) ?></button></form>
      <form method="post"><input type="hidden" name="action" value="annuler"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button class="warn" <?= ($statut === 'panier' && $panier->estVide()) ? 'disabled' : '' ?>><?= e($S['cancel']) ?></button></form>
    </div>
    <p class="note"><?= e($S['note']) ?></p>
  </div>

  <div class="card">
    <h2><?= e($S['log']) ?></h2>
    <?php if (empty($etatDemo['journal'])): ?>
      <p class="muted"><?= e($S['logEmpty']) ?></p>
    <?php else: ?>
      <ul class="log">
        <?php foreach (array_reverse($etatDemo['journal']) as $evt): ?>
        <li><?= e($evt) ?></li>
        <?php endforeach; ?>
      </ul>
    <?php endif; ?>
  </div>

  <div class="resetline">
    <form method="post" style="display:inline"><input type="hidden" name="action" value="reset"><input type="hidden" name="lang" value="<?= e($lang) ?>"><button class="ghost"><?= e($S['reset']) ?></button></form>
  </div>

  <p class="credit"><?= $S['credit'] ?></p>
</div>
</body>
</html>

Your turn

The game now is to absorb the owner's next requests without reopening existing classes:

First rung, as a warm-up: the relay-point carrier (€3.50), skeleton with holes. Fill the two /* ??? */ from memory, without scrolling up:

class RelayPoint implements /* ??? */ {
    public function compute(Cart $c): float {
        return /* ??? */;
    }
}
// ... and ONE line at the assembly. No other class moves: that's the test.
  • Add a "🎁 loyalty: points credited" subscriber on payment: one subscription line, zero change elsewhere.
  • Harder: a Dispute state (after shipping, the customer contests) where "cancel" becomes "open a case".
Challenge: a second promo, stacked on the first

The owner launches "€2 off shipping for newsletter subscribers", cumulative with "free over €50". Code a ShippingDiscount class and wire it. Try it alone first, and only unfold the solution after attempting.

You've succeeded if:

  • no existing class is modified: not the carriers, not FreeShippingOver, not Checkout;
  • both promos stack in any order and the fee never drops below €0;
  • everything is decided at the assembly, at the root.
See a solution hint
// Same interface, same move: one more wrapper.
class ShippingDiscount implements ShippingFee {
    public function __construct(
        private ShippingFee $base,
        private float $discount,
    ) {}
    public function compute(Cart $c): float {
        // max(0, ...): a discount never creates negative fees
        return max(0.0, $this->base->compute($c) - $this->discount);
    }
}

// At the assembly, wrappers nest like Russian dolls:
$shipping = new ShippingDiscount(
    new FreeShippingOver(new Express(), 50),
    2.00,
);
// Express doesn't know it's decorated. FreeShippingOver doesn't either.
// That's composition: each part ignores the others' existence.

If you modified an existing class, re-read the Decorator: the promo isn't a property of the carriers, it's a wrapper around them. And the max(0.0, ...) is the kind of detail you only catch by testing: promo + store pickup = 0 - 2 = -€2 without it.

The sequel lives in the books

You just practiced five patterns, each called in by a real need, and refused two. The full catalog (23 patterns, and above all the two principles behind them) is told in the library's Design Patterns notes, and its illustrated version in Head First Design Patterns.

Read the Design Patterns notes →
Next step

Well done, you just finished all thirteen applied projects, from a punchline card to a shop designed to absorb change. You now have a genuine instinct for building with AI: explore the rest of the catalog to go further.

Back to the catalog →

Spotted an error in this lesson, something unclear, a question? Email me: every message helps improve this course.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement