La programmation orientée objet
Des objets qui protègent leurs règles, et du code qu'on étend sans tout réécrire.
Écrire des classes ne suffit pas à faire de la programmation orientée objet. Un code avec des classes pleines de getters et de setters, et toute la logique dans des contrôleurs de 300 lignes, reste du code procédural déguisé. La POO devient utile quand les objets portent leurs propres règles, et quand on peut ajouter un comportement sans modifier ce qui marche déjà.
Tu utilises déjà ces idées tous les jours, parfois sans les nommer : Symfony est construit dessus. Cette fiche leur donne un nom, montre le problème que chacune résout, et dit quand elle coûte plus qu'elle ne rapporte.
1.L'encapsulation : un objet protège ses règles
Une commande ne doit pas être expédiée avant d'être payée. Où écrire cette règle ? Si l'état de la commande est public, chaque contrôleur, chaque commande console, chaque service doit penser à la vérifier. Il suffit d'un oubli.
<?php
declare(strict_types=1);
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
}
// ✗ Tout est public : n'importe quel code peut mettre la commande dans n'importe quel état
final class OpenOrder
{
public OrderStatus $status = OrderStatus::Pending;
}
$open = new OpenOrder();
$open->status = OrderStatus::Shipped; // expédiée sans avoir été payée, et rien ne l'empêche
echo 'Commande ouverte : ', $open->status->value, "\n";
// ✓ L'état se lit, mais ne change qu'à travers des méthodes qui portent les règles
final class Order
{
public private(set) OrderStatus $status = OrderStatus::Pending;
public private(set) ?string $trackingNumber = null;
public function pay(): void
{
if ($this->status !== OrderStatus::Pending) {
throw new LogicException('Seule une commande en attente peut être payée');
}
$this->status = OrderStatus::Paid;
}
public function ship(string $trackingNumber): void
{
if ($this->status !== OrderStatus::Paid) {
throw new LogicException('Une commande doit être payée avant d\'être expédiée');
}
$this->status = OrderStatus::Shipped;
$this->trackingNumber = $trackingNumber;
}
}
$order = new Order();
try {
$order->ship('FR123');
} catch (LogicException $error) {
echo 'Refusé : ', $error->getMessage(), "\n";
}
$order->pay();
$order->ship('FR123');
echo 'Commande protégée : ', $order->status->value, ', suivi ', $order->trackingNumber, "\n";Commande ouverte : shipped Refusé : Une commande doit être payée avant d'être expédiée Commande protégée : shipped, suivi FR123
L'encapsulation consiste à cacher l'état interne d'un objet et à ne le laisser changer qu'à travers des méthodes qui vérifient les règles. La règle est écrite une seule fois, à l'endroit où elle ne peut pas être contournée.
- Des méthodes métier plutôt que des setters :
pay()etship()disent ce qui se passe,setStatus()permet n'importe quoi. - « Dis, ne demande pas » (tell, don't ask) : plutôt que de lire l'état d'un objet pour décider à sa place, demande-lui de faire l'action. Il sait mieux que toi s'il le peut.
- La visibilité asymétrique de PHP 8.4 (fiche 5) rend l'état lisible sans le rendre modifiable.
Un formulaire ou une API qui copie tous les champs reçus dans une entité, avec ses setters, laisse un attaquant ajouter un champ que tu n'avais pas prévu : isAdmin, status, price. C'est l'affectation de masse (mass assignment). Ne lie jamais aveuglément une requête à une entité : déclare seulement les champs autorisés dans le formulaire Symfony, ou passe par un DTO (un petit objet qui ne contient que les champs attendus), puis appelle les méthodes métier.
Un objet qui ne fait que transporter des données (un DTO, une ligne de rapport) n'a pas de règle à protéger : des propriétés publiques readonly suffisent. L'encapsulation sert là où il y a des règles métier.
2.L'abstraction : garder l'essentiel
Quand tu appelles orderTotalInCents($items), tu ne relis pas le calcul : le nom de la fonction te suffit. Tu sais ce qu'elle fait, sans avoir besoin de savoir comment. C'est une abstraction.
L'abstraction consiste à ne garder que l'essentiel d'une chose et à cacher ses détails, pour pouvoir l'utiliser sans y penser. L'encapsulation protège l'intérieur d'un objet. L'abstraction simplifie la façon de s'en servir. Les deux vont ensemble, mais ce n'est pas la même idée.
- Un bon nom de fonction ou de méthode est déjà une abstraction (fiche 2).
- Une interface va plus loin : elle décrit ce que savent faire plusieurs classes différentes. Dans la section 3, le calculateur sait qu'un mode de livraison « a des frais », et ignore tout de leur calcul.
- Une classe abstraite est une classe parente incomplète. On ne peut pas l'instancier : elle sert de base à des classes enfants, leur fournit du code commun, et les oblige à écrire certaines méthodes.
Voici une classe abstraite qui fixe le déroulement d'un rapport de ventes, et laisse aux enfants le seul détail qui change, le format :
<?php
declare(strict_types=1);
// La classe abstraite fixe le déroulement commun, et laisse un seul détail aux enfants
abstract class SalesReport
{
/** @param list<array{product: string, quantity: int}> $rows */
final public function render(array $rows): string
{
$lines = [$this->header()];
foreach ($rows as $row) {
$lines[] = $this->formatRow($row['product'], $row['quantity']);
}
$lines[] = 'Total : ' . array_sum(array_column($rows, 'quantity')) . ' article(s)';
return implode("\n", $lines) . "\n";
}
// Les enfants DOIVENT écrire ces deux méthodes
abstract protected function header(): string;
abstract protected function formatRow(string $product, int $quantity): string;
}
final class CsvSalesReport extends SalesReport
{
protected function header(): string
{
return 'product;quantity';
}
protected function formatRow(string $product, int $quantity): string
{
return "$product;$quantity";
}
}
final class MarkdownSalesReport extends SalesReport
{
protected function header(): string
{
return "| Produit | Quantité |\n|---|---|";
}
protected function formatRow(string $product, int $quantity): string
{
return "| $product | $quantity |";
}
}
$rows = [
['product' => 'Clavier', 'quantity' => 3],
['product' => 'Souris', 'quantity' => 5],
];
echo (new CsvSalesReport())->render($rows), "\n";
echo (new MarkdownSalesReport())->render($rows), "\n";
try {
new SalesReport(); // une classe abstraite ne s'instancie pas
} catch (Error $error) {
echo $error->getMessage(), "\n";
}product;quantity Clavier;3 Souris;5 Total : 8 article(s) | Produit | Quantité | |---|---| | Clavier | 3 | | Souris | 5 | Total : 8 article(s) Cannot instantiate abstract class SalesReport
- Les méthodes
abstractn'ont pas de corps : chaque enfant doit les écrire, sinon PHP refuse de charger la classe. render()estfinal: les enfants ne peuvent pas modifier le déroulement, seulement les détails prévus. Ce montage s'appelle le pattern Template Method (méthode modèle).
| Interface | Classe abstraite | |
|---|---|---|
| Ce qu'elle contient | Un contrat : des signatures de méthodes, sans code | Du code commun, de l'état, et des méthodes à compléter |
| Combien par classe | Une classe peut en implémenter plusieurs | Une classe n'a qu'une seule parente |
| Le lien créé | « sait faire » | « est un » |
| Exemple dans Symfony | EventSubscriberInterface, UserInterface | AbstractController, Voter |
| À choisir | Par défaut, pour relier des composants | Quand il y a vraiment du code commun à partager, dans une relation stable |
Une mauvaise abstraction coûte plus cher que pas d'abstraction du tout. Deux morceaux de code qui se ressemblent aujourd'hui peuvent évoluer différemment demain. Si tu les as fusionnés trop tôt, chaque nouvelle différence ajoute un paramètre ou un if dans le code commun, jusqu'à ce que plus personne ne le comprenne. Sandi Metz le résume ainsi : « duplication is far cheaper than the wrong abstraction » (la duplication coûte bien moins cher que la mauvaise abstraction).
- La règle de trois : tolère une répétition, et abstrais à la troisième, quand tu vois enfin ce qui est vraiment commun.
- Méfie-toi des abstractions qui fuient : Doctrine te cache le SQL, mais une boucle qui déclenche cent requêtes (le problème N+1, fiche 8) te rappelle qu'il est toujours là. Une abstraction simplifie l'usage, elle ne dispense pas de comprendre ce qu'elle cache.
3.Interfaces et polymorphisme
Le calcul des frais de port dépend du mode de livraison : domicile, point relais, retrait en magasin. La première idée est un grand if/elseif. À chaque nouveau mode, il faut rouvrir la même méthode, la relire en entier, et risquer de casser les autres cas.
Une interface est un contrat : elle dit ce qu'un objet sait faire, sans dire comment. Plusieurs classes peuvent l'implémenter, chacune à sa façon. Le code qui l'utilise ne connaît que le contrat : c'est le polymorphisme (des objets différents utilisés de la même manière).
<?php
declare(strict_types=1);
// Le contrat : ce que tout mode de livraison sait faire
interface ShippingMethod
{
public function supports(string $code): bool;
public function feeInCents(int $weightInGrams): int;
}
// Les implémentations : chacune sa règle, chacune dans sa classe
final class HomeDelivery implements ShippingMethod
{
public function supports(string $code): bool
{
return $code === 'home';
}
public function feeInCents(int $weightInGrams): int
{
// 4,99 €, plus 1,50 € par kilo complet
return 499 + intdiv($weightInGrams, 1000) * 150;
}
}
final class PickupPoint implements ShippingMethod
{
public function supports(string $code): bool
{
return $code === 'pickup';
}
public function feeInCents(int $weightInGrams): int
{
return 299;
}
}
final class StorePickup implements ShippingMethod
{
public function supports(string $code): bool
{
return $code === 'store';
}
public function feeInCents(int $weightInGrams): int
{
return 0;
}
}
// Le code qui utilise : il ne connaît que l'interface, jamais les classes concrètes
final class ShippingCalculator
{
/** @param iterable<ShippingMethod> $methods */
public function __construct(private iterable $methods) {}
public function feeInCents(string $code, int $weightInGrams): int
{
foreach ($this->methods as $method) {
if ($method->supports($code)) {
return $method->feeInCents($weightInGrams);
}
}
throw new InvalidArgumentException("Mode de livraison inconnu : $code");
}
}
$calculator = new ShippingCalculator([
new HomeDelivery(),
new PickupPoint(),
new StorePickup(),
]);
foreach (['home', 'pickup', 'store'] as $code) {
printf("%-6s 2,5 kg : %5.2f €\n", $code, $calculator->feeInCents($code, 2500) / 100);
}home 2,5 kg : 7.99 € pickup 2,5 kg : 2.99 € store 2,5 kg : 0.00 €
Ce montage porte un nom : le pattern Strategy (stratégie). Une famille d'algorithmes interchangeables, choisie au moment de l'exécution. Ajouter un mode de livraison, c'est ajouter une classe, sans toucher au calculateur ni aux autres modes.
Dans Symfony, le conteneur de services fait l'assemblage tout seul :
<?php
// Dans Symfony, le conteneur de services fait l'assemblage à ta place
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
// 1. Toute classe qui implémente l'interface reçoit automatiquement l'étiquette
#[AutoconfigureTag('app.shipping_method')]
interface ShippingMethod
{
public function supports(string $code): bool;
public function feeInCents(int $weightInGrams): int;
}
// 2. Le calculateur reçoit tous les services qui portent cette étiquette
final class ShippingCalculator
{
/** @param iterable<ShippingMethod> $methods */
public function __construct(
#[AutowireIterator('app.shipping_method')]
private iterable $methods,
) {}
}
// 3. Ajouter un mode de livraison = créer une classe. Aucune autre ligne à modifier.Tu utilises déjà ce pattern sans le savoir : les Voters de la sécurité, les normalizers du Serializer et les value resolvers des contrôleurs fonctionnent exactement ainsi.
Deux cas qui ne changeront probablement jamais tiennent très bien dans un match (fiche 5). Le Strategy devient rentable quand les cas se multiplient, quand chacun a une logique non triviale, ou quand d'autres équipes doivent pouvoir en ajouter.
4.Composition plutôt qu'héritage
L'héritage (extends) semble pratique pour réutiliser du code. Mais dès qu'on combine plusieurs comportements, le nombre de classes explose :
<?php
// Avec l'héritage, chaque combinaison de comportements demande sa propre classe…
class EmailNotifier {}
class LoggingEmailNotifier extends EmailNotifier {}
class SmsNotifier {}
class LoggingSmsNotifier extends SmsNotifier {}
class RetryingLoggingEmailNotifier extends LoggingEmailNotifier {}
class RetryingLoggingSmsNotifier extends LoggingSmsNotifier {}
// … et un nouveau canal (Slack) ou un nouveau comportement (retry) double le nombre de classes.La composition consiste à construire un objet en lui donnant d'autres objets, plutôt qu'en héritant d'eux. Ici, un objet qui en enveloppe un autre pour lui ajouter un comportement s'appelle un décorateur :
<?php
declare(strict_types=1);
interface Notifier
{
public function send(string $to, string $message): void;
}
final class EmailNotifier implements Notifier
{
public function send(string $to, string $message): void
{
echo " [e-mail] à $to : $message\n";
}
}
final class SmsNotifier implements Notifier
{
public function send(string $to, string $message): void
{
echo " [SMS] à $to : $message\n";
}
}
// Un « décorateur » : il enveloppe N'IMPORTE QUEL Notifier et lui ajoute un comportement
final class LoggingNotifier implements Notifier
{
public function __construct(private Notifier $inner) {}
public function send(string $to, string $message): void
{
echo " [log] envoi vers $to\n";
$this->inner->send($to, $message);
}
}
final class DisabledInTestNotifier implements Notifier
{
public function __construct(private Notifier $inner, private bool $enabled) {}
public function send(string $to, string $message): void
{
if ($this->enabled) {
$this->inner->send($to, $message);
}
}
}
// On combine les morceaux comme des briques, sans créer de sous-classe
echo "E-mail journalisé :\n";
(new LoggingNotifier(new EmailNotifier()))->send('alice@example.com', 'Commande expédiée');
echo "SMS journalisé :\n";
(new LoggingNotifier(new SmsNotifier()))->send('+33600000000', 'Colis disponible');
echo "SMS désactivé (environnement de test) :\n";
(new DisabledInTestNotifier(new SmsNotifier(), enabled: false))->send('+33600000000', 'Rien');
echo " (aucun envoi)\n";E-mail journalisé : [log] envoi vers alice@example.com [e-mail] à alice@example.com : Commande expédiée SMS journalisé : [log] envoi vers +33600000000 [SMS] à +33600000000 : Colis disponible SMS désactivé (environnement de test) : (aucun envoi)
- Chaque brique fait une seule chose : envoyer, journaliser, désactiver. On les teste séparément.
- On les combine librement, y compris au moment de l'exécution selon la configuration.
- Un nouveau canal ou un nouveau comportement ajoute une classe, au lieu de doubler la hiérarchie.
Symfony l'utilise partout, et propose l'attribut #[AsDecorator] pour décorer un service existant sans le modifier. Marquer les classes final par défaut, comme dans ces exemples, pousse aussi vers la composition : on ne peut plus en hériter par facilité.
Quand la relation est vraiment « est un » et stable : AbstractController, WebTestCase ou une classe d'exception de base sont des usages sains. Le signal d'alarme, c'est une hiérarchie de plus de deux niveaux, ou une classe enfant qui désactive ce que fait sa parente.
5.SOLID, sans dogme
SOLID regroupe cinq principes, formulés par Robert C. Martin. Ce sont des repères pour reconnaître un code qui va devenir pénible à modifier, pas des lois à appliquer partout. Tu en as déjà vu la plupart dans cette fiche.
| Principe | En une phrase | Où tu l'as vu | Quand ça devient excessif |
|---|---|---|---|
| Single responsibility | Une classe n'a qu'une raison de changer. | Chaque mode de livraison dans sa classe | Des dizaines de classes de trois lignes, impossibles à suivre |
| Open/closed | On étend le comportement sans modifier le code existant. | Ajouter un mode de livraison sans toucher au calculateur | Prévoir des points d'extension pour des besoins qui n'arriveront jamais |
| Liskov substitution | Une sous-classe doit pouvoir remplacer sa parente sans surprise. | Tout Notifier peut être décoré | Rarement excessif : c'est le principe à ne jamais violer |
| Interface segregation | Plusieurs petites interfaces valent mieux qu'une grosse. | ShippingMethod n'a que deux méthodes | Une interface par méthode, sans raison |
| Dependency inversion | Dépendre de contrats, pas de classes concrètes. | ShippingCalculator ne connaît que l'interface | Une interface pour une classe qui n'aura jamais qu'une implémentation |
Le principe de Liskov mérite un exemple. Imagine une classe ReadOnlyStorage qui hérite de Storage et dont la méthode save() lève une exception. Tout code qui reçoit un Storage et appelle save() peut planter selon l'objet qu'on lui passe. L'héritage promettait quelque chose que l'enfant ne tient pas. La bonne réponse est souvent deux interfaces séparées : une pour lire, une pour écrire.
6.Les factories : bien construire un objet
Quand construire un objet demande une conversion, une validation ou un choix, on confie ce travail à une factory (fabrique). La forme la plus simple est un constructeur nommé, une méthode statique qui dit d'où vient la valeur :
<?php
declare(strict_types=1);
final readonly class Money
{
// Constructeur privé : on passe forcément par une méthode
// qui dit d'où vient la valeur
private function __construct(public int $amountInCents) {}
public static function fromCents(int $cents): self
{
return new self($cents);
}
// « 19,90 » ou « 19.90 », tel qu'on le saisit dans un formulaire
public static function fromUserInput(string $input): self
{
if (preg_match('/^(\d{1,7})(?:[.,](\d{1,2}))?$/', trim($input), $parts) !== 1) {
throw new InvalidArgumentException("Montant invalide : « $input »");
}
$cents = str_pad($parts[2] ?? '0', 2, '0');
return new self((int) $parts[1] * 100 + (int) $cents);
}
}
echo Money::fromCents(1990)->amountInCents, "\n";
echo Money::fromUserInput('19,9')->amountInCents, "\n";
echo Money::fromUserInput(' 7.05 ')->amountInCents, "\n";
try {
Money::fromUserInput('12,345');
} catch (InvalidArgumentException $error) {
echo $error->getMessage(), "\n";
}1990 1990 705 Montant invalide : « 12,345 »
Money::fromUserInput('19,9')se lit mieux quenew Money(...)suivi d'un calcul. Le constructeur privé oblige à passer par ces méthodes, donc par la validation.- Tu en utilises tous les jours :
DateTimeImmutable::createFromFormat(),Request::create()dans les tests,HttpClient::create(). - Quand la construction dépend de services (configuration, autre service), la factory devient un service à part entière, que Symfony peut aussi utiliser pour créer d'autres services.
Si new Product($name, $price) suffit, une factory n'ajoute qu'une indirection. Elle se justifie quand il y a plusieurs façons de construire l'objet, ou une logique de construction à cacher.
7.Les événements : le pattern Observer
Quand une commande est passée, il faut envoyer un e-mail, réserver le stock, prévenir la comptabilité, et demain peut-être autre chose. Si le service de commande appelle tout ça directement, il grossit à chaque nouvelle demande et dépend de tous les autres services.
Le pattern Observer inverse la relation : le service annonce ce qui s'est passé (un événement), et ceux que ça intéresse (des listeners, ou « écouteurs ») réagissent. Voici le principe, sans Symfony :
<?php
declare(strict_types=1);
// L'événement : un simple objet qui décrit ce qui s'est passé
final readonly class OrderPlaced
{
public function __construct(public int $orderId, public string $customerEmail) {}
}
// Un mini « dispatcher », pour voir le principe sans Symfony
final class EventDispatcher
{
/** @var array<class-string, list<callable>> */
private array $listeners = [];
public function listen(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
public function dispatch(object $event): void
{
foreach ($this->listeners[$event::class] ?? [] as $listener) {
$listener($event);
}
}
}
$dispatcher = new EventDispatcher();
// Chaque réaction est indépendante, et la commande ne les connaît pas
$dispatcher->listen(OrderPlaced::class, function (OrderPlaced $event): void {
echo "E-mail de confirmation à {$event->customerEmail}\n";
});
$dispatcher->listen(OrderPlaced::class, function (OrderPlaced $event): void {
echo "Stock réservé pour la commande {$event->orderId}\n";
});
// Le code qui passe la commande se contente d'annoncer ce qui s'est passé
$dispatcher->dispatch(new OrderPlaced(1042, 'alice@example.com'));E-mail de confirmation à alice@example.com Stock réservé pour la commande 1042
Avec le composant EventDispatcher de Symfony, c'est la même chose, avec la découverte automatique des listeners :
<?php
// La même chose avec le composant EventDispatcher de Symfony
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final class CheckoutService
{
public function __construct(private EventDispatcherInterface $dispatcher) {}
public function placeOrder(Order $order): void
{
// … enregistrement de la commande …
$this->dispatcher->dispatch(new OrderPlaced($order->getId(), $order->getEmail()));
}
}
// Symfony trouve ce listener tout seul grâce à l'attribut
#[AsEventListener]
final class SendOrderConfirmation
{
public function __invoke(OrderPlaced $event): void
{
// … envoi de l'e-mail …
}
}Symfony lui-même fonctionne ainsi : les événements kernel.request, kernel.exception ou ceux de Doctrine permettent de réagir à chaque étape d'une requête sans modifier le framework.
Les événements cachent le déroulement : en lisant placeOrder(), on ne voit plus ce qui se passe ensuite. La commande bin/console debug:event-dispatcher aide, mais pour deux actions toujours liées, un appel direct est plus honnête. Attention aussi : les listeners s'exécutent l'un après l'autre, dans la même requête. Une exception dans l'un arrête les suivants, et un traitement lent ralentit la page. Pour un travail lourd, le composant Messenger le fait en arrière-plan.
8.Quand la POO en fait trop
Chaque abstraction (une interface, une factory, un décorateur) a un coût : un fichier de plus, un saut de plus pour suivre le code, une notion de plus à comprendre pour le prochain développeur. Elle doit rapporter plus qu'elle ne coûte.
- YAGNI (You Aren't Gonna Need It, « tu n'en auras pas besoin ») : n'ajoute pas un point d'extension pour un besoin hypothétique. Ajoute-le le jour où le deuxième cas arrive.
- Une interface pour une seule implémentation est souvent inutile. Symfony injecte très bien une classe concrète, et tu pourras extraire l'interface plus tard, quand un deuxième besoin apparaîtra. Exception : quand l'interface sert de frontière avec l'extérieur (une API de paiement à remplacer dans les tests).
- Une fonction peut rester une fonction. Un calcul pur, sans état, n'a pas besoin d'une classe, d'une interface et d'une factory.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Un setter pour chaque propriété | Les règles métier sont éparpillées dans les contrôleurs | Des méthodes métier qui vérifient les règles |
| Lier une requête directement à une entité | Un attaquant modifie un champ non prévu | Un formulaire limité aux champs autorisés, ou un DTO |
Un if/elseif qui grossit à chaque besoin | Chaque ajout risque de casser les autres cas | Une interface et une classe par cas (Strategy) |
| Hériter pour réutiliser du code | Une hiérarchie qui explose, des parents qu'on n'ose plus modifier | La composition, et des classes final par défaut |
| Une sous-classe qui désactive une méthode | Le code qui reçoit la classe parente plante au hasard | Des interfaces séparées (Liskov) |
| Fusionner deux codes dès qu'ils se ressemblent | Un code commun rempli de paramètres et de if | La règle de trois |
| Une classe abstraite pour partager deux méthodes utilitaires | Une hiérarchie rigide, pour un gain minime | Un service injecté (composition) |
| Une interface pour chaque classe | Deux fichiers à maintenir pour rien | Extraire l'interface quand le deuxième besoin arrive |
| Un listener qui fait un travail lourd | La page attend la fin de l'envoi des e-mails | Messenger en arrière-plan |
| Appliquer SOLID à la lettre | Une architecture complexe pour un problème simple | Des repères, pas des lois : mesurer le coût |
Checklist avant de proposer ton code
- Les règles métier sont dans les objets qui portent les données, pas dans les contrôleurs.
- L'état d'une entité change par des méthodes qui disent ce qui se passe, pas par des setters.
- Aucune requête n'est liée directement à une entité sans liste de champs autorisés.
- Un nouveau cas s'ajoute avec une nouvelle classe, pas en rouvrant un grand
if. - Je n'ai pas utilisé l'héritage seulement pour réutiliser du code.
- Chaque abstraction que j'ai ajoutée répond à un besoin réel d'aujourd'hui, et regroupe ce qui est vraiment commun.
- Mes listeners sont légers, ou délégués à Messenger.
Exercice : remplacer un if par des stratégies
Le fichier exemples/06/exercice.php calcule des frais de paiement avec une suite de elseif. Aujourd'hui, il affiche :
card sur 100 € : 165 centimes paypal sur 100 € : 325 centimes transfer sur 100 € : 0 centimes
- Crée une interface
PaymentMethodet une classe par moyen de paiement. - Réécris
PaymentFeeCalculatorpour qu'il reçoive la liste des moyens de paiement, comme le calculateur de livraison de la section 3. La sortie doit rester exactement la même. - Ajoute un moyen de paiement « bon d'achat » (
voucher), sans frais. Combien de fichiers existants as-tu dû modifier ? - Ajoute un décorateur qui plafonne les frais à 5 € pour tous les moyens de paiement. Pourquoi est-ce plus simple qu'avec l'héritage ?
- Question de recul : pour trois moyens de paiement qui ne changent jamais, cette architecture valait-elle le coup ? À partir de quand devient-elle rentable ?
docker compose run --rm php php exemples/06/exercice.php
Conseils
- Pars du problème, pas du pattern. On ne se dit pas « je vais faire un Strategy », on constate qu'un
ifgrossit, et le Strategy est une réponse possible. - Lis le code de Symfony. Ouvre un Voter, un listener du noyau ou un décorateur : ce sont des exemples concrets et soignés de chaque pattern de cette fiche.
- Le test révèle la conception. Une classe difficile à tester (fiche 3) dépend souvent de trop de choses concrètes. Une dépendance passée au constructeur se remplace facilement par une doublure.
- Refactore vers un pattern, pas depuis. Commence simple, et introduis l'abstraction quand le deuxième ou le troisième cas arrive. Elle sera alors taillée pour un vrai besoin.
Documentation officielle
- Les classes abstraites en PHP
- Les interfaces en PHP et le mot-clé
final - Les tags de services dans Symfony
- Décorer un service
- Créer un service avec une factory
- Événements et listeners
- Les formulaires Symfony, pour limiter les champs liés à une entité