DTO必须用readonly class,因为其由PHP引擎在运行时强制拦截所有非法写入,连反射都无法绕过,而普通class仅靠约定或手动校验,易遗漏且可被绕过。

PHP 8.2+ 的 readonly class 是 Laravel 中实现 DTO 防腐层最轻量、最可靠的方式——它不依赖框架逻辑,由 PHP 引擎在运行时强制拦截非法写入,比模型事件或访问器更底层、更不可绕过。
为什么 DTO 必须用 readonly class 而不是普通 class?
DTO 在 Laravel 中常用于 API 请求/响应数据的结构化封装。若用普通 class,哪怕加了 getter、屏蔽 setter,仍可能被直接赋值:$dto->id = 123; —— 这会污染原始数据,破坏防腐意图。而 readonly class 一旦实例化,任何属性写操作都会触发 ReadOnlyPropertyError,连反射都无法绕过(PHP 8.3+ 对反射写入也做了限制)。
- 普通 class:靠约定或手动校验,容易漏、可 bypass
-
readonly class:引擎级防护,错误发生在赋值瞬间,无需额外逻辑 - Laravel 的
toArray()、json_encode()等序列化行为完全兼容只读类
Laravel 中 DTO 声明与使用的真实写法
不要在 DTO 中混用 public 属性和 protected + getter;只读类要求所有属性在构造函数中初始化,且必须显式声明访问修饰符。以下是最简可行模式:
readonly class UserDto
{
public function __construct(
public int $id,
public string $name,
public ?string $email,
) {}
}
- 构造参数直接提升为 public 属性,省去冗余赋值语句
- 支持可空类型(
?string)、联合类型(int|string),与 Laravel 的类型提示无缝衔接 - 禁止在构造函数外写入,包括在
__construct()后追加$this->id = 456; - 若需默认值,可写成
public string $role = 'user';,但注意这属于声明时初始化,非构造注入
与 Laravel 模型交互时的常见踩坑点
DTO 不该承担持久化职责,但开发者常误把它当模型用。典型错误包括:
立即学习“PHP免费学习笔记(深入)”;
- 试图从模型直接赋值:
$dto = new UserDto(...$user->only(['id', 'name', 'email']));——only()返回关联数组,需解构:new UserDto(...$user->only([...]))才合法 - 在控制器中修改 DTO 属性:
$dto->name = strtoupper($dto->name);—— 运行时报错,应新建实例:new UserDto($dto->id, strtoupper($dto->name), $dto->email) - 把 DTO 当作请求验证中间件的载体:Laravel 的
validated()返回数组,不能直接传给只读类构造函数,需先解包 - 忽略构造参数顺序:DTO 构造函数参数顺序必须与调用时严格一致,否则类型安全失效
只读类在测试和调试中的表现差异
PHPUnit 或 Tinker 中调试只读 DTO 时,var_dump($dto) 和 dd($dto) 显示正常,但尝试在 Tinker 中执行 $dto->id = 999 会立刻中断并报错。这点和普通对象不同——它不提供“临时调试修改”的宽容空间,反而迫使你提前暴露设计缺陷。
真正难处理的是嵌套只读结构:比如 UserDto 包含 AddressDto 属性。此时必须确保 AddressDto 本身也是 readonly class,否则防腐链断裂。PHP 不会自动递归保护子对象。



















