| name | laravel-dtos |
|---|---|
| description | Data Transfer Objects using Spatie Laravel Data. Use when handling data transfer, API requests/responses, or when user mentions DTOs, data objects, Spatie Data, formatters, transformers, or structured data handling. |
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
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
Uses Spatie Laravel Data. Refer to official docs for package features. This guide covers project-specific patterns and preferences.
Always prefer ::from() with arrays where keys match constructor property names. Let the package handle casting based on property types.
// ✅ 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
Don't use #[MapInputName] or case mapper attributes. Map field names explicitly in calling code.
// ❌ 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
The package automatically casts date strings to Carbon or CarbonImmutable based on property types. Configure the expected date format in the package config.
// 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);
}
}Always use promoted properties:
public function __construct(
public string $name,
public ?string $description,
public bool $active = true,
) {}Not:
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/** @var int[] */
public array $productIds;
/** @var Collection<int, OrderItemData> */
public Collection $items;class OrderData extends Data
{
public function __construct(
public CustomerData $customer,
public ShippingAddressData $shipping,
public BillingAddressData $billing,
/** @var Collection<int, OrderItemData> */
public Collection $items,
) {}
}Apply formatting in the constructor:
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
declare(strict_types=1);
namespace App\Data\Formatters;
class EmailFormatter
{
public static function format(string $email): string
{
return strtolower(trim($email));
}
}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
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
Cast model JSON columns to DTOs:
class Order extends Model
{
protected function casts(): array
{
return [
'metadata' => OrderMetadataData::class,
'status' => OrderStatus::class,
];
}
}Usage:
// Store
$order = Order::create([
'metadata' => $metadataData, // OrderMetadataData instance
]);
// Retrieve
$metadata = $order->metadata; // Returns OrderMetadataData instance| Type | Pattern | Examples |
|---|---|---|
| Response DTOs | {Entity}Data |
OrderData, UserData, ProductData |
| Request DTOs | {Action}{Entity}Data |
CreateOrderData, UpdateUserData |
| Nested DTOs | {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
Controllers transform requests to DTOs via transformers:
<?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);
}
}Actions accept DTOs as parameters:
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,
]);
});
}
}For complex transformations (external APIs, webhooks, field mappings), use dedicated transformer classes.
→ Complete guide: dto-transformers.md
// External system data
$data = PaymentDataTransformer::fromStripePaymentIntent($webhook['data']);
// Request with version-specific field names
$data = OrderDataTransformer::fromRequest($request);Hierarchy of preference:
Data::from($array)- Simple cases, direct mappingData::fromRequest()- Static method on DTO for smaller appsTransformer::from*()- Complex transformations, multiple sources
Create hydrated DTOs for tests using the HasTestFactory trait.
→ Complete guide: test-factories.md
Link DTOs to factories with PHPDoc:
/**
* @see \Database\Factories\Data\CreateOrderDataFactory
* @method static CreateOrderDataFactory testFactory()
*/
class CreateOrderData extends Data
{
// ...
}Usage:
$data = CreateOrderData::testFactory()->make();
$collection = OrderItemData::testFactory()->collect(count: 5);
// With overrides
$data = CreateOrderData::testFactory()->make([
'customerEmail' => 'test@example.com',
]);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');
});