关于 laravel-dtos
面向 Laravel 开发者的 DTO 编写指南,基于 Spatie Laravel Data 规范数据对象的类型、转换与使用方式。涵盖简单数组转换、复杂数据 Transformer、模型转换及测试工厂。
主要功能
- 01使用 Spatie Laravel Data
- 02通过 from() 转换数组
- 03类型化并嵌套数据对象
- 04为复杂映射使用 Transformer
- 05为 DTO 创建测试工厂
- 06将模型字段转换为 DTO
适用场景
- 01将请求数据传入 Action
- 02转换外部 API 或 webhook 数据
- 03在模型 JSON 字段中存取 DTO
- 04为测试生成填充好的 DTO
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ leeovery/ agentic-skills --skill laravel-dtos安装后在 Claude Code 中输入/laravel-dtos确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/AddressDataFactory.phpAppServiceProvider.phpData.phpDataTestFactory.phpdto-transformers.mdEmailFormatter.phpHasTestFactory.phphelpers.phptest-factories.mdTraceDataFactory.php详细介绍
Laravel DTOs
切勿传递多个基本类型值。 始终将数据封装在 Data 对象中。
相关指南:
- dto-transformers.md - 将外部数据转换为 DTO
- test-factories.md - 为测试创建已填充数据的 DTO
理念
DTO 提供:
- 类型安全和 IDE 自动补全
- 各层之间的清晰契约
- 用于轻松生成测试数据的测试工厂
- 验证集成
- 将请求转换为领域对象
Spatie Laravel Data 包
使用 Spatie Laravel Data。有关包功能,请参阅官方文档。本指南介绍项目特定的模式和偏好。
使用 ::from() 处理数组
对于键名与构造函数属性名称匹配的数组,始终优先使用 ::from()。 让包根据属性类型处理类型转换。
// ✅ PREFERRED - Let package cast automatically
$data = CreateOrderData::from([
'customerEmail' => $request->input('customer_email'),
'deliveryDate' => $request->input('delivery_date'), // String → CarbonImmutable
'status' => $request->input('status'), // String → OrderStatus enum
'items' => $request->collect('items'), // Array → Collection<OrderItemData>
]);
// ❌ AVOID - Manual casting in calling code
$data = new CreateOrderData(
customerEmail: $request->input('customer_email'),
deliveryDate: CarbonImmutable::parse($request->input('delivery_date')),
status: OrderStatus::from($request->input('status')),
items: OrderItemData::collect($request->input('items')),
);
优先使用 ::from() 的原因:
- 包会根据构造函数属性类型自动进行类型转换
- 调用代码更简洁,无需手动转换类型
- 转换行为保持一致
- 充分利用包的全部功能
可以使用 new 的情况:
- 在测试工厂中,你可以控制所有值
- 值已经是正确的类型
- 在 DTO 构造函数内的格式化器中
避免使用大小写映射属性
不要使用 #[MapInputName] 或大小写映射属性。 在调用代码中显式映射字段名称。
// ❌ AVOID - Case mapper attributes on the class
#[MapInputName(SnakeCaseMapper::class)]
class CreateOrderData extends Data
{
public function __construct(
public string $customerEmail, // Auto-maps from 'customer_email'
) {}
}
// ✅ PREFERRED - Explicit mapping in calling code
CreateOrderData::from([
'customerEmail' => $request->input('customer_email'),
]);
避免使用大小写映射器的原因:
- 显式映射更清晰、更易维护
- 不同的 API 版本可能使用不同的字段名称
- 转换器提供了一个集中查看所有映射的位置
- 避免难以追踪的魔法行为
日期会自动转换类型
包会根据属性类型自动将日期字符串转换为 Carbon 或 CarbonImmutable。在包配置中设置预期的日期格式。
// config/data.php
return [
'date_format' => 'Y-m-d H:i:s', // Or ISO 8601: 'Y-m-d\TH:i:s.u\Z'
];
class OrderData extends Data
{
public function __construct(
public CarbonImmutable $createdAt, // Automatically cast from string
public ?CarbonImmutable $shippedAt, // Nullable dates work too
) {}
}
// ✅ Just pass the string - package handles casting
$data = OrderData::from([
'createdAt' => '2024-01-15 10:30:00',
'shippedAt' => null,
]);
基本结构
<?php
declare(strict_types=1);
namespace App\Data;
use App\Enums\OrderStatus;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
/**
* @see \Database\Factories\Data\CreateOrderDataFactory
*/
class CreateOrderData extends Data
{
public function __construct(
public string $customerEmail,
public ?string $notes,
public ?CarbonImmutable $deliveryDate,
public OrderStatus $status,
/** @var Collection<int, OrderItemData> */
public Collection $items,
public ShippingAddressData $shippingAddress,
public BillingAddressData $billingAddress,
) {
// Apply formatters in constructor
$this->customerEmail = EmailFormatter::format($this->customerEmail);
}
}
关键模式
构造函数属性提升
始终使用提升属性:
public function __construct(
public string $name,
public ?string $description,
public bool $active = true,
) {}
不要这样:
public string $name;
public ?string $description;
public function __construct(string $name, ?string $description)
{
$this->name = $name;
$this->description = $description;
}
为所有内容指定类型
public string $email; // Required string
public ?string $phone; // Nullable string
public CarbonImmutable $createdAt; // DateTime (immutable)
public OrderStatus $status; // Enum
public Collection $items; // Collection
public AddressData $address; // Nested DTO
使用 PHPDoc 标注集合
/** @var int[] */
public array $productIds;
/** @var Collection<int, OrderItemData> */
public Collection $items;
嵌套 Data 对象
class OrderData extends Data
{
public function __construct(
public CustomerData $customer,
public ShippingAddressData $shipping,
public BillingAddressData $billing,
/** @var Collection<int, OrderItemData> */
public Collection $items,
) {}
}
格式化器
在构造函数中应用格式化:
public function __construct(
public string $email,
public ?string $phone,
public ?string $postcode,
) {
$this->email = EmailFormatter::format($this->email);
$this->phone = $this->phone ? PhoneFormatter::format($this->phone) : null;
$this->postcode = $this->postcode ? PostcodeFormatter::format($this->postcode) : null;
}
格式化器示例(app/Data/Formatters/EmailFormatter.php):
<?php
declare(strict_types=1);
namespace App\Data\Formatters;
class EmailFormatter
{
public static function format(string $email): string
{
return strtolower(trim($email));
}
}
DTO 上的静态工厂方法
对于较小的应用或刚开始时,可以直接在 DTO 类上添加静态 from* 方法。在复杂度尚未达到需要单独转换器的程度之前,这可以提供类似工厂的行为。
方法命名: from{SourceType} - 例如,fromArray、fromRequest、fromModel
class OrderData extends Data
{
public function __construct(
public string $customerEmail,
public ?string $notes,
public OrderStatus $status,
/** @var Collection<int, OrderItemData> */
public Collection $items,
) {}
public static function fromRequest(CreateOrderRequest $request): self
{
return self::from([
'customerEmail' => $request->input('customer_email'),
'notes' => $request->input('notes'),
'status' => $request->input('status'),
'items' => $request->input('items'),
]);
}
public static function fromModel(Order $order): self
{
return self::from([
'customerEmail' => $order->customer_email,
'notes' => $order->notes,
'status' => $order->status,
'items' => $order->items->toArray(),
]);
}
}
适合在 DTO 上使用静态方法的情况:
- DTO 数量较少的小型应用
- 无需专门测试的简单转换
- 映射与单个 DTO 紧密耦合
适合使用单独转换器的情况:
- 多个外部来源映射到同一个 DTO
- 需要大量测试的复杂转换逻辑
- 关注点分离明确的大型应用
模型转换
将模型 JSON 列转换为 DTO:
class Order extends Model
{
protected function casts(): array
{
return [
'metadata' => OrderMetadataData::class,
'status' => OrderStatus::class,
];
}
}
用法:
// Store
$order = Order::create([
'metadata' => $metadataData, // OrderMetadataData instance
]);
// Retrieve
$metadata = $order->metadata; // Returns OrderMetadataData instance
命名约定
| 类型 | 模式 | 示例 |
|---|---|---|
| 响应 DTO | {Entity}Data | OrderData、UserData、ProductData |
| 请求 DTO | {Action}{Entity}Data | CreateOrderData、UpdateUserData |
| 嵌套 DTO | {Descriptor}{Entity}Data | ShippingAddressData、OrderMetadataData |
目录结构
app/Data/
├── CreateOrderData.php
├── UpdateOrderData.php
├── OrderData.php
├── Concerns/
│ └── HasTestFactory.php
├── Formatters/
│ ├── EmailFormatter.php
│ ├── PhoneFormatter.php
│ └── PostcodeFormatter.php
└── Transformers/
├── PaymentDataTransformer.php
├── Web/
│ └── OrderDataTransformer.php
└── Api/
└── V1/
└── OrderDataTransformer.php
在控制器中使用
控制器通过转换器将请求转换为 DTO:
<?php
declare(strict_types=1);
namespace App\Http\Web\Controllers;
use App\Actions\Order\CreateOrderAction;
use App\Data\Transformers\Web\OrderDataTransformer;
use App\Http\Web\Requests\CreateOrderRequest;
use App\Http\Web\Resources\OrderResource;
class OrderController extends Controller
{
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
): OrderResource {
$order = $action(
user(),
OrderDataTransformer::fromRequest($request)
);
return OrderResource::make($order);
}
}
在 Action 中使用
Action 接受 DTO 作为参数:
class CreateOrderAction
{
public function __invoke(User $user, CreateOrderData $data): Order
{
return DB::transaction(function () use ($user, $data) {
return $user->orders()->create([
'customer_email' => $data->customerEmail,
'notes' => $data->notes,
'status' => $data->status,
]);
});
}
}
转换器
对于复杂的转换(外部 API、Webhook、字段映射),请使用专用的转换器类。
// External system data
$data = PaymentDataTransformer::fromStripePaymentIntent($webhook['data']);
// Request with version-specific field names
$data = OrderDataTransformer::fromRequest($request);
优先级顺序:
Data::from($array)- 简单场景,直接映射Data::fromRequest()- DTO 上的静态方法,适用于较小的应用Transformer::from*()- 复杂转换,涉及多个来源
测试工厂
使用 HasTestFactory trait 创建已填充数据的 DTO 以用于测试。
使用 PHPDoc 将 DTO 关联到工厂:
/**
* @see \Database\Factories\Data\CreateOrderDataFactory
* @method static CreateOrderDataFactory testFactory()
*/
class CreateOrderData extends Data
{
// ...
}
用法:
$data = CreateOrderData::testFactory()->make();
$collection = OrderItemData::testFactory()->collect(count: 5);
// With overrides
$data = CreateOrderData::testFactory()->make([
'customerEmail' => '[email protected]',
]);
测试 DTO
use App\Data\CreateOrderData;
it('can create DTO from array', function () {
$data = CreateOrderData::from([
'customerEmail' => '[email protected]',
'notes' => 'Test notes',
'status' => 'pending',
]);
expect($data)
->customerEmail->toBe('[email protected]')
->notes->toBe('Test notes');
});
it('formats email in constructor', function () {
$data = new CreateOrderData(
customerEmail: ' [email protected] ',
notes: null,
status: OrderStatus::Pending,
);
expect($data->customerEmail)->toBe('[email protected]');
});