mcpField
L
laravel-dtos

laravel-dtos

github.com/leeovery开发1549 次安装2 周前更新
代码仓库

关于 laravel-dtos

面向 Laravel 开发者的 DTO 编写指南,基于 Spatie Laravel Data 规范数据对象的类型、转换与使用方式。涵盖简单数组转换、复杂数据 Transformer、模型转换及测试工厂。

主要功能

  1. 01使用 Spatie Laravel Data
  2. 02通过 from() 转换数组
  3. 03类型化并嵌套数据对象
  4. 04为复杂映射使用 Transformer
  5. 05为 DTO 创建测试工厂
  6. 06将模型字段转换为 DTO

适用场景

  1. 01将请求数据传入 Action
  2. 02转换外部 API 或 webhook 数据
  3. 03在模型 JSON 字段中存取 DTO
  4. 04为测试生成填充好的 DTO

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/leeovery/agentic-skills --skill laravel-dtos

安装后在 Claude Code 中输入/laravel-dtos确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
AddressDataFactory.php
AppServiceProvider.php
Data.php
DataTestFactory.php
dto-transformers.md
EmailFormatter.php
HasTestFactory.php
helpers.php
test-factories.md
TraceDataFactory.php

详细介绍

Laravel DTOs

切勿传递多个基本类型值。 始终将数据封装在 Data 对象中。

相关指南:

理念

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}DataOrderData、UserData、ProductData
请求 DTO{Action}{Entity}DataCreateOrderData、UpdateUserData
嵌套 DTO{Descriptor}{Entity}DataShippingAddressData、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、字段映射),请使用专用的转换器类。

→ 完整指南:dto-transformers.md

// External system data
$data = PaymentDataTransformer::fromStripePaymentIntent($webhook['data']);

// Request with version-specific field names
$data = OrderDataTransformer::fromRequest($request);

优先级顺序:

  1. Data::from($array) - 简单场景,直接映射
  2. Data::fromRequest() - DTO 上的静态方法,适用于较小的应用
  3. Transformer::from*() - 复杂转换,涉及多个来源

测试工厂

使用 HasTestFactory trait 创建已填充数据的 DTO 以用于测试。

→ 完整指南:test-factories.md

使用 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]');
});

评论

支持 Markdown