Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 1133 minIntermédiaire

Classes et objets en PHP : syntaxe, enum, readonly

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

À la fin de ce chapitre, tu sauras

  • écrire une classe avec un constructeur à propriétés promues ;
  • utiliser readonly et les énumérations ;
  • écrire l'héritage, une interface et une classe abstraite en PHP ;
  • organiser les classes en espaces de noms chargés par Composer.

3 exercices corrigés

En PHP, une classe se déclare avec class, ses propriétés sont typées, et son constructeur peut déclarer et remplir les propriétés en une seule ligne grâce à la promotion des propriétés. Depuis PHP 8.1, les énumérations (enum) et les propriétés readonly permettent d'écrire des objets sûrs et lisibles. Ce chapitre montre la syntaxe PHP de la programmation orientée objet, appliquée à Prêtmat : classes, constructeur, readonly, enum, héritage et interfaces, espaces de noms, chargement automatique avec Composer, et une classe d'accès aux données.

Les concepts eux-mêmes (encapsulation, héritage, polymorphisme, interfaces, diagramme de classes) sont expliqués dans le cours Programmation orientée objet, avec C#. Si tu ne les connais pas encore, lis-le d'abord : ici, on suppose que tu sais ce qu'est une classe et un objet, et l'on montre comment l'écrire en PHP.

Déclarer une classe

php
<?php

declare(strict_types=1);

final class Emprunt
{
    private ?DateTimeImmutable $dateRetour = null;

    public function __construct(
        private int $idMateriel,
        private DateTimeImmutable $debut,
        private DateTimeImmutable $retourPrevu,
    ) {
        if ($retourPrevu < $debut) {
            throw new InvalidArgumentException('Le retour prévu précède le début.');
        }
    }

    public function rendre(DateTimeImmutable $date): void
    {
        $this->dateRetour = $date;
    }

    public function estEnCours(): bool
    {
        return $this->dateRetour === null;
    }
}

$emprunt = new Emprunt(2, new DateTimeImmutable('2026-10-26'), new DateTimeImmutable('2026-11-09'));
var_dump($emprunt->estEnCours());   // bool(true)
$emprunt->rendre(new DateTimeImmutable('2026-11-05'));
var_dump($emprunt->estEnCours());   // bool(false)
Syntaxe PHPRôleÉquivalent C#
new Emprunt(…)Créer un objetnew Emprunt(…)
$emprunt->rendre(…)Appeler une méthode, lire une propriétéemprunt.Rendre(…)
$this->dateRetourLa propriété de l'objet courant, dans une méthodethis.dateRetour (souvent implicite en C#)
public, protected, privateVisibilité : partout, classe et classes filles, classe seuleLes mêmes mots-clés
final classLa classe ne peut pas avoir de classe fillesealed class
__construct()Le constructeurUne méthode qui porte le nom de la classe

En PHP, $this est obligatoire pour accéder à une propriété dans une méthode : $dateRetour seul désignerait une variable locale, qui n'existe pas. Les propriétés sont typées comme les paramètres (private ?DateTimeImmutable $dateRetour). Une propriété typée qui n'a pas de valeur par défaut et n'est pas remplie par le constructeur est non initialisée : la lire lève une erreur.

Le constructeur et la promotion des propriétés

Les trois paramètres du constructeur ci-dessus sont précédés d'une visibilité (private int $idMateriel) : c'est la promotion des propriétés du constructeur (PHP 8.0). PHP déclare la propriété et lui affecte l'argument, ce qui remplace cette forme longue :

php
<?php

final class EmpruntFormeLongue
{
    private int $idMateriel;
    private DateTimeImmutable $debut;

    public function __construct(int $idMateriel, DateTimeImmutable $debut)
    {
        $this->idMateriel = $idMateriel;
        $this->debut = $debut;
    }
}

Le corps du constructeur reste disponible pour vérifier les invariants de l'objet : ici, refuser une date de retour antérieure au début. Un objet Emprunt invalide ne peut pas exister.

readonly : des propriétés qui ne changent plus

Une propriété readonly (PHP 8.1) ne peut être affectée qu'une fois, depuis l'intérieur de la classe, en général dans le constructeur. Une classe readonly (PHP 8.2) rend toutes ses propriétés readonly. C'est idéal pour les objets qui représentent une valeur ou une ligne lue en base.

php
<?php

declare(strict_types=1);

final readonly class Materiel
{
    public function __construct(
        public int $id,
        public string $reference,
        public string $libelle,
        public Categorie $categorie,
        public EtatMateriel $etat,
        public ?DateTimeImmutable $dateAchat = null,
        public ?string $valeur = null,          // DECIMAL : une chaîne, pour ne pas perdre de précision
    ) {
    }

    public function peutEtrePrete(): bool
    {
        return $this->etat === EtatMateriel::Disponible;
    }

    /** Une copie avec un autre état : l'objet d'origine ne change pas (PHP 8.5). */
    public function avecEtat(EtatMateriel $etat): self
    {
        return clone($this, ['etat' => $etat]);
    }
}

Les propriétés sont publiques, mais on ne peut que les lire : $materiel->libelle = 'x'; lève l'erreur « Cannot modify readonly property Materiel::$libelle ». Pas besoin d'écrire des accesseurs (getLibelle()) pour protéger les données.

Pour obtenir une version modifiée d'un objet readonly, on crée une copie. PHP 8.5 permet de copier en changeant certaines propriétés avec clone($objet, ['propriete' => valeur]), appelé depuis la classe elle-même : de l'extérieur, une propriété readonly reste non modifiable, même pendant un clonage. En PHP 8.4, la méthode avecEtat() s'écrit avec new self(…) en recopiant chaque propriété.

Les énumérations

Une énumération (enum, PHP 8.1) est un type qui n'a qu'un nombre fini de valeurs possibles. C'est exactement ce que sont la catégorie et l'état d'un matériel. Une énumération adossée (backed enum) associe à chaque cas une valeur string ou int, celle qu'on stocke en base.

php
<?php

declare(strict_types=1);

enum Categorie: string
{
    case Portable = 'portable';
    case Videoprojecteur = 'videoprojecteur';
    case Tablette = 'tablette';
    case Camera = 'camera';
    case Autre = 'autre';

    public function libelle(): string
    {
        return match ($this) {
            self::Portable => 'Ordinateur portable',
            self::Videoprojecteur => 'Vidéoprojecteur',
            self::Tablette => 'Tablette',
            self::Camera => 'Caméra',
            self::Autre => 'Autre',
        };
    }

    public function dureePretMax(): int
    {
        return match ($this) {
            self::Portable, self::Tablette => 14,
            self::Videoprojecteur, self::Camera => 3,
            self::Autre => 7,
        };
    }
}

enum EtatMateriel: string
{
    case Disponible = 'disponible';
    case EnReparation = 'en_reparation';
    case Reforme = 'reforme';
}
SyntaxeRésultat
Categorie::CameraLe cas lui-même, un objet unique
Categorie::Camera->value'camera' : la valeur à stocker en base
Categorie::Camera->name'Camera' : le nom du cas
Categorie::from('camera')Categorie::Camera ; une ValueError si la valeur est inconnue
Categorie::tryFrom('drone')null si la valeur est inconnue : pratique pour valider une saisie
Categorie::cases()La liste de tous les cas, pour remplir un <select>
$c === Categorie::CameraComparaison : chaque cas est unique, === suffit

Les énumérations remplacent avantageusement les constantes et les chaînes éparpillées dans le code. La validation de la catégorie devient $categorie = Categorie::tryFrom($saisie); suivi d'un test if ($categorie === null), la liste déroulante se remplit avec Categorie::cases() et libelle(), et un match sur une énumération sans bras default signale à l'exécution un cas oublié. Si l'on ajoute une catégorie, il suffit de modifier l'énumération (et l'ENUM de la table).

Visibilité asymétrique et property hooks

PHP 8.4 apporte deux nouveautés qui évitent d'écrire des accesseurs à la main.

La visibilité asymétrique donne une visibilité différente à la lecture et à l'écriture : public private(set) se lit partout, mais ne se modifie que depuis la classe.

php
<?php

final class CompteurRetards
{
    public private(set) int $total = 0;

    public function ajouter(): void
    {
        $this->total++;
    }
}

$c = new CompteurRetards();
$c->ajouter();
echo $c->total;   // 1
// $c->total = 5; lèverait : Cannot modify private(set) property CompteurRetards::$total from global scope

Les property hooks attachent du code à la lecture (get) ou à l'écriture (set) d'une propriété :

php
<?php

declare(strict_types=1);

final class Utilisateur
{
    public string $email {
        set(string $valeur) {
            $valeur = mb_strtolower(trim($valeur));
            if (filter_var($valeur, FILTER_VALIDATE_EMAIL) === false) {
                throw new InvalidArgumentException("Adresse invalide : $valeur");
            }
            $this->email = $valeur;
        }
    }

    public string $initiales {
        get => implode('', array_map(fn(string $mot): string => mb_substr($mot, 0, 1), explode(' ', $this->nom)));
    }

    public function __construct(public string $nom, string $email)
    {
        $this->email = $email;   // passe par le hook set
    }
}

$u = new Utilisateur('Julie Lefèvre', '  Julie.Lefevre@Sorbier.example ');
echo $u->email;       // julie.lefevre@sorbier.example
echo $u->initiales;   // JL : une propriété calculée, sans stockage

Ces deux mécanismes sont récents : tu les liras de plus en plus, mais readonly et des méthodes ordinaires couvrent l'essentiel des besoins d'une réalisation de BTS.

Constantes et membres statiques

Un membre statique appartient à la classe, pas à un objet : on l'appelle avec ::. L'usage le plus utile est la méthode de fabrique, qui crée un objet à partir d'une ligne de la base :

php
<?php

final readonly class MaterielLu
{
    public const int LONGUEUR_REFERENCE_MAX = 20;   // constante typée (PHP 8.3)

    public function __construct(
        public int $id,
        public string $reference,
        public Categorie $categorie,
        public ?DateTimeImmutable $dateAchat,
    ) {
    }

    public static function depuisLigne(array $ligne): self
    {
        return new self(
            id: (int) $ligne['id'],
            reference: $ligne['reference'],
            categorie: Categorie::from($ligne['categorie']),
            dateAchat: $ligne['date_achat'] === null ? null : new DateTimeImmutable($ligne['date_achat']),
        );
    }
}

echo MaterielLu::LONGUEUR_REFERENCE_MAX;   // 20

Dans la classe, self:: désigne la classe elle-même (self::LONGUEUR_REFERENCE_MAX, new self(…)). La méthode de fabrique convertit les types à un seul endroit : la chaîne 'portable' devient Categorie::Portable, la chaîne de date un DateTimeImmutable. Le reste du code manipule des objets bien typés.

Héritage, classes abstraites et interfaces : la syntaxe

php
<?php

declare(strict_types=1);

interface Notifiable
{
    public function notifier(string $message): void;
}

abstract class Personne
{
    public function __construct(protected string $nom)
    {
    }

    abstract public function role(): string;

    public function presentation(): string
    {
        return "{$this->nom} ({$this->role()})";
    }
}

final class Gestionnaire extends Personne implements Notifiable
{
    #[\Override]
    public function role(): string
    {
        return 'gestionnaire';
    }

    public function notifier(string $message): void
    {
        // envoi d'un mail…
        echo "Mail à {$this->nom} : $message\n";
    }
}

$chloe = new Gestionnaire('Chloé Lambert');
echo $chloe->presentation();          // Chloé Lambert (gestionnaire)
var_dump($chloe instanceof Notifiable);   // bool(true)
Syntaxe PHPSens
class B extends AB hérite de A (un seul parent)
class C implements I, JC réalise les interfaces I et J (plusieurs possibles)
abstract class, abstract public functionClasse non instanciable, méthode sans corps à écrire dans les classes filles
parent::__construct(…), parent::methode()Appeler la version de la classe parente
#[\Override] (PHP 8.3)Attribut qui vérifie que la méthode redéfinit bien une méthode parente : une faute de frappe devient une erreur
$objet instanceof ClasseTeste le type d'un objet

Les attributs (#[…]) sont des métadonnées attachées au code ; PHP en fournit quelques-uns (#[\Override], #[\Deprecated] en PHP 8.4, #[\NoDiscard] en PHP 8.5), et des bibliothèques en définissent d'autres.

Les espaces de noms

Un espace de noms (namespace) range les classes comme des dossiers rangent des fichiers, et évite les conflits de noms entre ton code et les bibliothèques. Par convention, l'espace de noms suit l'arborescence des dossiers :

php
<?php

declare(strict_types=1);

// src/Modele/Materiel.php
namespace App\Modele;

use DateTimeImmutable;

final readonly class Materiel
{
    public function __construct(
        public int $id,
        public string $reference,
        public ?DateTimeImmutable $dateAchat = null,
    ) {
    }
}
php
<?php

declare(strict_types=1);

// src/Depot/MaterielDepot.php
namespace App\Depot;

use App\Modele\Materiel;   // pour écrire Materiel au lieu de \App\Modele\Materiel
use PDO;

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

Dans un fichier qui déclare un espace de noms, les classes de PHP (PDO, DateTimeImmutable, InvalidArgumentException) se trouvent dans l'espace global : on les importe avec use PDO; ou on les écrit \PDO. Oublier ce détail donne l'erreur « Class "App\Depot\PDO" not found ».

Le chargement automatique avec Composer et PSR-4

Avec une classe par fichier, il faudrait un require par classe. Le chargement automatique (autoload) charge le fichier d'une classe la première fois qu'on l'utilise. La norme PSR-4 fixe la règle : l'espace de noms App\ correspond au dossier src/, donc la classe App\Depot\MaterielDepot est dans src/Depot/MaterielDepot.php.

Composer, le gestionnaire de dépendances de PHP, génère ce chargeur. Il n'est pas nécessaire d'installer la moindre bibliothèque pour s'en servir : un fichier composer.json à la racine du projet suffit.

json
{
    "name": "sorbier/pretmat",
    "description": "Prêt de matériel du service informatique",
    "type": "project",
    "require": {
        "php": ">=8.4",
        "ext-pdo": "*"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}
bash
composer dump-autoload    # génère vendor/autoload.php
php
<?php
// public/index.php : une seule inclusion pour toutes les classes
require __DIR__ . '/../vendor/autoload.php';

$depot = new App\Depot\MaterielDepot(bdd());

Relance composer dump-autoload après avoir ajouté un espace de noms dans composer.json, pas après chaque nouvelle classe : la règle PSR-4 trouve les fichiers toute seule. Le dossier vendor/ se régénère : ajoute-le au .gitignore (cours Git). Si tu ne peux pas installer Composer, la fonction spl_autoload_register() permet d'écrire le même chargeur en une dizaine de lignes.

Une classe d'accès aux données

Les fonctions de src/materiels.php (chapitre 10) deviennent une classe, souvent appelée dépôt (repository) ou DAO (Data Access Object). Elle reçoit la connexion PDO dans son constructeur (c'est l'injection de dépendance) et renvoie des objets Materiel plutôt que des tableaux. On suppose ici que la classe Materiel en lecture seule de la section readonly a reçu la méthode de fabrique depuisLigne() présentée plus haut, et que les classes sont rangées dans les espaces de noms App\Modele et App\Depot :

php
<?php

declare(strict_types=1);

namespace App\Depot;

use App\Modele\Categorie;
use App\Modele\EtatMateriel;
use App\Modele\Materiel;
use PDO;

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

    public function trouver(int $id): ?Materiel
    {
        $stmt = $this->pdo->prepare('SELECT * FROM materiel WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $ligne = $stmt->fetch();

        return $ligne === false ? null : Materiel::depuisLigne($ligne);
    }

    /** @return list<Materiel> */
    public function parCategorie(Categorie $categorie): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM materiel WHERE categorie = :categorie ORDER BY reference');
        $stmt->execute(['categorie' => $categorie->value]);

        return array_map(Materiel::depuisLigne(...), $stmt->fetchAll());
    }

    public function changerEtat(int $id, EtatMateriel $etat): void
    {
        $stmt = $this->pdo->prepare('UPDATE materiel SET etat = :etat WHERE id = :id');
        $stmt->execute(['etat' => $etat->value, 'id' => $id]);
    }
}
php
<?php

$depot = new App\Depot\MaterielDepot(bdd());
$tablette = $depot->trouver(4);
echo $tablette->reference . ' : ' . $tablette->categorie->libelle();   // TAB-0003 : Tablette
var_dump($tablette->peutEtrePrete());                                    // bool(false), elle est en réparation

Recevoir PDO en paramètre plutôt que d'appeler bdd() à l'intérieur permet de tester la classe avec une autre base (une base SQLite en mémoire, par exemple) sans rien changer à son code. C'est la classe « modèle » de l'architecture MVC du chapitre 12.

Exercices

Exercice 1 sur 3 Une énumération pour le rôle Réussi À revoir

Écris l'énumération Role adossée à des chaînes (employe, gestionnaire), avec une méthode libelle() (« Employé », « Gestionnaire ») et une méthode peutGererLeMateriel(): bool. Puis réécris la validation du rôle de la fonction validerCompte() (chapitre 9) avec Role::tryFrom().

Voir la réponseMasquer la réponse de l'exercice 1
php
<?php
declare(strict_types=1);

enum Role: string
{
    case Employe = 'employe';
    case Gestionnaire = 'gestionnaire';

    public function libelle(): string
    {
        return match ($this) {
            self::Employe => 'Employé',
            self::Gestionnaire => 'Gestionnaire',
        };
    }

    public function peutGererLeMateriel(): bool
    {
        return $this === self::Gestionnaire;
    }
}

// Dans validerCompte()
$role = Role::tryFrom(texte($saisie, 'role'));
if ($role === null) {
    $erreurs['role'] = 'Rôle invalide.';
}
// Pour l'INSERT : $role->value

tryFrom() remplace la constante ROLES et le in_array() : la liste des rôles autorisés n'existe plus qu'à un endroit. Dans exigerRole(), on pourrait de même comparer des Role plutôt que des chaînes.

Tu avais trouvé ?

Exercice 2 sur 3 Un emprunt en lecture seule Réussi À revoir

Écris la classe readonly EmpruntLu avec les propriétés id (int), retourPrevu (DateTimeImmutable) et retour (?DateTimeImmutable, null par défaut), et deux méthodes qui reçoivent la date du jour : estEnRetard() et joursDeRetard() (0 s'il n'y a pas de retard). Vérifie-la au 2 novembre 2026 avec les trois emprunts du jeu de données.

Voir la réponseMasquer la réponse de l'exercice 2
php
<?php
declare(strict_types=1);

final readonly class EmpruntLu
{
    public function __construct(
        public int $id,
        public DateTimeImmutable $retourPrevu,
        public ?DateTimeImmutable $retour = null,
    ) {
    }

    public function estEnRetard(DateTimeImmutable $aujourdhui): bool
    {
        return $this->retour === null && $this->retourPrevu < $aujourdhui;
    }

    public function joursDeRetard(DateTimeImmutable $aujourdhui): int
    {
        return $this->estEnRetard($aujourdhui) ? $this->retourPrevu->diff($aujourdhui)->days : 0;
    }
}

$aujourdhui = new DateTimeImmutable('2026-11-02');
$emprunts = [
    new EmpruntLu(1, new DateTimeImmutable('2026-11-09')),
    new EmpruntLu(2, new DateTimeImmutable('2026-10-22'), new DateTimeImmutable('2026-10-22')),
    new EmpruntLu(3, new DateTimeImmutable('2026-10-30')),
];
foreach ($emprunts as $e) {
    echo $e->id . ' : ' . $e->joursDeRetard($aujourdhui) . "\n";
}
// 1 : 0   (à rendre le 9 novembre)
// 2 : 0   (rendu)
// 3 : 3   (attendu le 30 octobre)

La date du jour est un paramètre, pas un new DateTimeImmutable('today') caché dans la méthode : la classe se teste avec n'importe quelle date (PHPUnit).

Tu avais trouvé ?

Exercice 3 sur 3 Trouver les erreurs Réussi À revoir

Ce code contient trois problèmes : un bogue silencieux et deux erreurs. Lesquels, et comment les corriger ?

php
<?php
namespace App\Modele;

final readonly class Materiel
{
    public function __construct(public string $reference, public string $etat) {}

    public function reformer(): void
    {
        $etat = 'reforme';
    }

    public function age(): int
    {
        return (new DateTime('2026-11-02'))->format('Y') - 2021;
    }
}

$m = new Materiel('PC-0087', 'disponible');
$m->reformer();
$m->etat = 'reforme';
Voir la réponseMasquer la réponse de l'exercice 3
  1. reformer() ne fait rien : $etat = 'reforme' affecte une variable locale, sans $this->. Ce n'est pas une erreur de PHP, c'est un bogue silencieux. Et même avec $this->etat, la propriété readonly ne peut pas être modifiée après le constructeur. Pour un objet en lecture seule, reformer() doit renvoyer un nouvel objet : return new self($this->reference, 'reforme'); (ou clone($this, ['etat' => 'reforme']) en PHP 8.5).
  2. $m->etat = 'reforme'; lève « Cannot modify readonly property App\Modele\Materiel::$etat » : on ne modifie pas une propriété readonly de l'extérieur.
  3. age(), si on l'appelait : new DateTime(…) dans l'espace de noms App\Modele cherche la classe App\Modele\DateTime, d'où « Class "App\Modele\DateTime" not found ». Il faut use DateTime; en haut du fichier, ou écrire \DateTime. Au passage, format('Y') renvoie une chaîne : (int) …->format('Y') rend le calcul explicite.

Et l'état serait mieux typé avec l'énumération EtatMateriel qu'avec une chaîne : 'reformé' mal orthographié passerait sans erreur.

Tu avais trouvé ?

Chapitre terminé !

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

chapitres
–
de série
–
du cours
–
Chapitre suivant : MVC en PHP sans framework : front controller et routeurRevoir le sommaire du cours