
我们探讨了PHP 8 终于感觉像领域驱动设计 (DDD) 的自然选择——得益于只读属性、枚举、联合类型、属性和更好的类型安全等特性。
这不是一个理论性的 DDD 讲座。本文重点关注你实际如何在 PHP 8 中编写值对象和聚合根,人们常见的错误,以及在生产代码中效果良好的模式。
在许多 PHP 应用中,我们仍然看到这样的代码:
$order->setPrice(19.99);$order->setCurrency('USD');$order->setEmail('foo@bar.com');
乍一看,这看起来没问题。实际上,它隐藏了问题:
19.99 有效吗?
货币可以是任意值吗?
谁保证电子邮件格式?
什么能阻止后续修改这些值?
DDD 的答案很简单:
如果某物具有业务含义,就明确建模它。
这正是值对象的作用。
值对象 表示一个仅由其值定义的概念,而不是由身份定义。
示例:
Money(货币)
EmailAddress(电子邮件地址)
Quantity(数量)
Percentage(百分比)
Coordinates(坐标)
DateRange(日期范围)
不可变
在创建时验证
无身份
按值相等
PHP 8.1+ 让这变得容易得多。
Money 值对象(正确实现)让我们以真实系统所需的方式建模 Money。
classMoney{public float $amount;public string $currency;}
这只是一个数据袋——不是领域概念。
declare(strict_types=1);final classMoney{public function__construct(public readonly int $amount, // 以美分为单位存储public readonly Currency $currency) {if($this->amount < 0) {throw new DomainException('货币金额不能为负');}}public functionadd(Money $other): Money{$this->assertSameCurrency($other);return new Money($this->amount + $other->amount,$this->currency);}public functionsubtract(Money $other): Money{$this->assertSameCurrency($other);if($other->amount > $this->amount) {throw new DomainException('结果货币不能为负');}return new Money($this->amount - $other->amount,$this->currency);}private functionassertSameCurrency(Money $other): void{if($this->currency !== $other->currency) {throw new DomainException('货币不匹配');}}}
enum Currency: string{case USD = 'USD';case EUR = 'EUR';case GBP = 'GBP';}
不可能存在无效的货币
不可能有货币不匹配
没有变异 bug
业务规则居住在领域内部
这不是“过度工程”——这是让 bug 不可能发生。
with…() 方法值对象绝不应改变状态。相反,你返回一个新实例。
public function withDiscount(int $discountInCents): Money{return new Money(max(0, $this->amount - $discountInCents),$this->currency);}
这让你的领域可预测、可测试,并且对并发安全。
如果值对象是原子,聚合根就是分子。
聚合根 是:
一组领域对象的簇
被视为单一的一致性边界
仅通过其根进行修改
让我们建模一个简化的电商 Order。
订单有多个 OrderItems
一旦发货,就不能修改订单
总价是派生的——不存储
只有订单控制其项目
enum OrderStatus: string{case Draft = 'draft';case Paid = 'paid';case Shipped = 'shipped';}
final classOrderItem{public function__construct(public readonly string $productId,public readonly Money $price,public readonly int $quantity) {if($quantity <= 0) {throw new DomainException('数量必须为正');}}public functiontotal(): Money{return new Money($this->price->amount * $this->quantity,$this->price->currency);}}
final classOrder{private OrderStatus $status;/** @var OrderItem[] */private array $items = [];public function__construct(private readonly string $id,private readonly Currency $currency) {$this->status = OrderStatus::Draft;}public functionaddItem(OrderItem $item): void{$this->assertDraft();if($item->price->currency !== $this->currency) {throw new DomainException('货币不匹配');}$this->items[] = $item;}public functionpay(): void{$this->assertDraft();if(empty($this->items)) {throw new DomainException('不能为订单为空支付');}$this->status = OrderStatus::Paid;}public functionship(): OrderShipped{if($this->status !== OrderStatus::Paid) {throw new DomainException('发货前订单必须已支付');}$this->status = OrderStatus::Shipped;return new OrderShipped(orderId: $this->id,shippedAt: new DateTimeImmutable());}public functiontotal(): Money{$total = new Money(0, $this->currency);foreach($this->items as $item) {$total = $total->add($item->total());}return $total;}private functionassertDraft(): void{if($this->status !== OrderStatus::Draft) {throw new DomainException('订单不能再被修改');}}}
— 无 setter — 无公共属性 — 无持久化逻辑 — 无框架依赖
然而,该模型是:
富有表现力
可测试
业务准确
难以误用
这就是一个好的聚合根的标志。
聚合根不应发送电子邮件、发布消息或调用 API。
相反,它们发出事实。
final classOrderShipped{public function__construct(public readonly string $orderId,public readonly DateTimeImmutable $shippedAt) {}}
你的应用层决定如何处理它:
发送电子邮件
通知仓库
发布到 Kafka/RabbitMQ
触发另一个有界上下文
是的,真实项目使用 Doctrine、Eloquent 等。
关键思想:
基础设施适应领域——而不是反过来。
PHP 8 属性有助于保持可读性:
#[ORM\Entity]classOrderEntity{// 映射逻辑在这里}
你的领域模型保持框架无关。
老实说——DDD 因误用而声名狼藉。
如果一切都有 getter/setter,你就不是在做 DDD。
并非一切都需要是聚合根。
存储库应返回领域对象——不是 ORM 模型。
如果业务说“Shipment”,不要叫它 DeliveryHandlerService。
PHP 8 并没有自动“添加 DDD”——但它移除了摩擦。
通过:
只读属性
枚举
严格类型
构造函数提升
属性
…在 PHP 中建模丰富的领域终于变得愉快。
值对象保护正确性。聚合根保护不变量。你的系统开始像业务一样行为——而不是数据库包装器。
老实说?
这就是 PHP 不再感觉像“只是一个 web 语言”,而开始感觉像一个严肃的建模工具的时候。
💬 轮到你了你已经在 PHP 8 中尝试过值对象或聚合根吗?
什么有效?
什么感觉多余?
你希望下一个深入探讨什么——存储库、领域服务,还是 PHP 中的事件驱动 DDD?