Modéliser les données métier avec des objets-valeurs dans Symfony

Remplacer les chaînes et les entiers ambigus par des objets-valeurs qui portent leurs règles métier.
Une chaîne PHP ne dit pas si elle contient une adresse e-mail, un nom d’utilisateur ou une date. Tant que l’application reste petite, le nom de la variable donne assez de contexte. Ensuite, les mêmes validations se répètent et des valeurs invalides franchissent plus facilement les frontières du système. Les objets-valeurs rassemblent la donnée et ses règles dans un type explicite.
Les limites des types primitifs
Prenons une classe Student classique :
Student accepte plusieurs chaînes, mais PHP ne distingue pas leur rôle. Le constructeur peut donc recevoir un nom d’utilisateur à la place d’une adresse e-mail sans produire d’erreur de type.
-
Des chaînes comme
emailoubirthdaten’ont pas de sens propre sans le nom de la variable. Elles peuvent être interverties sans erreur de type. -
La validation des adresses e-mail, des noms d’utilisateur et des autres données finit dispersée dans l’application. Les mêmes règles se répètent et peuvent diverger.
-
Sans garde-fous dans le type, une valeur invalide peut entrer dans le système et provoquer un comportement inattendu.
Un type propre à chaque concept rend ces erreurs visibles plus tôt.
Que sont les objets-valeurs ?
Les objets-valeurs se définissent par leurs données plutôt que par leur identité. Une entité possède un ID. Un objet-valeur regroupe des données et les règles qui les régissent.
-
Chaque objet-valeur porte ses propres règles.
Emailstocke l’adresse et refuse un format invalide dès sa construction. -
Un objet-valeur est immuable. Toute modification produit une nouvelle valeur.
-
Deux objets-valeurs sont égaux lorsque leurs données le sont, indépendamment de leur référence en mémoire.
En PHP, DateTimeImmutable et SplFileInfo illustrent déjà certains de ces principes.
Créer des objets-valeurs dans Symfony
La mise en place suit quelques étapes.
Créer la classe de l’objet-valeur
Un objet-valeur bien conçu respecte les principes suivants :
-
Validez les données dans le constructeur ou dans une méthode de fabrique. Une instance créée doit déjà respecter les règles du domaine.
-
Gardez dans l’objet les opérations qui portent sur cette valeur, notamment son formatage ou sa transformation.
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;
}
}Déplacer les validations externes dans une fabrique
Address peut vérifier sa propre structure, mais pas l’existence d’une ville ou d’un pays sans dépendre d’une source externe. Une fabrique effectue ces recherches avant de construire la valeur.
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
) {
}
}Injectez cette fabrique dans les formulaires et les autres services qui créent une adresse.
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);
}
}La fabrique ne renvoie une instance qu’après les vérifications externes. Le reste du code peut donc manipuler Address sans répéter ces contrôles.
Persister les objets-valeurs avec Doctrine
Les objets incorporables de Doctrine stockent un objet-valeur dans les colonnes de son entité propriétaire.
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
) {
}
}Les objets incorporables donnent ici le mapping le plus direct. Un type Doctrine personnalisé convient lorsque le format de stockage demande sa propre conversion.
Relier les objets-valeurs aux formulaires Symfony
Un type de formulaire dédié convertit la saisie en objet-valeur. DataMapperInterface lit les champs bruts, construit la valeur et laisse sa classe appliquer les règles.
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()));
}
}
}Le nom EmailType entre en conflit avec celui de Symfony. Redéfinissez getBlockPrefix pour distinguer les deux types.
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 avec AddressFactory
Cette approche associe difficilement une erreur de construction au champ précis qui l’a causée. Une AddressFormFactory peut ajouter chaque erreur au champ concerné. L’exemple garde l’AddressFactory plus simple définie plus haut.
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 contrôleur et les vues ne changent pas. Le type de formulaire leur présente les champs habituels, puis fournit des objets-valeurs à l’entité.
#[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,
) {
}
}Quand introduire un objet-valeur
Les objets-valeurs demandent quelques classes supplémentaires, mais elles retirent des validations dispersées et rendent les signatures plus précises. Commencez par les concepts qui ont de vraies règles, comme une adresse e-mail, une devise ou une période. Une simple chaîne sans contrainte n’a pas besoin d’être enveloppée par principe.