laravel-dtos

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Laravel DTOs

Laravel DTOs

Never pass multiple primitive values. Always wrap data in Data objects.
Related guides:
  • dto-transformers.md - Transform external data into DTOs
  • test-factories.md - Create hydrated DTOs for tests
永远不要传递多个原始值,始终将数据封装在数据对象中。
相关指南:
  • dto-transformers.md - 将外部数据转换为DTO
  • test-factories.md - 为测试创建已填充数据的DTO

Philosophy

设计理念

DTOs provide:
  • Type safety and IDE autocomplete
  • Clear contracts between layers
  • Test factories for easy test data generation
  • Validation integration
  • Transformation from requests to domain objects
DTO 具备以下优势:
  • 类型安全与IDE自动补全
  • 清晰的层间契约
  • 测试工厂,轻松生成测试数据
  • 验证集成
  • 从请求到领域对象的转换能力

Spatie Laravel Data Package

Spatie Laravel Data 包

Uses Spatie Laravel Data. Refer to official docs for package features. This guide covers project-specific patterns and preferences.
使用 Spatie Laravel Data。包的功能请参考官方文档,本指南涵盖项目特定的模式与偏好。

Use
::from()
with Arrays

结合数组使用
::from()

Always prefer
::from()
with arrays where keys match constructor property names. Let the package handle casting based on property types.
php
// ✅ 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')),
);
Why prefer
::from()
:
  • Package handles type casting automatically based on constructor property types
  • Cleaner calling code without manual casting
  • Consistent transformation behavior
  • Leverages the full power of the package
When
new
is acceptable:
  • In test factories where you control all values
  • When values are already the correct type
  • In formatters inside the DTO constructor
始终优先使用
::from()
配合与构造函数属性名匹配的数组。让包根据属性类型自动处理类型转换。
php
// ✅ 推荐 - 让包自动处理类型转换
$data = CreateOrderData::from([
    'customerEmail' => $request->input('customer_email'),
    'deliveryDate' => $request->input('delivery_date'),  // 字符串 → CarbonImmutable
    'status' => $request->input('status'),               // 字符串 → OrderStatus 枚举
    'items' => $request->collect('items'),               // 数组 → Collection<OrderItemData>
]);

// ❌ 避免 - 在调用代码中手动转换
$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构造函数内的格式化器中

Avoid Case Mapper Attributes

避免使用大小写映射器属性

Don't use
#[MapInputName]
or case mapper attributes.
Map field names explicitly in calling code.
php
// ❌ 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'),
]);
Why avoid case mappers:
  • Explicit mapping is clearer and more maintainable
  • Different API versions may have different field names
  • Transformers provide a single place to see all mappings
  • Avoids magic behavior that's hard to trace
不要使用
#[MapInputName]
或大小写映射器属性
,在调用代码中显式映射字段名。
php
// ❌ 避免 - 在类上使用大小写映射器属性
#[MapInputName(SnakeCaseMapper::class)]
class CreateOrderData extends Data
{
    public function __construct(
        public string $customerEmail,    // 自动映射自 'customer_email'
    ) {}
}

// ✅ 推荐 - 在调用代码中显式映射
CreateOrderData::from([
    'customerEmail' => $request->input('customer_email'),
]);
避免大小写映射器的原因:
  • 显式映射更清晰、更易于维护
  • 不同API版本可能有不同的字段名
  • 转换器提供了查看所有映射的统一位置
  • 避免难以追踪的“魔法”行为

Date Casting is Automatic

日期转换自动完成

The package automatically casts date strings to
Carbon
or
CarbonImmutable
based on property types. Configure the expected date format in the package config.
php
// config/data.php
return [
    'date_format' => 'Y-m-d H:i:s',  // Or ISO 8601: 'Y-m-d\TH:i:s.u\Z'
];
php
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,
]);
包会根据属性类型自动将日期字符串转换为
Carbon
CarbonImmutable
实例。可在包配置文件中设置预期的日期格式。
php
// config/data.php
return [
    'date_format' => 'Y-m-d H:i:s',  // 或 ISO 8601: 'Y-m-d\TH:i:s.u\Z'
];
php
class OrderData extends Data
{
    public function __construct(
        public CarbonImmutable $createdAt,   // 自动从字符串转换
        public ?CarbonImmutable $shippedAt,  // 可空日期同样支持
    ) {}
}

// ✅ 直接传入字符串即可 - 包会处理转换
$data = OrderData::from([
    'createdAt' => '2024-01-15 10:30:00',
    'shippedAt' => null,
]);

Basic Structure

基础结构

php
<?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);
    }
}
php
<?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,
    ) {
        // 在构造函数中应用格式化器
        $this->customerEmail = EmailFormatter::format($this->customerEmail);
    }
}

Key Patterns

核心模式

Constructor Property Promotion

构造函数属性提升

Always use promoted properties:
php
public function __construct(
    public string $name,
    public ?string $description,
    public bool $active = true,
) {}
Not:
php
public string $name;
public ?string $description;

public function __construct(string $name, ?string $description)
{
    $this->name = $name;
    $this->description = $description;
}
始终使用属性提升语法:
php
public function __construct(
    public string $name,
    public ?string $description,
    public bool $active = true,
) {}
不要这样写:
php
public string $name;
public ?string $description;

public function __construct(string $name, ?string $description)
{
    $this->name = $name;
    $this->description = $description;
}

Type Everything

为所有属性声明类型

php
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
php
public string $email;                          // 必填字符串
public ?string $phone;                         // 可空字符串
public CarbonImmutable $createdAt;             // 不可变日期时间
public OrderStatus $status;                    // 枚举类型
public Collection $items;                      // 集合
public AddressData $address;                   // 嵌套DTO

Collections with PHPDoc

带PHPDoc注释的集合

php
/** @var int[] */
public array $productIds;

/** @var Collection<int, OrderItemData> */
public Collection $items;
php
/** @var int[] */
public array $productIds;

/** @var Collection<int, OrderItemData> */
public Collection $items;

Nested Data Objects

嵌套数据对象

php
class OrderData extends Data
{
    public function __construct(
        public CustomerData $customer,
        public ShippingAddressData $shipping,
        public BillingAddressData $billing,
        /** @var Collection<int, OrderItemData> */
        public Collection $items,
    ) {}
}
php
class OrderData extends Data
{
    public function __construct(
        public CustomerData $customer,
        public ShippingAddressData $shipping,
        public BillingAddressData $billing,
        /** @var Collection<int, OrderItemData> */
        public Collection $items,
    ) {}
}

Formatters

格式化器

Apply formatting in the constructor:
php
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;
}
Example formatter (
app/Data/Formatters/EmailFormatter.php
):
php
<?php

declare(strict_types=1);

namespace App\Data\Formatters;

class EmailFormatter
{
    public static function format(string $email): string
    {
        return strtolower(trim($email));
    }
}
在构造函数中应用格式化逻辑:
php
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
<?php

declare(strict_types=1);

namespace App\Data\Formatters;

class EmailFormatter
{
    public static function format(string $email): string
    {
        return strtolower(trim($email));
    }
}

Static Factory Methods on DTOs

DTO上的静态工厂方法

For smaller applications or when starting out, add static
from*
methods directly on the DTO class. This provides factory-like behavior before complexity warrants separate transformers.
Method naming:
from{SourceType}
- e.g.,
fromArray
,
fromRequest
,
fromModel
php
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(),
        ]);
    }
}
When to use static methods on DTO:
  • Smaller applications with fewer DTOs
  • Simple transformations that don't need dedicated testing
  • When mapping is tightly coupled to a single DTO
When to use separate transformers:
  • Multiple external sources map to the same DTO
  • Complex transformation logic requiring extensive testing
  • Larger applications with clear separation of concerns
对于小型应用或项目初期,可在DTO类上直接添加静态
from*
方法。在复杂度需要独立转换器之前,这种方式能提供类似工厂的行为。
方法命名规则:
from{SourceType}
- 例如
fromArray
fromRequest
fromModel
php
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时
  • 复杂转换逻辑需要大量测试时
  • 关注点分离清晰的大型应用

Model Casts

模型转换

Cast model JSON columns to DTOs:
php
class Order extends Model
{
    protected function casts(): array
    {
        return [
            'metadata' => OrderMetadataData::class,
            'status' => OrderStatus::class,
        ];
    }
}
Usage:
php
// Store
$order = Order::create([
    'metadata' => $metadataData,  // OrderMetadataData instance
]);

// Retrieve
$metadata = $order->metadata;  // Returns OrderMetadataData instance
将模型的JSON列转换为DTO:
php
class Order extends Model
{
    protected function casts(): array
    {
        return [
            'metadata' => OrderMetadataData::class,
            'status' => OrderStatus::class,
        ];
    }
}
使用方式:
php
// 存储
$order = Order::create([
    'metadata' => $metadataData,  // OrderMetadataData实例
]);

// 读取
$metadata = $order->metadata;  // 返回OrderMetadataData实例

Naming Conventions

命名规范

TypePatternExamples
Response DTOs
{Entity}Data
OrderData
,
UserData
,
ProductData
Request DTOs
{Action}{Entity}Data
CreateOrderData
,
UpdateUserData
Nested DTOs
{Descriptor}{Entity}Data
ShippingAddressData
,
OrderMetadataData
类型命名模式示例
响应DTO
{Entity}Data
OrderData
,
UserData
,
ProductData
请求DTO
{Action}{Entity}Data
CreateOrderData
,
UpdateUserData
嵌套DTO
{Descriptor}{Entity}Data
ShippingAddressData
,
OrderMetadataData

Directory Structure

目录结构

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
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

Usage in Controllers

在控制器中的使用

Controllers transform requests to DTOs via transformers:
php
<?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);
    }
}
控制器通过转换器将请求转换为DTO:
php
<?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);
    }
}

Usage in Actions

在动作类中的使用

Actions accept DTOs as parameters:
php
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,
            ]);
        });
    }
}
动作类接受DTO作为参数:
php
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,
            ]);
        });
    }
}

Transformers

转换器

For complex transformations (external APIs, webhooks, field mappings), use dedicated transformer classes.
→ Complete guide: dto-transformers.md
php
// External system data
$data = PaymentDataTransformer::fromStripePaymentIntent($webhook['data']);

// Request with version-specific field names
$data = OrderDataTransformer::fromRequest($request);
Hierarchy of preference:
  1. Data::from($array)
    - Simple cases, direct mapping
  2. Data::fromRequest()
    - Static method on DTO for smaller apps
  3. Transformer::from*()
    - Complex transformations, multiple sources
对于复杂的转换场景(外部API、Webhook、字段映射),请使用独立的转换器类。
→ 完整指南:dto-transformers.md
php
// 外部系统数据转换
$data = PaymentDataTransformer::fromStripePaymentIntent($webhook['data']);

// 带版本特定字段名的请求转换
$data = OrderDataTransformer::fromRequest($request);
优先级层级:
  1. Data::from($array)
    - 简单场景,直接映射
  2. Data::fromRequest()
    - 小型应用中使用DTO上的静态方法
  3. Transformer::from*()
    - 复杂转换、多数据源场景

Test Factories

测试工厂

Create hydrated DTOs for tests using the
HasTestFactory
trait.
→ Complete guide: test-factories.md
Link DTOs to factories with PHPDoc:
php
/**
 * @see \Database\Factories\Data\CreateOrderDataFactory
 * @method static CreateOrderDataFactory testFactory()
 */
class CreateOrderData extends Data
{
    // ...
}
Usage:
php
$data = CreateOrderData::testFactory()->make();
$collection = OrderItemData::testFactory()->collect(count: 5);

// With overrides
$data = CreateOrderData::testFactory()->make([
    'customerEmail' => 'test@example.com',
]);
使用
HasTestFactory
trait为测试创建已填充数据的DTO。
→ 完整指南:test-factories.md
通过PHPDoc将DTO与工厂关联:
php
/**
 * @see \Database\Factories\Data\CreateOrderDataFactory
 * @method static CreateOrderDataFactory testFactory()
 */
class CreateOrderData extends Data
{
    // ...
}
使用方式:
php
$data = CreateOrderData::testFactory()->make();
$collection = OrderItemData::testFactory()->collect(count: 5);

// 覆盖默认值
$data = CreateOrderData::testFactory()->make([
    'customerEmail' => 'test@example.com',
]);

Testing DTOs

DTO测试

php
use App\Data\CreateOrderData;

it('can create DTO from array', function () {
    $data = CreateOrderData::from([
        'customerEmail' => 'test@example.com',
        'notes' => 'Test notes',
        'status' => 'pending',
    ]);

    expect($data)
        ->customerEmail->toBe('test@example.com')
        ->notes->toBe('Test notes');
});

it('formats email in constructor', function () {
    $data = new CreateOrderData(
        customerEmail: '  TEST@EXAMPLE.COM  ',
        notes: null,
        status: OrderStatus::Pending,
    );

    expect($data->customerEmail)->toBe('test@example.com');
});
php
use App\Data\CreateOrderData;

it('can create DTO from array', function () {
    $data = CreateOrderData::from([
        'customerEmail' => 'test@example.com',
        'notes' => 'Test notes',
        'status' => 'pending',
    ]);

    expect($data)
        ->customerEmail->toBe('test@example.com')
        ->notes->toBe('Test notes');
});

it('formats email in constructor', function () {
    $data = new CreateOrderData(
        customerEmail: '  TEST@EXAMPLE.COM  ',
        notes: null,
        status: OrderStatus::Pending,
    );

    expect($data->customerEmail)->toBe('test@example.com');
});