Développement Web

La programmation orientée objet en PHP, de A à Z

13 دقائق للقراءة

Représenter une pièce détachée par un tableau ['ref' => 'VIS-M6', 'prix' => 250] fonctionne… jusqu’au jour où vous écrivez $piece['prx'] par erreur, où un champ manque, ou où la même donnée se balade sous trois formes différentes. La programmation orientée objet règle ce problème en donnant une forme stable et typée à vos données et à leur comportement. Dans ce tutoriel, on modélise le cœur du projet « Atelier » — pièces, catégories et inventaire — avec des classes, des interfaces et des énumérations PHP modernes.

Article de référence : ce tutoriel s’inscrit dans le guide complet du PHP moderne. Il suppose que vous maîtrisez la syntaxe de PHP 8.4 vue précédemment.

Ce que vous allez apprendre

  • Concevoir une classe métier propre avec propriétés typées et méthodes.
  • Modéliser un ensemble fermé de valeurs avec une énumération (enum) dotée de méthodes.
  • Définir un contrat avec une interface et le respecter dans une implémentation.
  • Choisir entre héritage et composition pour réutiliser du code.
  • Factoriser du comportement transversal avec un trait.

Ce que vous allez construire

Le modèle objet complet de l’inventaire : une énumération Categorie qui connaît son libellé et son taux de TVA, une classe Piece, une interface DepotPieces décrivant les opérations de stockage, et une implémentation en mémoire DepotMemoire. Cette architecture est exactement celle que le tutoriel sur PDO viendra compléter avec une version branchée sur MySQL.

Prérequis

  • PHP 8.4 (les exemples utilisent les énumérations de 8.1 et les property hooks de 8.4).
  • Avoir suivi le tutoriel de syntaxe, ou connaître declare(strict_types=1), la promotion de constructeur et readonly.
  • Un éditeur avec autocomplétion PHP — l’orienté objet prend tout son sens quand l’IDE complète les méthodes.
  • ⏱️ Temps estimé : ~45 minutes.

Étape 1 — La classe, brique de base

Une classe est un moule : elle décrit les données (propriétés) et les comportements (méthodes) d’un type d’objet. Commençons par une version enrichie de Piece, avec une méthode métier qui exprime une règle du domaine : on ne peut pas sortir plus de pièces qu’il n’y en a en stock.

<?php
declare(strict_types=1);

namespace Atelier;

final class Piece
{
    public function __construct(
        public readonly string $reference,
        public string $nom,
        public Categorie $categorie,
        public float $prix,
        public private(set) int $quantite = 0,
    ) {}

    public function entrerStock(int $nombre): void
    {
        if ($nombre <= 0) {
            throw new \InvalidArgumentException("La quantité entrée doit être positive.");
        }
        $this->quantite += $nombre;
    }

    public function sortirStock(int $nombre): void
    {
        if ($nombre > $this->quantite) {
            throw new \RuntimeException("Stock insuffisant pour {$this->reference}.");
        }
        $this->quantite -= $nombre;
    }

    public float $prixTTC {
        get => $this->prix * (1 + $this->categorie->tauxTva());
    }
}

Remarquez comment les méthodes entrerStock() et sortirStock() protègent l’invariant du domaine : la quantité ne peut jamais devenir négative, et on lève une exception explicite si une règle est violée. C’est la grande force de l’objet : les données et les règles qui les gouvernent vivent au même endroit. La propriété $quantite étant en private(set), personne ne peut la contourner de l’extérieur — toute modification passe par ces méthodes contrôlées.

Étape 2 — L’énumération, pour un ensemble fermé de valeurs

La catégorie d’une pièce ne peut prendre qu’un petit nombre de valeurs connues d’avance. Représenter cela par une chaîne libre invite aux fautes de frappe ('electrqiue') et aux valeurs invalides. L’énumération adossée à une valeur (backed enum) résout cela : c’est un type fermé dont les seules instances possibles sont celles que vous déclarez. Et en PHP, une énumération peut porter des méthodes.

<?php
declare(strict_types=1);

namespace Atelier;

enum Categorie: string
{
    case Visserie    = 'visserie';
    case Electrique  = 'electrique';
    case Hydraulique = 'hydraulique';
    case Consommable = 'consommable';

    public function libelle(): string
    {
        return match ($this) {
            Categorie::Visserie    => 'Visserie et fixation',
            Categorie::Electrique  => 'Composants électriques',
            Categorie::Hydraulique => 'Circuit hydraulique',
            Categorie::Consommable => 'Consommables',
        };
    }

    public function tauxTva(): float
    {
        // Taux d'exemple ; les consommables sont à taux réduit ici
        return $this === Categorie::Consommable ? 0.10 : 0.18;
    }
}

Chaque case est une instance unique de Categorie. La méthode libelle() s’appuie sur match, qui est exhaustif : si vous ajoutez un case sans traiter son libellé, l’analyse statique vous le signale. On récupère une catégorie depuis sa valeur stockée avec Categorie::from('visserie') (qui lève une exception si la valeur est inconnue) ou Categorie::tryFrom('xxx') (qui renvoie null). C’est le pont idéal entre la base de données, où la catégorie est une chaîne, et le code, où elle est un type sûr.

Point d’étape — Créez une pièce avec new Piece('VIS-M6', 'Vis M6', Categorie::Visserie, 250.0, 100) et affichez $p->categorie->libelle() puis $p->prixTTC. Vous devez voir « Visserie et fixation » et un prix majoré de 18 %. Si Categorie::from('xxx') ne lève pas d’exception sur une valeur inconnue, c’est que vous appelez tryFrom par erreur.

Étape 3 — L’interface, un contrat sans implémentation

Maintenant, où range-t-on les pièces ? On voudrait pouvoir les stocker en mémoire pour les tests, puis en base de données en production, sans réécrire le code qui les manipule. La solution est l’interface : un contrat qui liste les opérations disponibles, sans dire comment elles sont réalisées. Le code client dépend du contrat, pas de l’implémentation.

<?php
declare(strict_types=1);

namespace Atelier;

interface DepotPieces
{
    public function ajouter(Piece $piece): void;

    public function parReference(string $reference): ?Piece;

    /** @return Piece[] */
    public function toutes(): array;
}

Cette interface décrit trois opérations : ajouter une pièce, en retrouver une par sa référence (ou null si absente), et lister toutes les pièces. Aucune logique n’est écrite ici — seulement les signatures. N’importe quelle classe qui « implémente » DepotPieces s’engage à fournir ces trois méthodes avec exactement ces types. Le commentaire @return Piece[] est une annotation que l’analyse statique comprend, puisque PHP ne type pas le contenu des tableaux.

Étape 4 — Implémenter le contrat en mémoire

Écrivons une première implémentation, qui garde les pièces dans un tableau interne. Elle servira pour les démonstrations et les tests, avant que le tutoriel PDO n’en fournisse une version persistante.

<?php
declare(strict_types=1);

namespace Atelier;

final class DepotMemoire implements DepotPieces
{
    /** @var array<string, Piece> */
    private array $pieces = [];

    public function ajouter(Piece $piece): void
    {
        $this->pieces[$piece->reference] = $piece;
    }

    public function parReference(string $reference): ?Piece
    {
        return $this->pieces[$reference] ?? null;
    }

    public function toutes(): array
    {
        return array_values($this->pieces);
    }
}

Le mot-clé implements DepotPieces engage la classe à respecter le contrat ; si une méthode manque ou a une mauvaise signature, PHP refuse de charger la classe. Les pièces sont indexées par leur référence, ce qui rend parReference() immédiat. L’intérêt apparaît dans le code client : une fonction qui prend un DepotPieces en paramètre fonctionne aussi bien avec DepotMemoire qu’avec un futur DepotPdo, sans changer une ligne. C’est l’injection de dépendances, la base d’un code testable.

<?php
function rapportStock(DepotPieces $depot): void
{
    foreach ($depot->toutes() as $piece) {
        $etat = $piece->enStock ? 'en stock' : 'RUPTURE';
        echo "{$piece->libelle} : {$piece->quantite} ({$etat})\n";
    }
}

$depot = new DepotMemoire();
$depot->ajouter(new Piece('VIS-M6', 'Vis M6', Categorie::Visserie, 250.0, 120));
$depot->ajouter(new Piece('FUS-5A', 'Fusible 5A', Categorie::Electrique, 100.0, 0));
rapportStock($depot);

Point d’étape — Lancez ce script. Vous devez voir deux lignes, la seconde marquée « RUPTURE ». La fonction rapportStock() ne connaît que l’interface : elle marchera à l’identique avec n’importe quel dépôt. C’est le signe que votre découplage est réussi.

Étape 5 — Héritage ou composition ?

Vient un moment où l’on veut réutiliser du comportement. Deux mécanismes s’opposent. L’héritage (class B extends A) crée une relation « est un » : une sous-classe hérite des propriétés et méthodes de sa classe mère. La composition assemble des objets : une classe contient d’autres objets et leur délègue le travail. La règle pratique, largement admise, est de préférer la composition à l’héritage, car l’héritage couple fortement la sous-classe à sa mère et devient vite rigide.

Illustrons avec une classe abstraite, cas légitime d’héritage. Imaginons deux types de pièces au comportement de prix différent : une pièce standard et une pièce sous garantie qui ajoute un supplément. Une classe abstraite définit le squelette commun et laisse les sous-classes compléter ce qui varie.

<?php
declare(strict_types=1);

namespace Atelier;

abstract class ArticleStock
{
    public function __construct(
        public readonly string $reference,
        public float $prix,
    ) {}

    // Méthode abstraite : chaque sous-classe doit la définir
    abstract public function prixFacture(): float;
}

final class PieceStandard extends ArticleStock
{
    public function prixFacture(): float
    {
        return $this->prix;
    }
}

final class PieceGarantie extends ArticleStock
{
    public function __construct(string $reference, float $prix, private float $supplement)
    {
        parent::__construct($reference, $prix);
    }

    public function prixFacture(): float
    {
        return $this->prix + $this->supplement;
    }
}

La classe ArticleStock ne peut pas être instanciée directement (abstract) ; elle impose à ses descendantes de fournir prixFacture(). PieceGarantie appelle parent::__construct() pour réutiliser l’initialisation de la mère, puis ajoute sa propre donnée. L’héritage est ici justifié parce qu’il y a une vraie relation « est un » et un comportement à spécialiser. Pour du code transversal sans relation hiérarchique, on préfère la composition ou les traits.

Étape 6 — Les traits pour le comportement transversal

Un trait est un bloc de méthodes que plusieurs classes peuvent inclure, sans relation d’héritage. C’est utile pour un comportement répété qui ne définit pas une identité — par exemple, horodater la création d’un objet. Là où l’héritage simple de PHP ne permet qu’une seule classe mère, on peut inclure autant de traits qu’on veut.

<?php
declare(strict_types=1);

namespace Atelier;

trait Horodatable
{
    public readonly \DateTimeImmutable $creeLe;

    public function initHorodatage(): void
    {
        $this->creeLe = new \DateTimeImmutable();
    }
}

final class PieceTracee
{
    use Horodatable;

    public function __construct(public readonly string $reference)
    {
        $this->initHorodatage();
    }
}

$p = new PieceTracee('VIS-M6');
echo $p->creeLe->format('Y-m-d H:i:s');

La classe PieceTracee obtient la propriété $creeLe et la méthode initHorodatage() simplement en écrivant use Horodatable;. Le trait n’est pas un type — on ne peut pas typer un paramètre par un trait — c’est purement de la réutilisation de code. À utiliser avec parcimonie : un trait trop gros cache souvent une classe ou un service qui s’ignore. Mais pour un petit comportement bien délimité, c’est l’outil adapté.

Étape 7 — Vérification finale

Assemblez les pièces du puzzle : l’énumération Categorie, la classe Piece, l’interface DepotPieces et son implémentation DepotMemoire. Écrivez un script qui crée un dépôt, y ajoute trois pièces de catégories différentes, en sort quelques unités, puis affiche un rapport avec le prix TTC de chacune. Si tout s’exécute sans erreur de type et que les règles de stock sont respectées (impossible de sortir plus que disponible), votre modèle objet tient debout. Vous avez là une architecture qu’un framework ne ferait pas autrement.

Pièges fréquents

Symptôme / erreur Cause probable Correctif
Class contains abstract method and must be declared abstract Une sous-classe oublie d’implémenter une méthode abstraite Définir toutes les méthodes abstract de la mère
Cannot instantiate interface Tentative de new DepotPieces() Instancier une classe concrète qui implémente l’interface
... must implement interface method Signature de méthode non conforme au contrat Aligner exactement types de paramètres et de retour
"xxx" is not a valid backing value for enum Categorie::from() sur une valeur inconnue Utiliser tryFrom() et gérer le null
Conflit de méthodes entre deux traits Deux traits définissent la même méthode Résoudre avec insteadof et as dans le use

Récapitulatif

Vous avez modélisé le domaine de « Atelier » avec les outils de l’orienté objet moderne. Les classes encapsulent données et règles ; les énumérations remplacent les chaînes magiques par des types fermés et intelligents ; les interfaces découplent le code de ses implémentations, ouvrant la voie aux tests et au remplacement ; l’héritage, réservé aux vraies relations « est un », spécialise un comportement ; et les traits factorisent le transversal. Cette structure n’est pas de la théorie : c’est précisément ce que vous retrouverez, en plus gros, dans Laravel ou Symfony.

Aide-mémoire

Élément Rôle
final class X Classe non héritable
abstract class X Classe non instanciable, à étendre
interface X Contrat de méthodes, sans implémentation
enum X: string Type fermé de valeurs, avec méthodes possibles
X::from() / X::tryFrom() Convertir une valeur en cas d’énumération (exception / null)
class B extends A Héritage (relation « est un »)
use MonTrait; Inclure un trait dans une classe
implements I S’engager à respecter une interface

À vous de jouer

Ajoutez à l’interface DepotPieces une méthode parCategorie(Categorie $c): array qui renvoie les pièces d’une catégorie donnée, puis implémentez-la dans DepotMemoire avec array_filter.

Voir une solution
// Dans l'interface :
/** @return Piece[] */
public function parCategorie(Categorie $c): array;

// Dans DepotMemoire :
public function parCategorie(Categorie $c): array
{
    return array_values(
        array_filter($this->pieces, fn(Piece $p) => $p->categorie === $c)
    );
}

Tutoriels liés

Pour aller plus loin

Foire aux questions

Quand utiliser une classe abstraite plutôt qu’une interface ?

Une interface décrit un contrat pur, sans code ; une classe abstraite peut fournir du code commun en plus d’imposer des méthodes. On choisit l’interface pour découpler, la classe abstraite quand plusieurs sous-classes partagent une vraie implémentation de base. Les deux se combinent : une classe abstraite peut implémenter une interface.

Une énumération peut-elle avoir des propriétés ?

Pas de propriétés d’instance modifiables, car les cas sont des constantes uniques. Mais elle peut avoir des méthodes, implémenter des interfaces et déclarer des constantes. Pour associer des données à un cas, on passe par une méthode qui fait correspondre le cas à sa valeur, comme libelle() ici.

Pourquoi « préférer la composition à l’héritage » ?

L’héritage lie une sous-classe à toute l’implémentation de sa mère : un changement dans la mère peut casser la fille, et une hiérarchie profonde devient difficile à suivre. La composition assemble des objets indépendants et remplaçables, ce qui donne un code plus souple et plus facile à tester. L’héritage reste utile, mais pour les vraies relations « est un », pas pour réutiliser du code par commodité.

مشاركة