Examples
Practical examples demonstrating common DTO patterns and use cases.
Mutable DTOs
Mutable DTOs allow direct modification. Changes affect all references to the same object.
use App\Dto\CarDto;
use App\Dto\OwnerDto;
// Create and configure a mutable DTO
$ownerDto = new OwnerDto();
$ownerDto->setName('The Owner');
$carDto = new CarDto();
$carDto->setOwner($ownerDto);
// References share the same object
$otherCarDto = $carDto;
$otherCarDto->getOwner()->setName('The new owner');
// Both references see the change
assert($carDto->getOwner()->getName() === 'The new owner');This is the default behavior. If you need isolated copies, use immutable DTOs or explicit cloning.
Immutable DTOs
Immutable DTOs create new instances for each modification, preserving the original.
<dto name="Article" immutable="true">
<field name="id" type="int" required="true"/>
<field name="title" type="string" required="true"/>
<field name="author" type="Author"/>
<field name="created" type="\DateTimeImmutable"/>
</dto>use App\Dto\ArticleDto;
$array = [
'id' => 2,
'author' => ['id' => 1, 'name' => 'me'],
'title' => 'My title',
'created' => new DateTimeImmutable('-1 day'),
];
$articleDto = new ArticleDto($array);
$modifiedArticleDto = $articleDto->withTitle('My new title');
// Original remains unchanged
assert($articleDto->getTitle() === 'My title');
assert($modifiedArticleDto->getTitle() === 'My new title');Data Conversion Patterns
Entity to DTO
Convert database entities or models to DTOs for safe transport across layers:
// From any ORM/database layer
$userData = $repository->findById(123);
// Convert to DTO (array or object with toArray())
$userDto = UserDto::createFromArray($userData->toArray());
// Or pass array directly
$userDto = UserDto::createFromArray([
'id' => $userData->id,
'email' => $userData->email,
'name' => $userData->name,
]);DTO to Entity
Convert DTOs back for persistence:
// Get only the modified fields
$changes = $userDto->touchedToArray();
// Update entity with changes
$entity->fill($changes);
$repository->save($entity);Working with Relations
<dto name="Order">
<field name="id" type="int" required="true"/>
<field name="customer" type="Customer"/>
<field name="items" type="OrderItem[]" collection="true" singular="item"/>
<field name="createdAt" type="\DateTimeImmutable"/>
</dto>// Convert order with relations
$orderData = $orderRepository->findWithRelations($orderId);
// Deep conversion happens automatically
$orderDto = OrderDto::createFromArray([
'id' => $orderData['id'],
'customer' => $orderData['customer'], // Converted to CustomerDto
'items' => $orderData['items'], // Converted to OrderItemDto[]
'createdAt' => $orderData['created_at'],
]);
// Access nested data with type safety
$customerName = $orderDto->getCustomer()->getName();
foreach ($orderDto->getItems() as $item) {
echo $item->getProduct()->getName();
}Key Format Handling
From Snake Case (Database/Forms)
$formData = [
'first_name' => 'John',
'last_name' => 'Doe',
'email_address' => 'john@example.com',
];
$dto = new UserDto();
$dto->fromArray($formData, false, UserDto::TYPE_UNDERSCORED);
// Access with camelCase getters
echo $dto->getFirstName(); // "John"
echo $dto->getEmailAddress(); // "john@example.com"From Dash Case (URLs/APIs)
$queryParams = [
'sort-by' => 'created_at',
'sort-order' => 'desc',
'page-size' => 25,
];
$dto = new PaginationDto();
$dto->fromArray($queryParams, false, PaginationDto::TYPE_DASHED);
echo $dto->getSortBy(); // "created_at"
echo $dto->getPageSize(); // 25Export to Different Formats
// Default camelCase
$camelCase = $dto->toArray();
// ['firstName' => 'John', 'lastName' => 'Doe']
// Snake case for database
$snakeCase = $dto->toArray(UserDto::TYPE_UNDERSCORED);
// ['first_name' => 'John', 'last_name' => 'Doe']
// Dash case for URLs
$dashCase = $dto->toArray(UserDto::TYPE_DASHED);
// ['first-name' => 'John', 'last-name' => 'Doe']Collections
Simple Collections
<dto name="Cart">
<field name="items" type="CartItem[]" collection="true" singular="item"/>
</dto>$cart = new CartDto();
// Add items one by one
$cart->addItem(new CartItemDto(['productId' => 1, 'quantity' => 2]));
$cart->addItem(new CartItemDto(['productId' => 5, 'quantity' => 1]));
// Check collection
echo count($cart->getItems()); // 2
// Iterate
foreach ($cart->getItems() as $item) {
echo $item->getProductId() . ': ' . $item->getQuantity();
}Associative Collections
<dto name="Config">
<field name="settings" type="Setting[]" collection="true"
singular="setting" associative="true"/>
</dto>$config = new ConfigDto();
// Add with keys
$config->addSetting('theme', new SettingDto(['value' => 'dark']));
$config->addSetting('language', new SettingDto(['value' => 'en']));
// Access by key
$theme = $config->getSetting('theme');
echo $theme->getValue(); // "dark"
// Check existence
if ($config->hasSetting('notifications')) {
// ...
}Nested Reading
Safe access to deeply nested values:
$orderDto = OrderDto::createFromArray([
'customer' => [
'address' => [
'city' => 'New York',
'country' => 'USA',
],
],
]);
// Safe nested access
$city = $orderDto->read(['customer', 'address', 'city']);
// "New York"
// With default for missing values
$state = $orderDto->read(['customer', 'address', 'state'], 'Unknown');
// "Unknown"
// Returns null for missing paths (without default)
$zipCode = $orderDto->read(['customer', 'address', 'zipCode']);
// nullDeep Cloning
Create independent copies of nested structures:
$original = new OrderDto();
$original->setCustomer(new CustomerDto(['name' => 'John']));
// Deep clone
$clone = $original->clone();
$clone->getCustomer()->setName('Jane');
// Original is unchanged
assert($original->getCustomer()->getName() === 'John');
assert($clone->getCustomer()->getName() === 'Jane');Required Fields
Ensure critical data is always present:
<dto name="User">
<field name="id" type="int" required="true"/>
<field name="email" type="string" required="true"/>
<field name="name" type="string"/>
</dto>// This throws an exception - required fields missing
$user = new UserDto(['name' => 'John']);
// RuntimeException: Required field 'id' is missing
// Proper initialization
$user = new UserDto([
'id' => 1,
'email' => 'john@example.com',
'name' => 'John',
]);OrFail Methods
Get values with guaranteed non-null returns:
// Standard getter - may return null
$email = $userDto->getEmail(); // string|null
// OrFail getter - throws if null
$email = $userDto->getEmailOrFail(); // string (throws if not set)Useful when you know a value must exist:
// After validation, we know email exists
$validatedDto = $this->validateUser($inputDto);
$email = $validatedDto->getEmailOrFail(); // Safe - validation ensures it existsDefault Values
Provide sensible defaults:
<dto name="Pagination">
<field name="page" type="int" defaultValue="1"/>
<field name="perPage" type="int" defaultValue="20"/>
<field name="sortOrder" type="string" defaultValue="asc"/>
</dto>$pagination = new PaginationDto();
echo $pagination->getPage(); // 1
echo $pagination->getPerPage(); // 20
echo $pagination->getSortOrder(); // "asc"
// Override defaults as needed
$pagination->setPage(5);Custom Collection Types
By default, DTO collections return ArrayObject. You can customize this globally to use your framework's collection class, gaining access to powerful collection methods like filter(), map(), reduce(), etc.
Why Use Custom Collections?
ArrayObject is functional but limited. Framework collections provide:
- Fluent, chainable operations (
->filter()->map()->sum()) - Lazy evaluation (in some implementations)
- Framework-specific integrations (e.g., Laravel's
pluck(), CakePHP'sgroupBy())
Setting a Global Factory
Set the factory early in your application bootstrap, before any DTOs are instantiated:
use PhpCollective\Dto\Dto\Dto;
// CakePHP Collection
Dto::setCollectionFactory(fn(array $items) => new \Cake\Collection\Collection($items));
// Laravel Collection
Dto::setCollectionFactory(fn(array $items) => collect($items));
// Doctrine ArrayCollection
Dto::setCollectionFactory(fn(array $items) => new \Doctrine\Common\Collections\ArrayCollection($items));Using Framework Collection Methods
Once set, all DTO collections gain the framework's collection methods:
// With Laravel collection factory
Dto::setCollectionFactory(fn($items) => collect($items));
$cart = new CartDto();
// ... add items
// Use Laravel collection methods
$total = $cart->getItems()
->filter(fn($item) => $item->getQuantity() > 0)
->sum(fn($item) => $item->getPrice() * $item->getQuantity());
$productNames = $cart->getItems()
->pluck('name')
->unique()
->values();Resetting to Default
// Reset to default ArrayObject
Dto::setCollectionFactory(null);When to Use
- API applications: Laravel/Symfony collections for response transformation
- Domain logic: Filter, aggregate, transform collections fluently
- Testing: Reset factory between tests to avoid state pollution
API Response Pattern
class UserController
{
public function show(int $id): array
{
$user = $this->userRepository->find($id);
$dto = UserDto::createFromArray([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'createdAt' => $user->created_at,
]);
// Return as snake_case for JSON API
return $dto->toArray(UserDto::TYPE_UNDERSCORED);
}
}Form Handling Pattern
public function update(Request $request, int $id): Response
{
// Convert form input to DTO
$dto = new UserDto();
$dto->fromArray($request->all(), false, UserDto::TYPE_UNDERSCORED);
// Validate and process
$this->validator->validate($dto);
// Get only changed fields for partial update
$changes = $dto->touchedToArray();
$this->userRepository->update($id, $changes);
return new Response('Updated');
}Value Object Integration
DTOs work seamlessly with value objects:
<dto name="Product">
<field name="id" type="int"/>
<field name="name" type="string"/>
<field name="price" type="\Money\Money"/>
<field name="createdAt" type="\DateTimeImmutable"/>
</dto>use Money\Money;
use Money\Currency;
$product = new ProductDto([
'id' => 1,
'name' => 'Widget',
'price' => Money::of(1999, new Currency('USD')),
'createdAt' => new DateTimeImmutable(),
]);
$price = $product->getPrice();
echo $price->getAmount(); // 1999Enum Support
<dto name="Order">
<field name="status" type="\App\Enum\OrderStatus"/>
</dto>enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Delivered = 'delivered';
}
$order = new OrderDto([
'status' => OrderStatus::Pending,
]);
// Or from string value
$order = new OrderDto([
'status' => 'confirmed', // Automatically converted to enum
]);
$status = $order->getStatus(); // OrderStatus::ConfirmedDeprecation Handling
Mark fields as deprecated for gradual migration:
<dto name="User">
<field name="username" type="string" deprecated="Use email instead"/>
<field name="email" type="string"/>
</dto>Your IDE will show deprecation warnings when using getUsername() or setUsername().
JSON Serialization
To JSON
$dto = new UserDto(['name' => 'John', 'email' => 'john@example.com']);
// Using serialize() - returns JSON string of touched fields
$json = $dto->serialize();
// {"name":"John","email":"john@example.com"}
// Using toArray() with json_encode - all fields
$json = json_encode($dto->toArray());
// Pretty printed
$json = json_encode($dto->toArray(), JSON_PRETTY_PRINT);
// With key format conversion
$json = json_encode($dto->toArray(UserDto::TYPE_UNDERSCORED));
// {"name":"John","email":"john@example.com"}From JSON
// Using fromUnserialized() - static constructor
$json = '{"name":"John","email":"john@example.com"}';
$dto = UserDto::fromUnserialized($json);
// Using json_decode + constructor
$data = json_decode($json, true);
$dto = new UserDto($data);
// With ignoreMissing for partial JSON
$partialJson = '{"name":"John"}';
$dto = new UserDto(json_decode($partialJson, true), ignoreMissing: true);API Response Pattern
class ApiController
{
public function show(int $id): JsonResponse
{
$user = $this->userRepository->find($id);
$dto = UserDto::createFromArray($user->toArray());
return new JsonResponse($dto->toArray(UserDto::TYPE_UNDERSCORED));
}
public function store(Request $request): JsonResponse
{
$dto = new UserDto(
$request->json()->all(),
ignoreMissing: true,
);
$user = $this->userService->create($dto);
return new JsonResponse(
UserDto::createFromArray($user->toArray())->toArray(),
201,
);
}
}Debugging with JSON
// Quick debug output
echo json_encode($dto->toArray(), JSON_PRETTY_PRINT);
// Or use __toString which returns JSON
echo $dto; // Calls serialize() internallyValidation Rules
Built-in validation rules provide field-level constraints checked during construction.
Basic Validation
// Configuration
Dto::create('User')->fields(
Field::string('username')->required()->minLength(3)->maxLength(20),
Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
)// Valid - passes all rules
$user = new UserDto([
'username' => 'johndoe',
'email' => 'john@example.com',
'age' => 25,
]);
// Invalid - throws InvalidArgumentException
$user = new UserDto([
'username' => 'jo', // Too short (minLength: 3)
'email' => 'john@example.com',
]);
// Exception: Field 'username' must be at least 3 characters
// Invalid email pattern
$user = new UserDto([
'username' => 'johndoe',
'email' => 'not-an-email',
]);
// Exception: Field 'email' does not match required patternNullable Fields Skip Validation
Dto::create('Profile')->fields(
Field::string('bio')->maxLength(500), // Optional field
Field::int('followers')->min(0),
)// Null values skip validation
$profile = new ProfileDto(['bio' => null, 'followers' => null]); // OK
// Non-null values are validated
$profile = new ProfileDto(['bio' => str_repeat('x', 501)]);
// Exception: Field 'bio' must be at most 500 charactersExtracting Validation Rules
Use validationRules() to get framework-agnostic rules for integration with validators:
$dto = new UserDto(['username' => 'test', 'email' => 'test@example.com']);
$rules = $dto->validationRules();
// Returns:
// [
// 'username' => ['required' => true, 'minLength' => 3, 'maxLength' => 20],
// 'email' => ['required' => true, 'pattern' => '/^[^@]+@[^@]+\.[^@]+$/'],
// 'age' => ['min' => 0, 'max' => 150],
// ]
// Use with framework validators
$validator = new FrameworkValidator($rules);Lazy Loading
Defer nested DTO/collection hydration for performance optimization.
Basic Lazy Loading
// Configuration
Dto::create('Order')->fields(
Field::int('id')->required(),
Field::string('status'),
Field::dto('customer', 'Customer')->asLazy(),
Field::collection('items', 'OrderItem')->singular('item')->asLazy(),
)// Create order from API response
$order = new OrderDto([
'id' => 123,
'status' => 'pending',
'customer' => ['name' => 'John', 'email' => 'john@example.com'],
'items' => [
['product' => 'Widget', 'quantity' => 2, 'price' => 29.99],
['product' => 'Gadget', 'quantity' => 1, 'price' => 49.99],
],
]);
// At this point: no CustomerDto or OrderItemDto objects exist yet
// Access triggers hydration
$customer = $order->getCustomer(); // CustomerDto created now
echo $customer->getName(); // "John"Pass-Through Optimization
When data is forwarded without deep inspection, lazy fields avoid unnecessary object creation:
// API gateway scenario
$orderData = $this->fetchFromUpstreamApi();
$order = new OrderDto($orderData);
// Forward to downstream service - no nested objects created
$this->downstreamApi->send($order->toArray());Checking Lazy State
// Access triggers hydration - subsequent calls return cached instance
$items1 = $order->getItems(); // Hydrated
$items2 = $order->getItems(); // Same instance returned
assert($items1 === $items2);Readonly Properties
Use readonly properties for language-level immutability with direct property access.
Basic Readonly DTO
// Configuration
Dto::create('DatabaseConfig')->readonlyProperties()->fields(
Field::string('host')->required(),
Field::int('port')->default(3306),
Field::string('database')->required(),
Field::string('username')->required(),
Field::string('password'),
)$config = new DatabaseConfigDto([
'host' => 'localhost',
'database' => 'myapp',
'username' => 'root',
]);
// Direct property access
echo $config->host; // "localhost"
echo $config->port; // 3306 (default)
echo $config->database; // "myapp"
// Getters also work
echo $config->getHost(); // "localhost"
// Modification throws Error
$config->host = 'other'; // Error: Cannot modify readonly propertyCreating Modified Copies
$devConfig = new DatabaseConfigDto([
'host' => 'localhost',
'database' => 'myapp_dev',
'username' => 'dev',
]);
// Create production config based on dev
$prodConfig = $devConfig
->withHost('prod-db.example.com')
->withDatabase('myapp_prod')
->withUsername('app_user')
->withPassword('secret');
// Original unchanged
echo $devConfig->host; // "localhost"
echo $prodConfig->host; // "prod-db.example.com"Readonly vs Immutable Comparison
// Immutable DTO - getter-based access (consistent with mutable DTOs)
Dto::immutable('Event')->fields(
Field::string('name')->required(),
Field::string('payload'),
)
$event = new EventDto(['name' => 'user.created', 'payload' => '{}']);
echo $event->getName(); // Access via getter
// Readonly DTO - direct property access
Dto::create('Event')->readonlyProperties()->fields(
Field::string('name')->required(),
Field::string('payload'),
)
$event = new EventDto(['name' => 'user.created', 'payload' => '{}']);
echo $event->name; // Direct access - shorter syntax
echo $event->getName(); // Getter also works