本文目录导读:

在 PHP 中,实体(Entity)和值对象(Value Object,简称 VO)是领域驱动设计(DDD)中的两个核心概念,它们的核心区别在于身份标识和可变性。
下面从概念、PHP 实现示例、最佳实践三个维度进行详细说明。
核心概念区分
| 特性 | 实体 (Entity) | 值对象 (Value Object) |
|---|---|---|
| 身份标识 | 有唯一标识(ID),如数据库自增ID、UUID。 | 无身份标识,由其属性值整体定义。 |
| 相等性 | 通过 ID 判断相等(同一个ID即同一个实体)。 | 通过所有属性值判断相等。 |
| 可变性 | 可变,状态可以改变(如修改用户名)。 | 不可变,创建后不能修改,需要修改则生成新对象。 |
| 生命周期 | 有独立生命周期,可持久化到数据库。 | 无生命周期,属于某个实体,纯内存对象。 |
| 典型例子 | User、Order、Product |
Address、Money、Coordinates、Email |
PHP 代码实现
场景假设
需求:设计一个用户系统,用户有姓名和地址。
User(实体):有 ID,名字可以被修改。Address(值对象):没有 ID,包含省份、城市、街道,且不可变。
步骤 1:实现值对象(Address)
关键点:使用 final、私有属性、构造函数赋值、with* 方法生成新对象、equals 方法比较。
<?php
// 1. 值对象:Address
final class Address
{
public function __construct(
private readonly string $province,
private readonly string $city,
private readonly string $street
) {}
// Getter(只读,不提供 Setter)
public function getProvince(): string
{
return $this->province;
}
public function getCity(): string
{
return $this->city;
}
public function getStreet(): string
{
return $this->street;
}
// 关键:修改时返回新对象(不可变原则)
public function withStreet(string $newStreet): self
{
return new self($this->province, $this->city, $newStreet);
}
// 关键:基于值比较相等性
public function equals(self $other): bool
{
return $this->province === $other->province
&& $this->city === $other->city
&& $this->street === $other->street;
}
}
步骤 2:实现实体(User)
关键点:有 id 字段,允许修改属性(提供 changeName 方法),持有值对象作为属性。
<?php
// 2. 实体:User
final class User
{
// 核心:实体必须有唯一 ID
public function __construct(
private readonly int $id,
private string $name,
private Address $address
) {}
// ID 通常是只读的(此处仅 Getter)
public function getId(): int
{
return $this->id;
}
// 允许修改状态
public function changeName(string $newName): void
{
$this->name = $newName;
}
public function getName(): string
{
return $this->name;
}
// 修改地址时,接收一个新的值对象(或使用 with 方法)
public function changeAddress(Address $newAddress): void
{
$this->address = $newAddress;
}
public function getAddress(): Address
{
return $this->address;
}
// 实体相等性:比较 ID
public function equals(self $other): bool
{
return $this->id === $other->id;
}
}
步骤 3:实际使用测试
<?php
// 创建值对象(不可变)
$address1 = new Address('浙江', '杭州', '文一西路100号');
$address2 = new Address('浙江', '杭州', '文一西路100号');
// 值对象比较(值相等)
var_dump($address1->equals($address2)); // bool(true)
// 修改值对象(生成新对象)
$address3 = $address1->withStreet('文二西路200号');
var_dump($address1->getStreet()); // 文一西路100号(原对象未变)
var_dump($address3->getStreet()); // 文二西路200号
// 创建实体
$user1 = new User(1, 'Alice', $address1);
$user2 = new User(1, 'Bob', $address1); // 相同 ID
// 实体比较(ID 相等)
var_dump($user1->equals($user2)); // bool(true)(忽略了名字差异)
// 修改实体状态
$user1->changeName('Alice Smith');
$user1->changeAddress($address3);
在 PHP 中的高级特性
1 PHP 8 特性优化(只读类)
PHP 8.2+ 提供了 readonly 类,非常适合值对象。
// 值对象使用 readonly 类,所有属性自动只读
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
// 依然需要手动实现 equals 和 with 方法
}
2 序列化 / JSON 转换
在返回 API 响应时,通常需要将值对象转换为数组或 JSON。
public function toArray(): array
{
return [
'province' => $this->province,
'city' => $this->city,
'street' => $this->street,
];
}
3 实体使用 UUID
在现代 PHP 应用中,实体 ID 常用 UUID(如 Ramsey\Uuid)。
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final class User
{
public function __construct(
private readonly UuidInterface $id, // 类型为 UUID
// ...
) {}
}
// 创建时生成
$user = new User(Uuid::uuid4(), 'Alice', $address);
设计原则与反模式
✅ 最佳实践
- 值对象使用
final:防止被继承(子类可能打破不变性和相等性)。 - 全局属性初始化:构造函数中完成所有属性赋值,避免
setXxx方法。 - 实体关注行为:不要在实体中直接
getAddress()->setStreet(...),应调用user->changeAddress(...)。 - 区分集合归属:值对象可以复用(如多个订单使用同一地址),但不必在数据库单独建表,通常以 JSON 或子表存储。
❌ 常见反模式
- 把值对象当实体用:给
Address加了一个自增id字段(破坏值对象原则)。 - 实体过度暴露 Setter:
setName()应改为体现业务意图的方法,如changeName()、rename()。 - 在值对象中实现业务逻辑:值对象可以包含计算(如
Money::add()),但不应依赖外部服务。
什么时候用哪个?
-
确定是否有生命周期:
- 如果这个对象需要被修改并持久化,且需要追踪身份(如用户改了几次名字),→ 实体。
- 如果只是一个“数据快照”,购买时的收货地址”(即使后续用户改了地址,这笔订单的地址不该变),→ 值对象。
-
典型场景:
Order(实体)包含ShippingAddress(值对象)。Customer(实体)包含CustomerStatus(值对象,如ACTIVE/BLOCKED)。Product(实体)包含Price(值对象,包含金额和货币)。
希望这些示例能帮你建立起清晰的边界,如果需要在 Laravel/Symfony 中的具体实现(如 Doctrine ORM 映射),可以继续告诉我。