| name | webman-tech-dto-best-practices |
| description | webman-tech/dto 最佳实践。使用场景:用户使用 WebmanTech\DTO 相关类时,给出明确的推荐用法。 |
webman-tech/dto 最佳实践
核心原则
- 让 PHP 类型声明做验证,不要重复写
#[ValidationRules]
- 只标注例外,默认行为已经够用
- 验证失败是用户错误,类型错误是代码错误,两者分开处理
选择基类
需要从 HTTP 请求取数据? → BaseRequestDTO
需要返回 HTTP 响应? → BaseResponseDTO
需要读取应用配置? → BaseConfigDTO
只是嵌套数据结构? → BaseDTO
用生成器快速起步
packages/dto/web/index.html 是一个离线 Web 工具,粘贴 JSON 数据即可生成 DTO 骨架,详见 references/generator.md。
手写代码规范
以下规范与生成器保持一致,手写时应遵守。
所有 DTO 类都用 final
final class CreateOrderForm extends BaseRequestDTO {}
class CreateOrderForm extends BaseRequestDTO {}
命名约定决定基类
| 类名后缀 | 基类 |
|---|
XxxForm | BaseRequestDTO |
XxxFormResult | BaseResponseDTO |
XxxConfig / XxxConfigDTO | BaseConfigDTO |
| 其他 | BaseDTO |
BaseRequestDTO 要有 handle() 方法
final class CreateOrderForm extends BaseRequestDTO
{
public string $title;
public int $amount;
public function handle(): CreateOrderFormResult
{
}
}
BaseResponseDTO 用构造函数属性提升
final class CreateOrderFormResult extends BaseResponseDTO
{
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly string|null $remark = null,
) {}
}
final class CreateOrderForm extends BaseRequestDTO
{
public string $title;
public int $amount;
}
可选字段写 Type|null $field = null
public string|null $remark = null;
public ?string $remark = null;
嵌套类命名:{Parent}{Key} 和 {Parent}{Key}Item
final class CreateOrderForm extends BaseRequestDTO
{
public CreateOrderFormAddress $address;
public array $items;
}
final class CreateOrderFormAddress extends BaseDTO { ... }
final class CreateOrderFormItemsItem extends BaseDTO { ... }
BaseRequestDTO — 推荐写法
类型声明即验证规则,不要重复写
class CreateUserRequest extends BaseRequestDTO
{
public string $name;
public int $age;
public ?string $bio = null;
public StatusEnum $status;
public AddressDTO $address;
}
class CreateUserRequest extends BaseRequestDTO
{
#[ValidationRules(required: true, string: true)]
public string $name;
}
只在需要额外约束时加注解
class CreateUserRequest extends BaseRequestDTO
{
#[ValidationRules(rules: 'email')]
public string $email;
#[ValidationRules(min: 1, max: 120)]
public int $age;
#[ValidationRules(minLength: 2, maxLength: 50)]
public string $name;
}
数组类型:用 docblock,不用注解
class OrderRequest extends BaseRequestDTO
{
public array $items;
public array $tags;
}
class OrderRequest extends BaseRequestDTO
{
#[ValidationRules(arrayItem: OrderItemRequest::class)]
public array $items;
}
RequestPropertyIn:只标注例外来源
默认行为已覆盖 90% 场景(GET→query,POST json→json body,POST form→form body),
只在字段来自非默认位置时才加注解:
class SearchRequest extends BaseRequestDTO
{
public string $keyword;
#[RequestPropertyInHeader(name: 'X-Tenant-Id')]
public string $tenantId;
#[RequestPropertyInPath]
public int $userId;
}
class SearchRequest extends BaseRequestDTO
{
#[RequestPropertyInQuery]
public string $keyword;
}
跨字段验证:用 getExtraValidationRules
class RegisterRequest extends BaseRequestDTO
{
public string $password;
public string $passwordConfirm;
protected static function getExtraValidationRules(): array
{
return ['passwordConfirm' => 'same:password'];
}
}
异常处理:两种异常含义不同
try {
$dto = CreateUserRequest::fromRequest();
} catch (DTOValidateException $e) {
return json(['errors' => $e->getErrors()], 422);
}
BaseResponseDTO — 推荐写法
保持结构一致性,不要默认 ignoreNull
class UserResponse extends BaseResponseDTO
{
public int $id;
public string $name;
public ?string $bio = null;
}
#[ToArrayConfig(ignoreNull: true)]
class UserResponse extends BaseResponseDTO { ... }
ignoreNull: true 只在明确需要精简输出时使用,例如稀疏数据、动态字段场景。
敏感字段用 exclude
#[ToArrayConfig(exclude: ['password', 'token'])]
class UserResponse extends BaseResponseDTO
{
public int $id;
public string $name;
public string $password;
}
BaseConfigDTO — 推荐写法
用构造函数定义默认值,getAppConfig 读配置文件
class SwaggerConfig extends BaseConfigDTO
{
public function __construct(
public string $title = 'API Docs',
public bool $enabled = true,
public array $servers = [],
) {}
protected static function getAppConfig(): array
{
return config('plugin.swagger.app', []);
}
}
$config = SwaggerConfig::fromConfig();
$config = SwaggerConfig::fromConfig(['title' => 'My API']);
注意:列表数组是追加合并,不是覆盖
FromDataConfig — 全局配置优于逐类注解
不要在每个 DTO 上加 #[FromDataConfig],在配置文件统一设置:
return [
'from_data_config' => [
'request' => ['trim' => true],
],
];
只有当某个 DTO 需要与全局配置不同的行为时,才在类上加注解覆盖。
嵌套类型
直接用 PHP 类型声明,框架自动递归验证和转换:
class AddressDTO extends BaseDTO
{
public string $city;
public string $street;
}
class OrderRequest extends BaseRequestDTO
{
public string $title;
public AddressDTO $address;
public ?AddressDTO $billing = null;
public array $extraAddresses;
}
传入数据:
{
"title": "order1",
"address": { "city": "Beijing", "street": "Chaoyang" },
"billing": null,
"extraAddresses": [
{ "city": "Shanghai", "street": "Pudong" }
]
}
$req->address 直接是 AddressDTO 实例,$req->extraAddresses 是 AddressDTO[],无需手动转换。
多态类型(进阶)
当一个字段的类型取决于另一个字段的值时使用:
class ShipmentRequest extends BaseRequestDTO
{
public string $type;
#[ValidationRules(nullable: true, discriminator: [
'property' => 'type',
'mapping' => [
'normal' => NormalShipmentDTO::class,
'express' => ExpressShipmentDTO::class,
],
])]
public NormalShipmentDTO|ExpressShipmentDTO|null $detail = null;
}
完整控制器示例
class CreateOrderRequest extends BaseRequestDTO
{
public string $title;
#[ValidationRules(min: 1, max: 9999)]
public int $amount;
public array $items;
#[RequestPropertyInHeader(name: 'X-Tenant-Id')]
public string $tenantId;
}
class OrderItemRequest extends BaseDTO
{
public string $sku;
public int $qty;
}
class CreateOrderResponse extends BaseResponseDTO
{
public int $id;
public string $status;
public ?string $remark = null;
}
class OrderController
{
public function create(): mixed
{
try {
$req = CreateOrderRequest::fromRequest();
} catch (DTOValidateException $e) {
return json(['errors' => $e->getErrors()], 422);
}
$order = OrderService::create($req);
$resp = new CreateOrderResponse();
$resp->id = $order->id;
$resp->status = $order->status;
return $resp->toResponse();
}
}
常见错误
| 错误 | 原因 | 解决 |
|---|
| 枚举字段报错 | 使用了 UnitEnum | 改用 BackedEnum(enum Foo: string) |
| 嵌套 DTO 字段为空时报错 | 字段非 nullable | 改为 ?NestedDTO $field = null |
| 数组元素类型不转换 | 缺少类型声明 | 加 docblock @var Foo[] |
| 验证通过但字段值错误 | 类型声明与传入数据不匹配 | 检查 PHP 类型声明是否与预期一致 |
DTONewInstanceException | 代码 bug,不是用户错误 | 不要 catch,让它暴露 |