PHP 8.2 a démocratisé les classes readonly et posé les bases de l'immutabilité au niveau moteur. PHP 8.3 a affiné le modèle. Pourtant, une friction subsistait dans chaque projet DDD sérieux : créer une variante d'un Value Object en ne changeant qu'une propriété obligeait soit à rappeler un constructeur entier, soit à entretenir des wither methods redondantes. PHP 8.5.0, sorti stable le 20 novembre 2025, tranche la question avec clone $obj with ['prop' => $val]. Voici ce que ça change en pratique, du Value Object le plus simple jusqu'à l'Aggregate Symfony le plus chargé.
Le problème des Value Objects readonly avant PHP 8.5
Dans un projet DDD bien tenu, un Value Object comme Money est immutable par contrat. Avec readonly, PHP garantit cette immuabilité au niveau moteur — c'est précisément son intérêt. Le souci arrive quand le domaine exige une variante : $price->addTax(0.20) doit retourner une nouvelle instance sans toucher à l'originale. Avant PHP 8.5, trois patterns se disputaient cette niche, chacun avec ses défauts.
<?php
// PHP 8.2-8.4 : trois stratégies, toutes douloureuses
// 1. Rappeler le constructeur complet (fragile si signature évolue)
readonly class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
public readonly int $scale,
) {}
public function withAmount(int $amount): self
{
return new self($amount, $this->currency, $this->scale); // si on ajoute une prop, on oublie ici
}
}
// 2. Passer par ReflectionClass (lent, illisible, aucun type safety)
public function cloneWithAmount(int $amount): self
{
$clone = (new ReflectionClass($this))->newInstanceWithoutConstructor();
$ref = new ReflectionProperty($this, 'amountInCents');
$ref->setValue($clone, $amount);
// ... répéter pour chaque prop copiée
return $clone;
}
// 3. Wither methods manuelles (maintenable mais O(N) par propriété)
public function withCurrency(string $currency): self
{
return new self($this->amountInCents, $currency, $this->scale);
}
public function withScale(int $scale): self
{
return new self($this->amountInCents, $this->currency, $scale);
}La solution 3 est la plus courante dans les codebases sérieuses — et la plus pénible à tenir dans la durée. Chaque nouvelle propriété génère une wither method à créer, et toutes les wither existantes à mettre à jour. Sur un Value Object à 6 champs, c'est 6 méthodes dont chacune passe 5 propriétés en aveugle. L'oubli d'une seule lors d'un refactoring silencieux ne produit aucune erreur de compilation.
La syntaxe `clone with` en deux lignes
La RFC clone_with_v2, acceptée mi-2025 et implémentée dans PHP 8.5, introduit un opérateur clone … with qui crée une nouvelle instance en copiant toutes les propriétés de l'original, puis en surchargeant uniquement celles listées dans le tableau. Les propriétés readonly sont supportées nativement — c'est le point central de la v2 : la v1, rejetée en 2023, ne le permettait pas.
<?php
// PHP 8.5+
readonly class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
public readonly int $scale = 2,
) {}
}
$price = new Money(1000, 'EUR');
// Changer une seule propriété readonly — sans wither method
$discounted = clone $price with ['amountInCents' => 800];
var_dump($discounted->amountInCents); // int(800)
var_dump($discounted->currency); // string(3) "EUR"
var_dump($discounted->scale); // int(2)
// Changer plusieurs propriétés en une passe
$converted = clone $price with [
'amountInCents' => 1080,
'currency' => 'USD',
];Quelques précisions sur le comportement moteur : le constructeur n'est pas appelé (c'est un clone, pas une instanciation), mais la méthode magique __clone() l'est. Les propriétés non listées sont copiées bit-à-bit depuis l'original, y compris les objets imbriqués par référence (shallow copy, identique à un clone standard). Si tu passes une clé inexistante dans le tableau, PHP lève une Error au runtime. Le typage est vérifié : passer un string là où une propriété est typée int déclenche une TypeError immédiate.
Wither methods vs `clone with` : le comparatif qui compte
L'impact se mesure sur un Value Object réaliste. Prenons un Address avec 5 champs — typique dans tout domaine e-commerce ou B2B — avant et après PHP 8.5.
<?php
// Avant PHP 8.5 : 5 wither methods = 30+ lignes de boilerplate
readonly class Address
{
public function __construct(
public readonly string $street,
public readonly string $city,
public readonly string $postalCode,
public readonly string $countryCode,
public readonly string|null $complement = null,
) {}
public function withStreet(string $street): self
{
return new self($street, $this->city, $this->postalCode, $this->countryCode, $this->complement);
}
public function withCity(string $city): self
{
return new self($this->street, $city, $this->postalCode, $this->countryCode, $this->complement);
}
public function withPostalCode(string $postalCode): self
{
return new self($this->street, $this->city, $postalCode, $this->countryCode, $this->complement);
}
// ... et ainsi de suite
}
// Après PHP 8.5 : 0 wither method
$updated = clone $address with ['street' => '12 rue de la Paix', 'postalCode' => '75001'];Le gain est immédiat : moins de lignes à écrire, zéro maintenance en miroir, et le code appelant exprime clairement quelle propriété change et pourquoi. Si tu ajoutes une 6ème propriété au constructeur, les sites d'appel clone with n'ont pas à évoluer. Seul le constructeur change. L'inverse — oublier de mettre à jour une wither method — n'est désormais plus un risque.
Value Objects DDD concrets : Money, EmailAddress, DateRange
Besoin d'un expert Symfony ?
Réserver un appel →Trois Value Objects tels qu'on les écrit en 2026 sur des projets Symfony 8.1, avec validation dans le constructeur et usage de clone with dans les méthodes de domaine. La logique de validation reste dans le constructeur : les méthodes exposent leur intention sans se préoccuper du câblage interne.
<?php
declare(strict_types=1);
namespace App\Domain\Shared\ValueObject;
use InvalidArgumentException;
readonly class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
if (!in_array($currency, ['EUR', 'USD', 'GBP'], strict: true)) {
throw new InvalidArgumentException("Unsupported currency: $currency");
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot add different currencies.');
}
// clone with contourne le constructeur — on vérifie manuellement
assert($this->amountInCents + $other->amountInCents >= 0);
return clone $this with ['amountInCents' => $this->amountInCents + $other->amountInCents];
}
public function applyDiscount(float $rate): self
{
return clone $this with [
'amountInCents' => (int) round($this->amountInCents * (1 - $rate)),
];
}
}
readonly class EmailAddress
{
public function __construct(public readonly string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: $value");
}
}
}
readonly class DateRange
{
public function __construct(
public readonly \DateTimeImmutable $start,
public readonly \DateTimeImmutable $end,
) {
if ($end <= $start) {
throw new InvalidArgumentException('End must be after start.');
}
}
public function extendBy(\DateInterval $interval): self
{
return clone $this with ['end' => $this->end->add($interval)];
}
}Point critique sur la validation : puisque le constructeur n'est pas appelé lors d'un clone with, les invariants de construction ne sont pas réexécutés. C'est intentionnel — le clone est supposé produire un état cohérent à partir d'un objet déjà valide — mais ça exige de valider manuellement dans les méthodes de domaine quand une opération peut violer un invariant, comme dans add() ci-dessus. Ce n'est pas un bug, c'est un contrat à expliciter avec ton équipe.
Dans un Aggregate Symfony 8.1 : Order et ses lignes
Le vrai gain de clone with se révèle dans les Aggregates, où l'on manipule des Value Objects imbriqués et où les transitions d'état sont fréquentes. Le pattern est direct : chaque méthode de domaine retourne self, le Command Handler persiste la nouvelle instance. Aucune mutation, aucun setter, aucune wither method à synchroniser.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Shared\ValueObject\Money;
class Order
{
private function __construct(
public readonly OrderId $id,
private readonly array $lines, // OrderLine[]
private readonly Money $total,
private readonly OrderStatus $status,
) {}
public static function create(OrderId $id, OrderLine ...$lines): self
{
$total = array_reduce(
$lines,
fn (Money $carry, OrderLine $line) => $carry->add($line->subtotal()),
new Money(0, 'EUR'),
);
return new self($id, $lines, $total, OrderStatus::Draft);
}
public function confirm(): self
{
if ($this->status !== OrderStatus::Draft) {
throw new \DomainException('Only draft orders can be confirmed.');
}
// On retourne une nouvelle instance de l'Aggregate
// sans toucher aux autres champs
return clone $this with ['status' => OrderStatus::Confirmed];
}
public function applyGlobalDiscount(float $rate): self
{
return clone $this with [
'total' => $this->total->applyDiscount($rate),
'lines' => array_map(
fn (OrderLine $l) => $l->withDiscount($rate),
$this->lines,
),
];
}
}
// Usage dans un Command Handler Symfony
final class ConfirmOrderHandler
{
public function __construct(
private readonly OrderRepository $orders,
) {}
public function __invoke(ConfirmOrderCommand $command): void
{
$order = $this->orders->get($command->orderId);
$confirmed = $order->confirm();
$this->orders->save($confirmed);
}
}Le code se lit comme du domaine pur : confirm() et applyGlobalDiscount() expriment l'intention métier sans traverser du boilerplate de copie. Les Command Handlers restent fins. Et si l'Aggregate gagne une nouvelle propriété demain — une note, un channel — les appels clone with existants ne bougent pas.
Les cas limites à anticiper
- Shallow copy des objets imbriqués : comme tout
cloneclassique, les objets référencés sont copiés par référence. Un\DateTimemutable imbriqué doit être cloné explicitement dans__clone(). Avec\DateTimeImmutableou d'autres Value Objectsreadonly, le problème ne se pose pas. - Le constructeur n'est pas appelé : les invariants de construction ne sont pas réexécutés. Pour les opérations qui modifient des données sensibles (montant, statut), ajoute les gardes directement dans la méthode de domaine qui appelle
clone with. - Clé inexistante = Error fatale :
clone $obj with ['typo' => 'val']lève uneErrorau runtime, pas à la compilation. PhpStorm 2026.1+ et PHPStan 2.x analysent statiquement le tableau et signalent les propriétés inconnues avant l'exécution. - Propriétés uninitialized : une propriété typée sans valeur par défaut absente du tableau est copiée depuis l'original. Le problème ne survient que si l'original était lui-même dans un état uninitialized — exclu par les constructeurs stricts.
- Héritage :
clone withopère sur la classe concrète. Cloner un enfant en ne listant que des propriétés du parent copie normalement les propriétés de l'enfant. Aucune surprise. - Performances : implémenté au niveau moteur Zend, sans Reflection. En benchmark, comparable à un
clonestandard suivi d'une assignation directe — largement plus rapide que le contournement par Reflection utilisé dans la solution 2.
Intégration dans ton workflow : Doctrine, tests, et migrations
Avec Doctrine ORM via Symfony 8.1, les Value Objects sont typiquement mappés comme Embeddable. La compatibilité avec clone with est transparente : Doctrine instancie ses entités via Reflection de son côté ; tu travailles en domaine pur du tien. Pour les migrations, rien ne change — clone with est une construction purement applicative. Les tests unitaires bénéficient directement de la clarté syntaxique :
<?php
// PHPUnit 11 + Pest — tests sur Money avec clone with
it('applies a 20% discount correctly', function (): void {
$price = new Money(1000, 'EUR');
$discounted = $price->applyDiscount(0.20);
expect($discounted->amountInCents)->toBe(800);
expect($discounted->currency)->toBe('EUR');
// L'original reste intact — immutabilité garantie par readonly
expect($price->amountInCents)->toBe(1000);
});
it('preserves currency on addition', function (): void {
$a = new Money(300, 'EUR');
$b = new Money(700, 'EUR');
expect($a->add($b)->amountInCents)->toBe(1000);
expect($a->amountInCents)->toBe(300); // $a inchangé
});
it('rejects cross-currency addition', function (): void {
expect(fn () => new Money(500, 'EUR')->add(new Money(200, 'USD')))
->toThrow(InvalidArgumentException::class, 'Cannot add different currencies.');
});Avec clone with, l'immutabilité dans un projet DDD n'est plus un compromis entre rigueur et verbosité. Tu écris un constructeur propre avec ses invariants, tu exposes des méthodes de domaine qui expriment l'intention métier, et PHP se charge du reste. La réduction du boilerplate n'est pas un détail esthétique : c'est du code en moins à lire, à tester et à faire évoluer. PHP 8.5 n'a pas réinventé le Domain-Driven Design — il a simplement retiré le dernier obstacle syntaxique qui rendait l'immutabilité laborieuse à grande échelle.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.