☰
PHP 8 实战:用 DTO 构建清晰的数据边界,告别数组传参
2026/10/1 15:30:04 网站建设 项目流程

1. 为什么 PHP 项目里要用 DTO:先看清楚痛点

说到 DTO,很多 PHP 开发者的第一反应是“这不就是个类嘛,我平时不也在用”。但真要在团队里落地一套成熟的 DTO 方案,大部分人又会犹豫:到底有没有必要?会不会过度设计?

先说结论,如果你的项目还在用数组在接口层、业务层、数据层之间来回传数据,那大概率已经踩过下面这些坑。

1.1 数组传参的三个隐患

第一个隐患是键名拼错毫无提示。我们口头约定的“user_name”“userName”“username”在代码里全靠自觉,IDE 不会帮你检查,参数到了业务层才发现键不存在,排查成本不低。

第二个隐患是类型完全失控。数组里放的是字符串还是整数、是 null 还是空字符串,从代码上根本看不出来。写 PHP 时间久的人都有过这种体验:接口返回的数据,明明前端要的是数字,结果上层取出个字符串,JS 那边比较半天对不上。

第三个隐患是边界模糊。一个接口从 Controller 到 Service 到 Model,全程数组贯穿。模型字段一变,几层代码全部失效,而且失败是静默的,没有任何编译期或运行期的明确报错。

1.2 用 DTO 之后发生了什么

DTO(Data Transfer Object,数据传输对象)的核心思路很简单:在层与层之间传递数据时,用一个显式的类来定义"数据的形状"。

举个例子,过去你可能是这么写的:

$data = [ 'uid' => 123, 'name' => '张三', 'age' => 28, ];

用了 DTO 之后是这么写的:

$user = new UserDTO( uid: 123, name: '张三', age: 28, );

看起来只是换了个写法,但实际差别很大。第一,IDE 可以识别 UserDTO 的属性,拼错字段名会直接报错;第二,构造函数参数有类型声明,传错类型马上能发现;第三,DTO 是一个独立类,你可以单独给它写单元测试,业务代码不再依赖数组的隐式约定。

这篇文章不是讲高大上的架构理论,就是实战。我会从零开始写 DTO,讲清楚设计思路、PHP 8 特性怎么用、和 Entity/VO 的区别,再分享几个我实际踩过的坑。适合从 PHP 7 过渡到 PHP 8 的团队,以及那些想规范接口数据、想让业务代码不再被数组绑架的同学。

2. 手写一个能用的 PHP DTO:从基础到进阶

这一章直接上代码。我默认你用的是 PHP 8.0 以上版本,最好是 PHP 8.1 或 8.2,因为构造器属性提升、readonly、enum 这些特性会让 DTO 写起来非常爽。

2.1 基础版:构造函数复制型 DTO

最朴素的 DTO 长这样:

class UserDTO { public int $uid; public string $name; public int $age; public function __construct(int $uid, string $name, int $age) { $this->uid = $uid; $this->name = $name; $this->age = $age; } }

然后这样实例化:

$user = new UserDTO(123, '张三', 28);

问题来了:如果参数特别多,调用方根本分不清第三个参数是 age 还是 gender。PHP 8 的命名参数可以解决这个问题:

$user = new UserDTO( uid: 123, name: '张三', age: 28 );

命名参数的可读性提升非常明显,尤其 DTO 字段多的时候,别人看代码时不需要再去翻类定义。

如果你用的 PHP 8.0 及以上,构造器属性提升可以让 DTO 精简到极致:

class UserDTO { public function __construct( public int $uid, public string $name, public int $age, ) {} }

到这里,一个最基础的 DTO 就完成了。注意,属性提升里的public是必须的,如果你写成private或者protected,外部无法直接访问属性,DTO 作为"传输对象"的意义就丢了。

2.2 进阶:给 DTO 加上 fromArray 和 toArray

基础版的问题在于:数据库查询返回的是数组,请求参数也是数组,你总不能每处都手写new UserDTO($row['uid'], $row['name'], ...),字段一多能写到怀疑人生。

静态工厂方法是常见解法。从一个关联数组构造 DTO:

class UserDTO { public function __construct( public int $uid, public string $name, public int $age, ) {} public static function fromArray(array $data): self { return new self( uid: (int)($data['uid'] ?? 0), name: (string)($data['name'] ?? ''), age: (int)($data['age'] ?? 0), ); } public function toArray(): array { return get_object_vars($this); } }

fromArray负责把散乱的数组"洗"成结构化的对象,toArray负责把对象还原成数组,方便写入缓存、传给模板或者兼容老代码。

这里有两个细节值得展开。

第一,??空合并运算符配合默认值,可以让 DTO 对缺失字段"宽容但不放纵"。缺字段时给个安全默认值,比直接报错更适合接口输入这种不可控场景。

第二,get_object_vars在 PHP 8 里返回的是当前作用域下的可见属性。因为构造器属性提升定义的属性是 public,所以能拿到全部字段。如果你混入了private辅助属性,它不会被带出来,这其实是个隐性的好处。

实际项目中,我更建议把toArray写得更显式一些,比如:

public function toArray(): array { return [ 'uid' => $this->uid, 'name' => $this->name, 'age' => $this->age, ]; }

显式的好处是字段重命名时你能看到全貌。比如前端要求的字段名从 name 变成 nickname,你只需要改toArray的键。

2.3 嵌套 DTO 与复杂数据结构的处理

真实业务里几乎没有"只有三个字段"这么理想的情况。常见的是用户在订单里,订单里有商品列表,商品里还有分类信息。

DTO 最好的处理方式就是嵌套,而不是摊平。

class OrderItemDTO { public function __construct( public int $goodsId, public string $goodsName, public int $price, public int $qty, ) {} } class OrderDTO { /** * @param OrderItemDTO[] $items */ public function __construct( public string $orderNo, public int $uid, public array $items, ) {} }

注意,我在构造参数上加了 PHPDoc 注解说明$items是OrderItemDTO[]。PHP 本身支持数组类型,但没法声明"这个数组里装的全是某个类",所以 PHPDoc 是实际情况下的折中方案。

嵌套 DTO 在组装时比较麻烦,建议在fromArray里处理子集:

public static function fromArray(array $data): self { return new self( orderNo: (string)($data['order_no'] ?? ''), uid: (int)($data['uid'] ?? 0), items: array_map( fn($item) => OrderItemDTO::fromArray($item), (array)($data['items'] ?? []) ), ); }

array_map配合箭头函数,每个子数组自动转成子 DTO。这里我用的是order_no而不是orderNo,因为数据库字段通常都是 snake_case,DTO 属性坚持 camelCase,中间由fromArray做一次映射,这是 PHP 生态里比较舒服的约定。

2.4 readonly 与不可变性:给 DTO 上把锁

PHP 8.1 引入了 readonly 属性,对 DTO 来说几乎是量身定做的特性。

class UserDTO { public function __construct( public readonly int $uid, public readonly string $name, public readonly int $age, ) {} }

readonly的意思是:属性只能在构造函数里赋值一次,之后不能再改。这解决了 DTO 一个很实际的问题,防止别人在业务代码里把 DTO 当临时变量到处改。

我见过不少团队,DTO 本来是传输用的,结果业务层为了省事,直接$dto->name = '新名字',类里还写着private+getter/setter,最后 DTO 变成了一堆行为不明确的"小实体"。

用了 readonly 之后,想改?可以,重新 new 一个 DTO:

$newUser = new UserDTO( uid: $user->uid, name: '新名字', age: $user->age, );

这样做的最直接好处是,DTO 的整个生命周期里,它的状态是确定的。后面在复杂业务流程中,数据传了多少层、被谁看了一眼,都不可怕,因为没人能改动它。

有个坑需要提醒:readonly属性不能有默认值,不能在构造函数外面赋默认值。所以如果你需要"可选字段",默认值请写在fromArray的空合并那一步。

2.5 用 enum 取代魔法字符串

PHP 8.1 的 enum(枚举)同样是 DTO 的好搭档,特别是那些"状态""类型"字段。以前你可能是这么写的:

public string $status; // 值可能是 'pending' 'paid' 'canceled'

在任何一处都可能拼错,拼错之后还没有任何报错,直到前端发现状态不对。

用了 enum 之后:

enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Canceled = 'canceled'; } class OrderDTO { public function __construct( public string $orderNo, public OrderStatus $status, ) {} }

这里$status的类型不再是 string,而是 OrderStatus。外部传入非法的状态值会直接抛TypeError,不可能再拼错字符串。

fromArray里对应转换:

$status = OrderStatus::tryFrom((string)($data['status'] ?? '')) ?? OrderStatus::Pending;

tryFrom尝试把字符串转成枚举,转不了就返回 null,再通过??给一个默认值。处理脏数据时这个组合很常用。

3. DTO、VO、Entity 到底差在哪:一次讲清楚

很多 PHP 开发者包括一些架构师,经常把 DTO、VO、Entity 混为一谈。面试时每次聊到这块,十个人里有六七个表达得比较模糊。这很正常,因为这几个概念在 Java 世界里分层清晰,到了 PHP 这种灵活的语言里,边界确实容易模糊。

3.1 从数据角色看四种对象

名称英文全称核心用途典型场景是否携带行为
DTOData Transfer Object跨层传递数据Controller 到 Service、Service 到外部接口否
VOValue Object / View Object承载展示层数据渲染页面、响应 JSON否
EntityEntity领域模型的核心业务逻辑操作、持久化关联是
POPersistent Object数据库表映射ORM 模型类否或少量

最重要的一句话:Entity 是有行为的领域对象,DTO/VO 是纯粹的数据壳。

3.2 从真实代码看 Entity 和 DTO 的区别

假设你在写一个订单模块。Entity 可以长这样:

class Order { public function __construct( private int $id, private string $orderNo, private int $status, private float $amount, ) {} public function canRefund(): bool { return $this->status === 2 && $this->amount > 0; } public function markRefunded(): void { $this->status = 3; } }

Entity 里的canRefund、markRefunded承载了业务规则。Controller 和 Service 层操作的是 Order 的行为,而不是直接改它的字段。

DTO 则完全不同:

class OrderDTO { public function __construct( public readonly int $id, public readonly string $orderNo, public readonly int $status, public readonly float $amount, ) {} }

DTO 里没有任何行为逻辑,它就是拿来装数据、传数据的。

那么 VO 呢?

class OrderVO { public function __construct( public readonly string $orderNo, public readonly string $statusText, ) {} }

VO 往往会在 DTO 基础上再做一次"面向展示的加工"。比如 DTO 里的status是 int 2,VO 里的statusText是 "已支付",amount_formatted是 "299.00 元",这些就是典型的视图模型做的事。有些团队直接拿 DTO 渲染,也能跑,但遇到复杂展示逻辑时,VO 会整洁很多。

我这几年做项目的体会是:小项目不用强行分 VO 和 DTO,一个 DTO 管到底没毛病;但 Entity 必须和 DTO 分开,尤其是用了 ORM 的团队,直接在 Controller 里把 Model 丢给前端,隐患太大——暴露了不该暴露的字段,还让前端依赖上数据库结构,后面一旦重构就是灾难。

4. 实战:DTO 在接口层和数据层的落地方案

说了这么多概念,这一章我们看几个可以直接抄作业的实战场景。

4.1 API 请求参数转 DTO

后端接收前端传参最常用的场景。比如有一个创建用户的接口,前端 POST 过来的 JSON 是这样的:

{ "name": "李四", "email": "lisi@example.com", "age": 30 }

用 ThinkPHP 或者 Laravel,通常在 Controller 里拿到的是$request->all(),也就是一个数组。推荐的做法是:先做数据校验,再转 DTO,最后交给 Service。

// Controller public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:50', 'email' => 'required|email', 'age' => 'required|integer|min:1|max:150', ]); $dto = CreateUserDTO::fromArray($validated); $this->userService->createUser($dto); }

这里有几层意思。校验通过后,$validated数组里的字段已经被框架过滤过、格式检查过了,再交给fromArray,等于给 DTO 上了一道双保险。如果连 DTO 都防御不了的类型问题,说明校验规则有遗漏。

CreateUserDTO 的定义:

class CreateUserDTO { public function __construct( public readonly string $name, public readonly string $email, public readonly int $age, ) {} }

你看,Service 层接收的一定是一个完整、合法、类型正确的对象。Service 里面不需要再到处写"如果 $name 为空"之类的判断,代码会干净很多。

4.2 从 ORM 模型转 DTO

从数据库查出来的模型对象,往 DTO 转时,最忌讳的是先->toArray()再fromArray(),多了一次中间态、多了一次拷贝。

正确的用法是直接读取模型属性构造 DTO:

class UserService { public function getUserProfile(int $uid): UserProfileDTO { $user = User::query()->find($uid); if (!$user) { throw new UserNotFoundException(); } return new UserProfileDTO( uid: $user->id, name: $user->name, email: $user->email, createdAt: $user->created_at->toDateTimeString(), ); } }

这里直接用$user->id、$user->name这些模型属性来构造 DTO,不需要经过数组中转。这么做有两个好处:一是不用担心模型里带出来的多余字段(比如密码哈希)被意外泄漏;二是 DTO 的字段名和数据库字段名解耦,你完全可以在 DTO 里用createdAt,底层数据库叫created_at。

如果有嵌套关联,比如用户查出来还要带上他最近的三条订单,推荐的方式是先用 ORM 关联查询把数据取齐,再统一组装:

$user = User::query() ->with(['recentOrders' => fn($q) => $q->limit(3)]) ->find($uid); $dto = new UserProfileDTO( uid: $user->id, name: $user->name, recentOrders: array_map( fn(Order $order) => new OrderDTO( orderNo: $order->order_no, amount: (float)$order->amount, ), $user->recentOrders->all() ), );

重点:务必把数组转换成 DTO 放在预加载之后,不要在循环里执行数据库查询。你可能觉得这是常识,但我真见过不少朋友在循环里写查询,一次接口几十条 SQL,慢得要命。

4.3 列表接口的批量 DTO 组装

列表接口通常是这样的:

public function listUsers(): array { $users = User::query()->paginate(20); $list = array_map( fn(User $user) => new UserDTO( uid: $user->id, name: $user->name, email: $user->email, ), $users->items() ); return [ 'total' => $users->total(), 'list' => $list, ]; }

array_map批量把一个模型的 collection 映射成 DTO 数组。这里我用了UserDTO而不直接用模型,原因在于列表接口往往只暴露部分字段,如果直接把模型塞回去,等于把所有字段无差别丢给前端,安全问题只是一方面,更重要的是中后台接口会越来越"肥胖",前端拿到的永远比用到的多。

分页这种大数据量场景还应注意内存。paginate(20)本身是分页查询,不受影响。但如果你一次性查 10 万条再array_map,内存会瞬间暴涨,所以列表 DTO 只应用于分页或限流后的结果集。

5. 踩坑记录:DTO 用不好反而更糟

最后分享几个我在实际项目中踩过的坑,以及团队引入 DTO 时最容易走的弯路。

5.1 DTO 不是越多越好

有一种滥用方式是把 Service 内部每一步都拆一个 DTO。比如第一步临时数据DTO、第二步中间结果DTO、第三步组装完成DTO。说实话,这样做只会让代码更啰嗦,review 的人还要来回跳转类定义。

我的原则是:DTO 只出现在"边界"上。什么是边界?Controller 和 Service 之间是边界,Service 和外部接口/网关之间是边界,Service 和 Repository/Model 返回数据之间是边界。边界之内的数据处理,老老实实用基础变量和数组,别为了 DTO 而 DTO。

5.2 字段映射的蛇形与驼峰之争

很多团队折在字段命名上。数据库字段是user_name,前端接口要求userName,DTO 属性到底用哪个?

我的建议是:DTO 内部属性用 camelCase,fromArray入口处接收 snake_case,toArray出口处再按业务要求转换为 camelCase。这样一个 DTO 把两层命名都消化干净。

public static function fromArray(array $data): self { return new self( userName: (string)($data['user_name'] ?? ''), userEmail: (string)($data['user_email'] ?? ''), ); } public function toArray(): array { return [ 'userName' => $this->userName, 'userEmail' => $this->userEmail, ]; }

千万不要一会儿$this->user_name一会儿$this->userName,混着写就是给自己埋雷。

5.3 校验逻辑别塞进 DTO

一个常见误区是把"这个字段必填""那个值不能小于多少"之类的验证规则也写进fromArray。你可以做基本的类型强转和默认值处理,但业务规则校验应该交给独立的校验器,比如 ThinkPHP 的 Validate 类或 Laravel 的 FormRequest。

为什么?因为 DTO 有两个职责之外的禁忌:一是承载行为,二是承载校验规则。一旦 DTO 开始管业务规则,它就不再是简单的传输对象,你会发现在不同接口里同一个 DTO 的校验规则互相打架,改一处牵一发动全身。

正确的取舍是:校验在入口做,传输由 DTO 管。拿到 DTO 的人默认它已经是合法数据,没必要再防一遍;真防不住,说明入口校验有漏洞,修入口而不是在 DTO 里打补丁。

5.4 别让 DTO 泄漏到数据库层

我见过一个团队把 DTO 直接传给 Repository 的save方法,代码大概是这样:

$repository->save($userDTO);

Repository 层接收 DTO 本身没问题,但直接在内部把 DTO 属性赋给 Model:

$model->name = $dto->name;

短期能跑,但问题是 Repository 的接口被 DTO"钉死"了。一旦 DTO 重构,比如字段改名、结构调整,Repository 层的改动成本会非常大。

更稳妥的做法是,Repository 层只依赖基础数据类型或者模型自身,由 Service 负责把 DTO 转换成 Repository 需要的形态。分层清晰了,DTO 才不会变成一条贯穿全局的"铁索",把各层全绑在一起。

我个人做了这么多年 PHP 项目,从纯数组传参到全面引入 DTO,最大的体会是:DTO 的价值不在于类本身多高级,而在于它帮你把数据边界固定下来,让每个人都知道"到这里数据长什么样、该有什么字段"。它就像接口对外承诺的数据契约,层与层之间的谈话终于有了一个大家都认的准话。

最后再分享一个小技巧:写 DTO 时,把fromArray、toArray、构造器提升、readonly 全部配合起来,几十行代码就能实现一个完整可用的 DTO,而且不需要引入任何第三方库。真到了要序列化成 JSON 的场景,直接json_encode($dto),只要属性是 public 就能正常工作;如果还需要更定制化的输出格式,实现一下JsonSerializable接口,就能精确控制 JSON 里哪些字段出现、用什么字段名。这套组合拳打下来,团队里每个人写出来的 DTO 风格都会高度统一,review 代码时也省心得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询