Prendre en main les objets-valeurs dans Symfony

Comment les objets-valeurs rendent le code métier Symfony plus clair, plus strict et plus facile à maintenir.
Dans le développement PHP moderne, la clarté et la précision sont essentielles. Utiliser uniquement des types primitifs, comme des chaînes de caractères et des entiers, pour représenter des données métier importantes peut entraîner de nombreux problèmes. À mesure qu’une application évolue et se complexifie, ces types ne suffisent souvent plus à exprimer les nuances et les règles propres au domaine. Il peut en résulter des bogues subtils, du code dupliqué et une maintenance difficile. Je vais voir comment les objets-valeurs offrent une solution robuste dans Symfony en encapsulant la logique métier, en garantissant l’intégrité des données et en favorisant un code plus clair et plus facile à maintenir.
Les limites des types primitifs
Prenons une classe Student classique :
Lorsque des types primitifs représentent des données complexes, le contexte qui définit ces données se perd souvent. Une simple chaîne peut, par exemple, représenter une adresse e-mail, un nom d’utilisateur ou du texte brut. Cette ambiguïté peut entraîner les problèmes suivants :
-
Ambiguïté et manque de contexte : des chaînes comme « email » ou « birthdate » n’ont pas de sens propre sans contexte supplémentaire. Elles peuvent donc être mal interprétées et provoquer des erreurs.
-
Validation dispersée et dupliquée : vérifier les adresses e-mail, les noms d’utilisateur et d’autres données conduit souvent à disperser la logique de validation dans l’application, ce qui crée des duplications et de possibles incohérences.
-
Intégrité des données menacée : sans garde-fous intégrés, des données invalides peuvent facilement entrer dans le système et provoquer des comportements inattendus ou des bogues.
Ces limites invitent à adopter de meilleures solutions pour traiter les données propres au domaine.
Que sont les objets-valeurs ?
Les objets-valeurs se définissent par la valeur de leurs données plutôt que par leur identité. Contrairement aux entités, qui sont identifiées par un ID, ils encapsulent un ensemble de données et les règles qui les régissent.
-
Encapsulation des règles métier : chaque objet-valeur valide et gère ses propres données. Par exemple, un objet-valeur
Emailne se contente pas de stocker une adresse e-mail : il vérifie aussi qu’elle respecte le format attendu. -
Immutabilité : une fois créés, les objets-valeurs ne peuvent plus être modifiés. Cette propriété garantit la cohérence des données dans toute l’application.
-
Comparaison par valeur : les objets-valeurs sont comparés à partir des données qu’ils contiennent, et non de leurs références. Les tests d’égalité sont ainsi simples et prévisibles.
En PHP, des classes comme DateTimeImmutable et SplFileInfo constituent des exemples courants de ces principes.
Créer des objets-valeurs dans Symfony
L’adoption des objets-valeurs dans Symfony passe par quelques étapes essentielles :
1. Créer la classe de l’objet-valeur
Un objet-valeur bien conçu respecte les principes suivants :
-
Valider les données d’entrée : à sa création, un objet-valeur doit valider rigoureusement les données reçues pour garantir leur conformité aux règles du domaine.
-
Encapsuler le comportement : toute logique liée aux données de l’objet-valeur, comme le formatage ou la transformation, doit être encapsulée dans l’objet lui-même.
Objet-valeur Email :
namespace App\Entity\ValueObject;
use Webmozart\Assert\Assert;
final readonly class Email implements \Stringable
{
public string $email;
public function __construct(string $value)
{
Assert::notEmpty($value);
Assert::email($value);
$this->value = $value;
}
#[\Override]
public function __toString(): string
{
return $this->email;
}
}Objet-valeur Username :
namespace App\Entity\ValueObject;
use Webmozart\Assert\Assert;
final readonly class Username implements \Stringable
{
private const int MIN_LENGTH = 3;
private const int MAX_LENGTH = 30;
private const string PATTERN = 'some complex regex';
private string $username;
private function __construct(string $username)
{
Assert::notEmpty($username);
Assert::minLength($username, self::MIN_LENGTH);
Assert::maxLength($username, self::MAX_LENGTH);
Assert::pattern($username, self::PATTERN);
$this->username = $username;
}
#[\Override]
public function __toString(): string
{
return $this->username;
}
}2. Maîtriser la complexité avec des fabriques
La validation d’un objet-valeur Address peut être délicate en raison de sa complexité. Comment vérifier, par exemple, que le pays ou la ville indiqué existe réellement ? Dans ce cas, une fabrique d’objets peut créer l’objet-valeur. Elle peut s’appuyer sur des services externes ou interroger la base de données pour valider les informations fournies.
Objet-valeur Address :
namespace App\Entity\ValueObject;
final readonly class Address
{
public function __construct(
public ?string $city = null,
public ?string $country = null,
public ?string $addressLine1 = null,
public ?string $addressLine2 = null
) {
}
}Lorsque la logique de validation devient trop complexe pour être gérée dans l’objet-valeur, utilisez une fabrique. Celle-ci peut être injectée dans les formulaires ou dans toute autre partie de l’application qui crée des objets-valeurs. Voici un exemple simple :
Fabrique d’Address :
namespace App\Factory;
use App\Entity\ValueObject\Address;
use Symfony\Component\Intl\Countries;
use Webmozart\Assert\Assert;
final readonly class AddressFactory
{
public function create(
?string $city,
?string $country,
?string $line1,
?string $line2
): Address {
Assert::notEmpty($city, 'City cannot be empty');
Assert::notEmpty($country, 'Country cannot be empty');
Assert::notEmpty($addressLine1, 'Address line 1 cannot be empty');
Assert::nullOrNotEmpty($addressLine2, 'Address line 2 cannot be empty');
// or any data source like a repository etc...
if (!Countries::alpha3CodeExists($country) || !Countries::exists($country)) {
throw new \InvalidArgumentException('Invalid Country');
}
return new Address($city, $country, $addressLine1, $addressLine2);
}
}Avec cette approche, toute instance d’un objet-valeur garantit la validité de sa valeur. Aucun contrôle supplémentaire n’est nécessaire. Cela simplifie le raisonnement et se révèle particulièrement utile dans les applications de grande taille.
3. Persister les objets-valeurs avec Doctrine
L’intégration de Doctrine dans Symfony facilite la persistance des objets-valeurs en base de données. Grâce à la fonctionnalité embeddable de Doctrine, un objet-valeur peut être traité comme une partie d’une entité plus large.
Objet-valeur Email :
namespace App\Entity\ValueObject;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
use Webmozart\Assert\Assert;
#[Embeddable]
final readonly class Email implements \Stringable
{
#[Column(type: "string")]
public string $email;
}Objet-valeur Username :
namespace App\Entity\ValueObject;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
use Webmozart\Assert\Assert;
#[Embeddable]
final readonly class Username implements \Stringable
{
#[Column(type: "string")]
public string $username;
}Objet-valeur Address :
namespace App\Entity\ValueObject;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
#[Embeddable]
final class Address
{
public function __construct(
#[Column(length: 255)] public ?string $city = null,
#[Column(length: 255)] public ?string $country = null,
#[Column(length: 255)] public ?string $addressLine1 = null,
#[Column(length: 255, nullable: true)] public ?string $addressLine2 = null
) {
}
}Il s’agit de l’approche la plus simple, mais vous pouvez aussi définir un type de mapping personnalisé pour des besoins plus spécifiques.
4. Gérer les types de formulaires
Travailler avec les formulaires Symfony implique souvent de convertir les saisies des utilisateurs en objets-valeurs personnalisés, ce qui nécessite de créer des types de formulaires dédiés. Il faut notamment implémenter DataMapperInterface pour assurer la conversion entre les données brutes du formulaire et l’objet-valeur, puis vérifier que ces données sont transformées et validées conformément aux règles définies dans sa classe.
Type Email :
namespace App\Form\Types;
use App\Entity\ValueObject\Email;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\EmailType as SymfonyEmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class EmailType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('email', SymfonyEmailType::class, [
'label' => "email",
'attr' => [
'placeholder' => 'exemple bernard@devscast.tech'
]
])->setDataMapper($this);
}
/**
* @see https://github.com/symfony/symfony/issues/59950
*/
public function getBlockPrefix(): string
{
return '';
}
public function configureOptions(OptionsResolver $resolver): OptionsResolver
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'data_class' => Email::class, /** value object */
'empty_data' => null
]);
return $resolver;
}
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
$forms = iterator_to_array($forms);
$forms['email']->setData((string) $viewData);
}
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
$forms = iterator_to_array($forms);
try {
$viewData = new Email($forms['email']->getData());
} catch (\InvalidArgumentException $e) {
$forms['email']->addError(new FormError($e->getMessage()));
}
}
}Dans le cas particulier d’EmailType, qui peut être confondu avec l’EmailType natif de Symfony, vous devez redéfinir la méthode getBlockPrefix. Vous trouverez davantage d’informations à ce sujet ici.
Type Username :
namespace App\Form\Types;
use App\Entity\ValueObject\Username;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class UsernameType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('username', TextType::class, [
'label' => "nom d'utilisateur",
'attr' => [
'placeholder' => 'exemple @_bernard.ng_'
]
])->setDataMapper($this);
}
public function configureOptions(OptionsResolver $resolver): OptionsResolver
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'data_class' => Username::class,
'empty_data' => null
]);
return $resolver;
}
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
$forms = iterator_to_array($forms);
$forms['username']->setData((string) $viewData);
}
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
$forms = iterator_to_array($forms);
try {
$viewData = new Username($forms['username']->getData());
} catch (\InvalidArgumentException $e) {
$forms['username']->addError(new FormError($e->getMessage()));
}
}
}Type Address utilisant AddressFactory :
Avec cette approche, il peut être difficile d’associer les erreurs au bon champ. Si vous avez besoin de plus de contrôle, vous pouvez créer une AddressFormFactory qui gère l’ensemble de la validation du formulaire et ajoute les erreurs aux champs concernés. Pour rester simple, je conserverai toutefois l’AddressFactory élémentaire définie précédemment.
namespace App\Form\Types;
use App\Entity\ValueObject\Address;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class AddressType extends AbstractType implements DataMapperInterface
{
public function __construct(
private readonly AddressFactory $addressFactory
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('city', TextType::class)
->add('country', CountryType::class)
->add('addressLine1', TextType::class)
->add('addressLine2', TextType::class, [
'required' => false
])
->setDataMapper($this);
}
public function configureOptions(OptionsResolver $resolver): OptionsResolver
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'data_class' => Address::class,
'empty_data' => null
]);
return $resolver;
}
public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
$forms = iterator_to_array($forms);
$forms['city']->setData($viewData?->city);
$forms['country']->setData($viewData?->country);
$forms['addressLine1']->setData($viewData?->addressLine1);
$forms['addressLine2']->setData($viewData?->addressLine2);
}
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
$forms = iterator_to_array($forms);
try {
// encapsulate heavy validation logic
$viewData = $this->addressFactory->create(
$forms['city']->getData(),
$forms['country']->getData(),
$forms['addressLine1']->getData(),
$forms['addressLine2']->getData()
);
} catch (\InvalidArgumentException $e) {
// you can create custom exception for each field
// and map it to the right field
$forms['city']->addError(new FormError($e->getMessage()));
}
}
}Le traitement du formulaire doit maintenant fonctionner sans modification du contrôleur ni des vues. En définissant mes objets-valeurs et un type de formulaire pour chacun, mon entité peut les utiliser et ne plus dépendre de types primitifs.
#[ORM\Entity(repositoryClass: StudentRepository::class)]
class Student
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private(set) ?int $id = null;
public function __construct(
#[ORM\Embedded(class: Email::class)]
private(set) Email $email,
#[ORM\Embedded(class: Username::class)]
private(set) Username $username,
#[ORM\Embedded(class: Address::class, columnPrefix: false)]
private(set) Address $address,
#[ORM\Column]
private(set) \DateTimeImmutable $birthdate,
) {
}
}Conclusion
Adopter les objets-valeurs dans Symfony transforme la gestion des données propres au domaine. En encapsulant les règles métier, en garantissant l’immutabilité et en améliorant la clarté, ils permettent d’éviter les limites des types primitifs. Qu’il s’agisse de valider une adresse e-mail ou d’intégrer des types complexes dans vos entités, cette approche produit un code plus sûr, plus prévisible et plus expressif.
Bon développement !