constructor promotion将dto属性声明与构造参数绑定为一步操作,仅支持public/protected/private修饰符且不可与同名普通参数混用,不负责校验或默认值处理,需搭配builder模式实现分步构建与约束校验。

Constructor Promotion让DTO属性声明和赋值一步到位
它直接把属性定义、类型声明、构造参数、赋值逻辑压缩进一行,避免DTO类里重复写public string $name和$this->name = $name。比如传统DTO要写四遍字段信息,而PHP 8只需在__construct()参数前加public即可自动提升为属性。
常见错误是误以为它能处理可选参数校验或默认值逻辑——它不负责验证,也不自动补??或??=;它只做“声明即绑定”。如果你的DTO需要email必填但phone可空,Promotion本身不拦你传null,得靠后续校验或类型系统(如string|null联合类型)兜底。
- 支持
public/protected/private,但仅限于__construct()中非抽象方法 - 不能和同名普通参数混用:
function __construct(public string $id, string $raw)合法;function __construct(public string $id, $id)会报错 - 与命名参数天然兼容:调用时可用
new User(name: 'Alice', age: 30),大幅提升可读性
建造者模式补上Promotion缺失的“分步+约束”能力
Constructor Promotion解决了“写得多”,但没解决“传得乱”。当DTO字段超过5个、部分必填部分可选、存在互斥规则(如body和method组合校验)时,光靠Promotion + 构造函数会退化成望远镜地狱。
这时候建造者模式不是替代Promotion,而是叠加使用:DTO本身用Promotion精简内部结构,Builder类负责对外暴露清晰、类型安全的链式入口。关键点在于Builder的每个withXxx()方法返回self,且build()只做最终校验和new DTO(...)实例化。
- Builder中不要存中间状态(如
$this->isBuilt),PHP没有构建后冻结机制,靠类型约束和文档不如靠一次性build()语义 -
build()必须显式抛异常(如throw new InvalidArgumentException('email is required')),不建议静默返回null或stdClass,否则破坏IDE提示和静态分析 - DTO类保持私有构造:
private function __construct(...),强制走Builder,确保不可变性
两者合用时最容易踩的坑
最典型的是把Builder写成“带状态的DTO代理”——比如在Builder里也用Promotion声明一堆public属性,结果导致Builder自身变成可被随意修改的状态容器,违背了“构建过程应专注、隔离”的初衷。
另一个隐蔽问题是类型推导断裂:如果DTO用Promotion声明public DateTimeImmutable $createdAt,但Builder的withCreatedAt()接受string|DateTimeInterface,PHPStan或Psalm可能无法准确追踪最终类型,导致build()返回的DTO实例类型变弱。
- Builder方法参数类型尽量和DTO属性类型一致,必要时用联合类型(如
string|int|null)但需在build()里归一化 - 避免在Builder中复用DTO的Promotion参数名做属性——Builder应只持有原始输入值,而非模拟DTO结构
- 如果DTO字段含复杂嵌套(如
array{url:string, timeout:int}),别试图用Promotion直接声明,改用独立VO类+Promotion,再由Builder组装
一个真实可用的最小闭环示例
假设你要建一个PaymentRequest DTO,含amount(必填)、currency(默认'USD')、metadata(可选数组):
class PaymentRequest
{
public function __construct(
public int $amount,
public string $currency = 'USD',
public ?array $metadata = null,
) {}
}
class PaymentRequestBuilder
{
private int $amount;
private string $currency = 'USD';
private ?array $metadata = null;
public function withAmount(int $amount): self
{
$this->amount = $amount;
return $this;
}
public function withCurrency(string $currency): self
{
$this->currency = $currency;
return $this;
}
public function withMetadata(array $metadata): self
{
$this->metadata = $metadata;
return $this;
}
public function build(): PaymentRequest
{
if (!isset($this->amount)) {
throw new InvalidArgumentException('amount is required');
}
return new PaymentRequest($this->amount, $this->currency, $this->metadata);
}
}
注意build()里没用??兜底,因为DTO自身已有默认值;Builder只管收、校、传,不越界处理业务逻辑。这种分工一旦模糊,就容易滑向“Builder越来越重,DTO越来越轻”的失衡状态。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











