Symfony 7 不提供 ValueObject 基类或接口,需自行用 PHP 8.2+ readonly class 实现,确保不可变性、值语义和相等性;Doctrine 和 Serializer 均不原生支持,须手动配置 Embeddable 或 Normalizer。

Symfony 7 本身不提供 ValueObject 基类或接口——它不是框架内置概念,而是领域驱动设计(DDD)中的一种模式,需自行实现或借助第三方库。
ValueObject 在 Symfony 7 中没有原生支持
Symfony 的核心组件(如 symfony/framework-bundle、symfony/property-access)不定义 ValueObject 抽象类或契约。你不会在官方文档里找到 new ValueObject() 或 extends AbstractValueObject 这类用法。
这意味着:
- 所有
ValueObject行为(不可变性、值语义、相等性判断)必须由你手动保障 - 不能依赖 Symfony 自动序列化/反序列化时“识别”它是值对象——除非你显式配置
NormalizerInterface - Doctrine ORM 也不会自动将
ValueObject映射为嵌入式值,除非你用@Embeddable+ 自定义类型(PHP 8.1+ 推荐用readonly class)
用 readonly class 实现最简 ValueObject(PHP 8.2+ 推荐)
PHP 8.2 的 readonly 类天然契合 ValueObject 要求:构造后不可变、属性自动参与 == 比较(值语义)、无需手写 __construct 参数验证逻辑。
示例:
readonly class Money
{
public function __construct(
public int $amount,
public string $currency = 'EUR',
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount must be non-negative');
}
if (!\in_array($currency, ['EUR', 'USD', 'GBP'], true)) {
throw new \InvalidArgumentException('Unsupported currency');
}
}
public function isGreaterThan(Money $other): bool
{
return $this->amount > $other->amount && $this->currency === $other->currency;
}
}
注意:
- 别在
readonly class里加 setter 或可变方法——破坏值对象本质 - 若需 JSON 序列化,得注册自定义
NormalizerInterface,否则默认只输出公开属性(无方法调用) - Doctrine 不会自动处理它;要持久化,要么用
@Embeddable(需配合EmbeddableType),要么只存字段(如money_amount和money_currency)
与 Symfony Serializer 配合时的坑
直接把 Money 实例交给 SerializerInterface::serialize(),默认只会输出:
{"amount": 100, "currency": "EUR"}
但反序列化回来的是普通 stdClass 或数组,不是 Money 对象——除非你显式配置:
- 用
ObjectNormalizer+ConstructorAnnotationDenormalizer(需启用注解) - 或写一个专用
MoneyNormalizer实现NormalizerInterface和DenormalizerInterface - 更稳妥的方式:在 DTO 或 API 响应层用数组/标准对象传输,领域层才用
readonly class封装
常见错误现象:Cannot create an instance of Money from serialized data —— 这是因为反序列化器没被告知如何重建该类。
什么时候该用 ValueObject,而不是 Entity 或普通 DTO?
判断依据不是“看起来像值”,而是行为和语义:
- 需要值相等性(
$a == $b且$a !== $b成立)→ 适合readonly class - 会被多个 Entity 共享且无独立生命周期(如
Address、Email、PhoneNumber)→ 可考虑嵌入式映射 - 涉及业务规则校验(如邮箱格式、金额范围)→ 必须封装进构造逻辑,而非放 Controller 或 FormType 里
- 不需要 Doctrine ID、不参与生命周期管理(如
persist()/remove())→ 明确排除 Entity
容易被忽略的一点:ValueObject 的“不变性”是靠语言机制(readonly)+ 构造约束共同保证的,不是靠文档或命名约定。一旦你在某处偷偷修改了属性(比如通过反射),整个模型就失效了。


















