Skip to content

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.

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

xml
<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>
php
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:

php
// 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:

php
// Get only the modified fields
$changes = $userDto->touchedToArray();

// Update entity with changes
$entity->fill($changes);
$repository->save($entity);

Working with Relations ​

xml
<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>
php
// 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) ​

php
$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) ​

php
$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();  // 25

Export to Different Formats ​

php
// 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 ​

xml
<dto name="Cart">
    <field name="items" type="CartItem[]" collection="true" singular="item"/>
</dto>
php
$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 ​

xml
<dto name="Config">
    <field name="settings" type="Setting[]" collection="true"
           singular="setting" associative="true"/>
</dto>
php
$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:

php
$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']);
// null

Deep Cloning ​

Create independent copies of nested structures:

php
$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:

xml
<dto name="User">
    <field name="id" type="int" required="true"/>
    <field name="email" type="string" required="true"/>
    <field name="name" type="string"/>
</dto>
php
// 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:

php
// 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:

php
// After validation, we know email exists
$validatedDto = $this->validateUser($inputDto);
$email = $validatedDto->getEmailOrFail();  // Safe - validation ensures it exists

Default Values ​

Provide sensible defaults:

xml
<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>
php
$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's groupBy())

Setting a Global Factory ​

Set the factory early in your application bootstrap, before any DTOs are instantiated:

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

php
// 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 ​

php
// 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 ​

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

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

xml
<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>
php
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();  // 1999

Enum Support ​

xml
<dto name="Order">
    <field name="status" type="\App\Enum\OrderStatus"/>
</dto>
php
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::Confirmed

Deprecation Handling ​

Mark fields as deprecated for gradual migration:

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

php
$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 ​

php
// 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 ​

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

php
// Quick debug output
echo json_encode($dto->toArray(), JSON_PRETTY_PRINT);

// Or use __toString which returns JSON
echo $dto;  // Calls serialize() internally

Validation Rules ​

Built-in validation rules provide field-level constraints checked during construction.

Basic Validation ​

php
// 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),
)
php
// 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: Validation failed in App\Dto\UserDto: username must be at least 3 characters

// Invalid email pattern
$user = new UserDto([
    'username' => 'johndoe',
    'email' => 'not-an-email',
]);
// Exception: Validation failed in App\Dto\UserDto: email must match pattern /^[^@]+@[^@]+\.[^@]+$/

Nullable Fields Skip Validation ​

php
Dto::create('Profile')->fields(
    Field::string('bio')->maxLength(500),  // Optional field
    Field::int('followers')->min(0),
)
php
// 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: Validation failed in App\Dto\ProfileDto: bio must be at most 500 characters

Extracting Validation Rules ​

Use validationRules() to get framework-agnostic rules for integration with validators:

php
$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],
// ]

// Map to framework-native validation rules or constraints
$frameworkRules = [
    'username' => ['required', 'string', 'min:' . $rules['username']['minLength']],
    'email' => ['required', 'email'],
];

Lazy Loading ​

Defer nested DTO/collection hydration for performance optimization.

Basic Lazy Loading ​

php
// Configuration
Dto::create('Order')->fields(
    Field::int('id')->required(),
    Field::string('status'),
    Field::dto('customer', 'Customer')->asLazy(),
    Field::collection('items', 'OrderItem')->singular('item')->asLazy(),
)
php
// 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:

php
// 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 ​

php
// 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 ​

php
// 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'),
)
php
$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 property

Creating Modified Copies ​

php
$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 ​

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

Released under the MIT License.