← Retour aux articles
Architecture PHP et patrons de conception

Les Value Objects en PHP : arrêtez d'écrire du code obsédé par les primitives

Découvrez comment les Value Objects éliminent l'obsession des primitives en PHP, avec des exemples réels de classes Email et Money apportant sécurité des types, validation et clarté à votre code métier.

Les Value Objects en PHP : arrêtez d'écrire du code obsédé par les primitives
Article en vedette ↗

Les Value Objects en PHP : arrêtez d'écrire du code obsédé par les primitives

Regardez presque n'importe quelle base de code PHP et vous trouverez le même schéma : les e-mails stockés sous forme de chaînes, l'argent stocké sous forme de floats, les ID stockés sous forme d'entiers, les numéros de téléphone stockés sous forme de chaînes. C'est ce qu'on appelle la primitive obsession (obsession des primitives) — l'utilisation excessive de types primitifs pour représenter des concepts du domaine. C'est l'un des anti-patterns les plus courants et les plus dommageables du développement PHP moderne.

Les Value Objects sont le remède. Ils apportent sécurité des types, validation et clarté à votre code — et une fois que vous commencerez à les utiliser, vous vous demanderez comment vous avez pu vivre sans eux.

Qu'est-ce qu'un Value Object ?

Un Value Object (VO, objet-valeur) est un petit objet immuable qui représente un concept de votre domaine. Contrairement à une Entity, un Value Object n'a pas d'identité — il est entièrement défini par ses valeurs. Deux Value Objects avec les mêmes valeurs sont considérés comme égaux.

Les exemples classiques incluent :

  • Email — au lieu d'une chaîne brute
  • Money — au lieu d'un float plus une chaîne de devise
  • UserId — au lieu d'un entier
  • PhoneNumber — au lieu d'une chaîne
  • DateRange — au lieu de deux objets DateTime
  • Address — au lieu d'un tableau de chaînes
  • Password — au lieu d'une simple chaîne

Le problème avec les primitives

Considérez ce code typique :

<?php function registerUser(string $email, float $balance): void { // $email est-il valide ? Qui sait. // $balance est-il en USD, EUR ou BTC ? Aucune idée. // $balance peut-il être négatif ? Peut-être. }

Chaque valeur primitive porte des hypothèses cachées. Le système de types ne vous dit rien sur :

  • Si la valeur est valide
  • Dans quel format elle se trouve
  • Quelles opérations sont autorisées
  • Quelles unités ou devises elle représente
  • Si elle peut être modifiée ou réutilisée en toute sécurité

Le résultat ? Une logique de validation éparpillée partout, des bugs dus à des formats incompatibles et un code difficile à raisonner.

Votre premier Value Object : Email

Remplaçons une chaîne primitive par un véritable Value Object :

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }

Maintenant, le système de types vous protège. Si vous avez un objet Email, vous savez qu'il est valide. Plus de vérifications de validation éparpillées.

Utilisation du Value Object

<?php final class User { public function __construct( public readonly UserId $id, public readonly Email $email, public readonly string $name, ) { } } // Maintenant, le constructeur impose la validité : $user = new User( id: UserId::fromInt(42), email: Email::fromString('John@Example.com'), name: 'John Doe', ); echo $user->email->toString(); // "john@example.com" (normalisé)

Un exemple plus complexe : Money

L'argent est le cas classique où les primitives échouent catastrophiquement. Les floats perdent de la précision, et mélanger les devises est un bug silencieux qui n'attend que de se produire.

<?php declare(strict_types=1); final class Money { private function __construct( private readonly int $amountInCents, private readonly string $currency, ) { if ($amountInCents < 0) { throw new InvalidArgumentException('Amount cannot be negative.'); } if (!preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidArgumentException('Currency must be a 3-letter ISO code.'); } } public static function of(int $amountInCents, string $currency): self { return new self($amountInCents, $currency); } public static function fromFloat(float $amount, string $currency): self { return new self((int) round($amount * 100), $currency); } public function add(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents + $other->amountInCents, $this->currency, ); } public function subtract(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents - $other->amountInCents, $this->currency, ); } public function isGreaterThan(self $other): bool { $this->assertSameCurrency($other); return $this->amountInCents > $other->amountInCents; } public function amount(): int { return $this->amountInCents; } public function currency(): string { return $this->currency; } public function format(): string { return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency; } public function equals(self $other): bool { return $this->amountInCents === $other->amountInCents && $this->currency === $other->currency; } private function assertSameCurrency(self $other): void { if ($this->currency !== $other->currency) { throw new InvalidArgumentException( sprintf('Cannot operate on %s and %s.', $this->currency, $other->currency) ); } } }

Maintenant, regardez à quel point le code appelant devient propre :

<?php $price = Money::fromFloat(19.99, 'USD'); $tax = Money::fromFloat(1.60, 'USD'); $total = $price->add($tax); echo $total->format(); // "21.59 USD" // Ceci lève une exception au lieu de corrompre silencieusement les données : $price->add(Money::of(500, 'EUR'));

Caractéristiques clés d'un bon Value Object

  • Immuable — une fois créé, il ne change jamais. Les opérations retournent de nouvelles instances.
  • Auto-validant — une instance ne peut pas exister dans un état invalide.
  • Sans identité — l'égalité est basée sur les valeurs, pas sur un ID de base de données.
  • Sans effet de bord — aucun appel à la base de données, aucune journalisation, aucun état global.
  • Petit — représente un concept, pas un agrégat entier.
  • Remplaçable — l'objet entier peut être échangé, plutôt que muté champ par champ.

Value Object vs. DTO vs. Entity

Il est important de distinguer ces trois patterns :

  • Value Object — immuable, défini par ses valeurs, contient un comportement lié à ces valeurs (comme add() sur Money).
  • DTO — immuable ou mutable, transporte des données entre les couches, ne contient aucune logique métier.
  • Entity — possède une identité unique qui persiste dans le temps, même lorsque ses attributs changent.

Là où les Value Objects brillent

  • Modèles de domaine — représentation d'e-mails, d'argent, d'ID, de dates, d'adresses.
  • Validation des entrées — créer un VO à partir des entrées utilisateur garantit la validité partout en aval.
  • Règles métier — encapsulation de logique telle que la correspondance des devises ou la vérification des plages de dates.
  • Tests — des tests plus simples, plus rapides et plus ciblés sans dépendances à la base de données.
  • Refactoring — lorsque les exigences changent, vous modifiez le VO à un seul endroit.

Pièges courants à éviter

  • Ajouter des setters — cela brise l'immuabilité. Si vous avez besoin d'une valeur différente, créez une nouvelle instance.
  • Les rendre trop gros — un VO doit représenter un concept, pas une racine d'agrégat entière.
  • Ajouter une logique de persistance — gardez les préoccupations de base de données dans les repositories, pas dans les VOs.
  • Ignorer l'égalité — implémentez toujours une méthode equals().
  • Lancer des exceptions génériques — définissez des exceptions spécifiques au domaine pour une gestion d'erreurs plus claire.

Un chemin de refactoring pratique

Vous n'avez pas besoin de réécrire toute votre application du jour au lendemain. Commencez ici :

  1. Identifiez un champ primitif qui cause des bugs ou de la confusion (e-mails, argent, ID).
  2. Créez un Value Object pour celui-ci avec validation et méthodes utiles.
  3. Remplacez la primitive dans une classe ou un module.
  4. Laissez le compilateur et vos tests guider le reste.
  5. Répétez avec le prochain candidat évident.

Conclusion

Les Value Objects sont l'un des refactorings les plus efficaces que vous puissiez effectuer dans une base de code PHP. Ils éliminent l'obsession des primitives, centralisent la validation, rendent les états invalides impossibles à représenter et transforment votre code en une expression auto-documentée de votre domaine. Le système de types devient un allié plutôt qu'une formalité.

Commencez par un seul Value Object — peut-être Email ou Money. Une fois que vous verrez à quel point cela apporte de la clarté à votre code, vous ne reviendrez jamais à faire passer des chaînes brutes.

Arrêtez d'écrire du code obsédé par les primitives. Laissez vos types parler pour votre domaine.

Technologies et sujets

Tags de l'article

Aucun article ne correspond à ces filtres.

Vous avez un projet ou une idée à discuter ?

Parlons-en ↗