Chargement...

Différents types d'objets en PHP

Toute l'actualité de Prestaconcept
Accueil /  Blog /  PHP /  Différents types d'objets en PHP

Différents types d'objets en PHP

Publié le jeudi 1 février 2024

Entité, Value Object, Data Transfer Object, Service.
Réflexion autour de la classification des différents types d'objets, en PHP.

Programmation orientée objet

Comme beaucoup de langages, PHP est un langage orienté objet.
Quand on développe dans un tel langage, on manipule donc des objets à tour de bras.
Déjà, un objet, c'est quoi ?

[...] objects, [...] can contain data and code: data in the form of fields (often known as attributes or properties), and code in the form of procedures (often known as methods).
Object-Oriented Programming

Donc un objet ne serait donc qu'un conteneur de données et de logique.
Dans le sens strict de ce qui le compose, c'est vrai, mais n'est-ce-pas un peu réducteur ?

Différents types d'objets ?

Si vous avez déjà pratiqué un langage orienté objet, vous avez probablement déjà eu le sentiment que certaines choses devaient être faites sur certains objets, mais pas sur d'autres.
Est-ce-que vous envisageriez de mettre une méthode faisant un appel API dans un objet User ? Peut-être pas.
Mais pourquoi ? Qu'est-ce-qui différencie mon objet User des autres objets de mon application ?

Vous le sentez sûrement venir, on range mentalement les objets dans des "familles", et on donne des caractéristiques à ces familles.
C'est donc tout le propos de cet article, passer en revue ces familles, et leurs caractéristiques, pour vous aider à vous y retrouver.

Note : Certains des termes qui seront utilisés ici peuvent rentrer en conflit avec ceux du Domain-Driven Design (DDD).
Chaque fois que ce sera le cas, une note indiquera les différences potentielles.

Entité

Une entité est un objet ayant un identifiant unique au sein d'un système.

final class Person
{
    public function __construct(
        public readonly string $username,
        public string $firstName,
        public string $lastName,
    ) {
    }
}

final class TimeEntry
{
    public function __construct(
        public readonly int $id,
        public readonly Person $person,
        public readonly DateTimeInterface $day,
        public Duration $duration,
    ) {
        assert($id > 0);
        assert($day <= new DateTimeImmutable());
    }
}

Une entité peut avoir de multiples propriétés, dont au moins un identifiant :

  • l'identifiant d'un objet Person est son username
  • l'identifiant d'un objet TimeEntry est son id

Si on veut savoir si 2 instances d'une même entité sont les mêmes, il faut vérifier leur identité :

  • 2 objets Person ayant un username avec la valeur "john.doe"
  • 2 objets TimeEntry ayant un id avec la valeur 1659

Les autres propriétés qui composent ces entités sont ce que l'on appelle son état, et cet état peut changer :

  • un User peut changer de firstName ou lastName
  • une TimeEntry peut changer de duration

Attention : une propriété ne pouvant pas être modifiée ne représente pas nécessairement l'identité de l'entité.

Les propriétés peuvent être d'autres entités, des value objects, ou de simples scalaires.

On retrouve souvent des entités lorsqu'on interagit avec une base de données, notamment au travers d'un ORM.
Chaque ligne d'une table SQL pouvant être considérée comme une entité différente.

Attention : la notion d'entité dans un ORM et en DDD ne sont pas forcément les mêmes.
Du point de vue du DDD, une entité doit être valide à tout moment, et dès sa construction.
Les 2 notions peuvent cohabiter au sein du même projet.

Value Object

Comme son nom l'indique, un value object est un wrapper autour d'une (ou plusieurs) valeur(s).

final readonly class Duration
{
    public function __construct(
        public int $hours,
        public int $minutes,
    ) {
        assert($this->hours >= 0);
        assert($this->minutes >= 0 && $this->minutes < 60);
    }

    public static function fromHoursDecimal(float $decimal): self
    {
        $decimal = round($decimal, 2);
        $hours = (int)floor($decimal);
        $minutes = (int)(($decimal - $hours) * 60);

        return new self($hours, $minutes);
    }

    public function toHoursDecimal(): float
    {
        return round($this->hours + $this->minutes / 60, 2);
    }

    public function compare(self $duration): Comparison
    {
        return Comparison::fromMembersComparison($this->toHoursDecimal(), $duration->toHoursDecimal());
    }
}

enum Comparison: int
{
    case LowerThan = -1;
    case Equal = 0;
    case GreaterThan = 1;

    public static function fromMembersComparison(int|float|string $left, int|float|string $right): self
    {
        return self::from($left <=> $right);
    }
}

Un value object n'a pas d'identité ni d'état, et est habituellement immutable :

  • on ne peut modifier aucune des propriétés de Duration puisque la classe est readonly
  • on ne peut modifier la valeur de Comparison puisque c'est un Enum

Un value object peut avoir plusieurs propriétés (même s'il est conseillé de viser un minimum).
Ces propriétés peuvent être d'autres value objects, ou de simples scalaires.

S'il est habituellement immutable, c'est qu'un value object est intrinsèquement lié à sa(ses) valeur(s).
Si on change sa(ses) valeurs, ce n'est plus le même value object.

Si on veut savoir si 2 instances d'un même value object sont les mêmes, il faut vérifier leur(s) valeur(s) :

  • 2 objets Duration ayant un hours avec la valeur 1 et un minutes avec la valeur 45
  • 2 objets Comparison ayant un value avec la valeur 0 (mais pour les enum, vous pouvez comparer strictement 2 objets)

Un value object sert donc à ajouter de la logique métier autour de certaines valeurs particulières.
Un enum n'étant qu'une forme très particulière de value object : où la liste de valeurs possibles est connue à l'avance.
Un objet DateTimeImmutable est également un value object (en revanche, son pendant DateTime ne satisfait pas à la règle d'immutabilité).

Attention : Du point de vue du DDD, un value object doit être valide à sa construction.

Data Transfer Object (DTO)

Un DTO est un conteneur de données qui doivent être rassemblées pour être transférées en une fois.

final class AddTimeEntry
{
    public function __construct(
        public string $username,
        public DateTimeInterface $day,
        public float $duration,
    ) {
    }
}

final class UpdateTimeEntry
{
    public function __construct(
        public int $id,
        public float $duration,
    ) {
    }
}

Un DTO n'a pas d'identité, et peut être mutable :

  • toutes les propriétés de la class AddTimeEntry sont modifiables après sa construction
  • toutes les propriétés de la class UpdateTimeEntry sont modifiables après sa construction

Les propriétés qui composent un DTO peuvent être d'autres DTO, des value objects, ou de simples scalaires.

Contrairement à une entité ou un value object, un DTO n'a aucune garantie de validité.
Même s'il arrive fréquemment que l'on souhaite les valider avant de les considérer.

On retrouve souvent des DTO lorsque l'on souhaite rassembler une grande liste de paramètres dans un seul objet.
Une commande, un message, ou un événement peuvent également être considérés comme des DTOs.

Attention : en DDD, un événement, bien qu'il puisse être considéré comme un DTO, est un objet précieux qui doit également être valide en toute circonstance.

Service

Comme son nom l'indique, un service effectue une action ou calcule une information.

final readonly class TimeTracker
{
    private function __construct(
        private PersonRepository $personRepository,
        private TimeEntryRepository $timeEntryRepository,
    ) {
    }

    public function add(AddTimeEntry $command): void
    {
        $this->timeEntryRepository->add(
            $this->personRepository->get($command->username),
            $command->day,
            Duration::fromHoursDecimal($command->duration),
        );
    }

    public function update(UpdateTimeEntry $command): void
    {
        $entry = $this->timeEntryRepository->get($command->id);

        $duration = Duration::fromHoursDecimal($command->duration);
        if ($duration->compare($entry->duration) === Comparison::Equal) {
            return;
        }

        $entry->duration = $duration;
        $this->timeEntryRepository->update($entry);
    }
}

Un service a souvent des propriétés, des dépendances, qui l'aident à faire son travail.
Il est recommandé que ces propriétés soient immutables (pour éviter les effets de bords), même s'il peut arriver que cela soit nécessaire.
Ces propriétés peuvent être d'autres services, des value objects, ou de simples scalaires.

Un service est toujours valide, mais sa validité réside dans votre capacité à l'instancier.

Attention : il faut dissocier la notion de service au sens où on l'entend ici à celle d'un service dans Symfony (par exemple).
Dans Symfony, un service est une instance d'une classe, disponible dans le conteneur de service.
Il peut donc exister plusieurs instances d'une même classe au sein du même conteneur, avec un jeu de dépendances différentes (par exemple).

Typer nos objets

Ce qui serait vraiment génial, ça serait de pouvoir faire valider ces règles sur nos familles d'objets.
Par exemple, avoir des règles de code style différentes, selon que la famille à laquelle la classe appartient.
Pour commencer, il faudrait déjà que l'on soit capable d'associer une famille à chacune de nos classes.
L'utilisation d'un attribut pourrait satisfaire ce besoin :

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ObjectType
{
    public const ENTITY = 'entity';
    public const VALUE_OBJECT = 'vo';
    public const DATA_TRANSFER_OBJECT = 'dto';
    public const SERVICE = 'service';

    public function __construct(
        public string $type,
    ) {
        assert(in_array($type, [
            self::ENTITY,
            self::VALUE_OBJECT,
            self::DATA_TRANSFER_OBJECT,
            self::SERVICE,
        ]));
    }
}

#[ObjectType(ObjectType::VALUE_OBJECT)]
enum Comparison: int
{
}

#[ObjectType(ObjectType::VALUE_OBJECT)]
final readonly class Duration
{
}

#[ObjectType(ObjectType::ENTITY)]
final class Person
{
}

#[ObjectType(ObjectType::ENTITY)]
final class TimeEntry
{
}

#[ObjectType(ObjectType::DATA_TRANSFER_OBJECT)]
final class AddTimeEntry
{
}

#[ObjectType(ObjectType::DATA_TRANSFER_OBJECT)]
final class UpdateTimeEntry
{
}

#[ObjectType(ObjectType::SERVICE)]
final readonly class TimeTracker
{
}

Reste à collecter l'ensemble des classes avec cet attribut :

use olvlvl\ComposerAttributeCollector\Attributes;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ObjectType
{
    //...

    /**
     * @return \Generator<string>
     */
    public static function files(string $type): \Generator
    {
        foreach (Attributes::findTargetClasses(self::class) as $target) {
            /** @var ObjectType $attribute */
            $attribute = $target->attribute;
            if ($attribute->type === $type) {
                yield (new \ReflectionClass($target->name))->getFileName();
            }
        }
    }
}

Note : Pour se simplifier la vie dans la collecte des classes ayant un attribut, on a utilisé olvlvl/composer-attribute-collector.

Comme notre binaire output les chemins de chaque classe ayant la famille de classe qui nous intéresse, on peut récupérer son output et le passer à notre outil de checkstyle préféré.
Chez PrestaConcept, on utilise symplify/easy-coding-standard.

Un fichier de configuration du checkstyle par famille plus tard :

// ecs.dto.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;

require_once __DIR__ . '/vendor/attributes.php';

return ECSConfig::configure()
    ->withPaths(\iterator_to_array(ObjectType::files(ObjectType::DATA_TRANSFER_OBJECT)))
    ->withRules([
        // todo every Data Transfert Object must not be readonly
        // todo every Data Transfert Object must be public
        // ...
    ]);
// ecs.entity.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;

require_once __DIR__ . '/vendor/attributes.php';

return ECSConfig::configure()
    ->withPaths(\iterator_to_array(ObjectType::files(ObjectType::ENTITY)))
    ->withRules([
        // todo every Entity must have at least one readonly property
        // ...
    ]);
// ecs.service.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;

require_once __DIR__ . '/vendor/attributes.php';

return ECSConfig::configure()
    ->withPaths(\iterator_to_array(ObjectType::files(ObjectType::SERVICE)))
    ->withRules([
        // todo every Service must final
        // todo every Service properties must be private
        // ...
    ]);
// ecs.vo.php
use App\ObjectType;
use Symplify\EasyCodingStandard\Config\ECSConfig;

require_once __DIR__ . '/vendor/attributes.php';

return ECSConfig::configure()
    ->withPaths(\iterator_to_array(ObjectType::files(ObjectType::VALUE_OBJECT)))
    ->withRules([
        // todo every Value Object must be readonly
        // todo every Value Object properties must be public
        // ...
    ]);

Et nous voilà prêts à automatiser la vérification :

vendor/bin/ecs check --config=ecs.dto.php
vendor/bin/ecs check --config=ecs.entity.php
vendor/bin/ecs check --config=ecs.service.php
vendor/bin/ecs check --config=ecs.vo.php

Bien évidemment, les règles que vous allez écrire dans chacun de ces fichiers vous regardent.
Libre à vous d'implémenter ce que vous voulez sur chaque famille, selon vos convictions.

Lire autour du sujet

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.