DTOs at the Speed of Plain PHP – DerEuroMark

16 min read Original article ↗
Table of Contents

Zero Reflection, Zero Regrets

Every PHP developer knows the pain. You’re deep in a template, staring at $data['user']['address']['city'], wondering if that key actually exists or if you’re about to trigger a notice that’ll haunt your logs forever.

DTOs solve this. But the cure has often been worse than the disease.

This post aims to:

  1. raise awareness about array > ArrayObject > DTO performance loss
  2. provide a high-speed alternative to reflection libraries with the same feature set (or more)

The Reflection Tax

Modern PHP DTO libraries are clever. Too clever. They use runtime reflection to magically hydrate objects from arrays, infer types from docblocks, and validate on the fly. It’s beautiful—until you profile it.

Every. Single. Instantiation. Pays the reflection tax.

For a simple API endpoint returning 100 users? That’s 100 reflection calls. For a batch job processing 10,000 records? You’re burning CPU cycles on introspection instead of actual work.

And then there’s the IDE problem. Magic means your IDE is guessing. “Find Usages” becomes “Find Some Usages, Maybe.” PHPStan needs plugins. Autocomplete works… sometimes.

What If We Just… Generated the Code?

Here’s a radical idea: what if we did all that reflection once, at build time, and generated plain PHP classes?

Introducing php-collective/dto: The Code-Generation Approach

Data Transfer Objects (DTOs) have become essential in modern PHP applications. They provide type safety, IDE autocomplete, and make your code more maintainable. But the PHP ecosystem has long debated how to implement them: runtime reflection or manual boilerplate?

php-collective/dto takes a third path: code generation. Define your DTOs once in configuration, generate optimized PHP classes, and enjoy the best of both worlds.

Why Another DTO Library?

The PHP DTO landscape in 2026 looks like this:

  • Native PHP 8.2+ readonly classes: Manual implementation
  • spatie/laravel-data: Laravel-specific, runtime reflection
  • cuyz/valinor: Framework-agnostic runtime mapper
  • symfony/serializer: Component-based serialization

These are excellent tools, but they share a common limitation: runtime reflection overhead. Every time you create a DTO, the library inspects class metadata, parses types, and builds the object dynamically.

What if we did all that work once, at build time?

Basic concept

The idea is not that radical after all. Similar implementations have existed for more than 15 years, way before modern PHP and the new syntax and features it brought along.
I have been using it for a bit more than 11 years now myself.

You decide on config as XML, YAML, NEON or PHP.
PHP using builders is the most powerful one, as it has full auto-complete/type-hinting:

return Schema::create()

->dto(Dto::create('User')->fields(

Field::int('id')->required(),

Field::string('email')->required(),

Field::dto('address', 'Address'),

))

->toArray();

Run the generator:

vendor/bin/dto generate

Get a real PHP class:

class UserDto extends AbstractDto

{

public function getId(): int { /* ... */ }

public function getEmail(): string { /* ... */ }

public function getAddress(): ?AddressDto { /* ... */ }

public function setEmail(string $email): static { /* ... */ }

// ...

}

No magic. No reflection. Just PHP.

What You Get

  1. Perfect IDE Support – Real methods = perfect autocomplete, “Find Usages”, refactoring
  2. Excellent Static Analysis – PHPStan/Psalm work without plugins or special annotations
  3. Reviewable Code – Generated classes appear in pull requests
  4. Zero Runtime Overhead – No reflection, no type parsing per instantiation
  5. Framework Agnostic – Works anywhere PHP runs

History

The concept was first used almost 2 decades ago in e-commerce systems that had a high amount of modular packages and basically disallowed all manual array usage.
All had to be DTOs for maximum extendability and discoverability. The project could add fields per DTO as needed.
The XMLs of each module as well as project extensions were all merged together. XML makes this easy, and the generated DTOs are fully compatible with both core and project level.

I never needed the “merging” feature, but I did like how quickly you could generate them, and that it could always generate full DTOs with all syntactic sugar as per current “language standards”.

Personally I always liked the XML style, because with XSD modern IDEs have full autocomplete and validation on them. But in some cases PHP might be more flexible and powerful.

Features That Matter

1. Multiple Configuration Formats

Choose what works for your team:

XML (with XSD validation):

<dto name="User">

<field name="id" type="int" required="true"/>

<field name="email" type="string" required="true"/>

<field name="roles" type="string[]" collection="true"/>

</dto>

Or use YAML or NEON for minimal syntax. Or stick to the PHP one above.

2. Mutable and Immutable Options

Mutable (default) – traditional setters:

$user = new UserDto();

$user->setName('John');

$user->setEmail('[email protected]');

Immutable – returns new instances:

$user = new UserDto(['name' => 'John']);

$updated = $user->withEmail('[email protected]');

// $user is unchanged, $updated has new email

Configure per-DTO:

Dto::immutable('Event')->fields(/* ... */);

3. Smart Key Format Conversion

APIs use snake_case. JavaScript wants camelCase. Forms send dashed-keys. Handle all of them:

// From snake_case database

$dto->fromArray($dbRow, false, UserDto::TYPE_UNDERSCORED);

// To camelCase for JavaScript

return $dto->toArray(); // default camelCase

// To snake_case for Python API

return $dto->toArray(UserDto::TYPE_UNDERSCORED);

4. Collections with Type Safety

<dto name="Order">

<field name="items" type="OrderItem[]" collection="true" singular="item"/>

</dto>

Generated methods:

$order->getItems(); // ArrayObject<OrderItemDto>

$order->addItem($itemDto); // Type-checked

$order->hasItems(); // Collection not empty

Associative collections work too:

$config->addSetting('theme', $settingDto);

$theme = $config->getSetting('theme');

Custom collection factories let you use Laravel Collections, Doctrine ArrayCollection, or CakePHP Collection (when generated with a non-\ArrayObject collection type):

Dto::setCollectionFactory(fn($items) => collect($items));

// Now all getters return Laravel collections

$order->getItems()->filter(...)->sum(...);

5. Deep Nesting and Safe Access

$company = new CompanyDto($data);

// Safe nested reading with default

$city = $company->read(['departments', 0, 'address', 'city'], 'Unknown');

// Deep cloning - nested objects are fully cloned

$clone = $company->clone();

$clone->getDepartments()[0]->setName('Changed');

// Original unchanged

6. TypeScript Generation

Share types with your frontend:

vendor/bin/dto typescript --output=frontend/src/types/

Generates:

export interface UserDto {

id: number;

email: string;

name?: string;

roles: string[];

}

export interface OrderDto {

id: number;

customer: UserDto;

items: OrderItemDto[];

}

Options include multi-file output, readonly interfaces, and strict null handling.

7. Field Tracking for Partial Updates

Know exactly what was changed:

$dto = new UserDto();

$dto->setEmail('[email protected]');

$changes = $dto->touchedToArray();

// ['email' => '[email protected]']

// Perfect for partial database updates

$repository->update($userId, $changes);

8. OrFail Methods for Non-Null Guarantees

Every nullable field gets an OrFail variant:

$email = $dto->getEmail(); // string|null

$email = $dto->getEmailOrFail(); // string (throws if null)

Use after validation to avoid null checks:

$email = $dto->getEmailOrFail(); // PHPStan now knows it is not nullable

9. Required Fields

Enforce data integrity at creation:

<field name="id" type="int" required="true"/>

new UserDto(['name' => 'John']);

// InvalidArgumentException: Required fields missing: id

10. Validation Rules

Beyond required fields, you can add common validation constraints:

Dto::create('User')->fields(

Field::string('name')->required()->minLength(2)->maxLength(100),

Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),

Field::int('age')->min(0)->max(150),

)

Rule Applies To Description
minLength string Minimum string length
maxLength string Maximum string length
min int, float Minimum numeric value
max int, float Maximum numeric value
pattern string Regex pattern validation

Validation runs on instantiation. Null fields skip validation — rules only apply when a value is present.

The validationRules() method extracts all rules as metadata, useful for bridging to framework validators:

$rules = $dto->validationRules();

// ['name' => ['required' => true, 'minLength' => 2, 'maxLength' => 100], ...]

11. Enum Support

<field name="status" type="\App\Enum\OrderStatus"/>

// From enum instance

$order->setStatus(OrderStatus::Pending);

// From backing value - auto-converted

$order = new OrderDto(['status' => 'confirmed']);

$order->getStatus(); // OrderStatus::Confirmed

12. Value Objects and DateTime

<field name="price" type="\Money\Money"/>

<field name="createdAt" type="\DateTimeImmutable"/>

Custom factories for complex instantiation:

Field::class('date', \DateTimeImmutable::class)->factory('createFromFormat')

13. Transform Functions

Apply callables to transform values during hydration or serialization:

Field::string('email')

->transformFrom('App\\Transform\\Email::normalize') // Before hydration

->transformTo('App\\Transform\\Email::mask') // After serialization

Useful for normalizing input (trimming, lowercasing) or masking output (hiding sensitive data). For collections, transforms apply to each element.

14. DTO Inheritance

Share common fields:

Dto::create('BaseEntity')->fields(

Field::int('id')->required(),

Field::class('createdAt', \DateTimeImmutable::class),

)

Dto::create('User')->extends('BaseEntity')->fields(

Field::string('email')->required(),

)

// UserDto has id, createdAt, and email

15. Array Shapes

Every generated DTO now gets shaped array types on toArray() and createFromArray():

// UserDto with fields: id (int, required), name (string), email (string, required)

/**

* @return array{id: int, name: string|null, email: string}

*/

public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array

  1. IDE Autocomplete$dto->toArray()['na suggests name
  2. Typo Detection$dto->toArray()['naem'] shows error
  3. Type Inference['name' => $name] = $dto->toArray() infers $name as string|null
  4. Destructuring Support – Full type safety when unpacking arrays

16. JSON Schema Generation

Complement your TypeScript types with JSON Schema for API documentation and contract testing:

vendor/bin/dto jsonschema --output=schemas/

Supports --single-file (with $defs references), --multi-file, --no-refs (inline nested objects), and --date-format options.

Also:

  • Property name mapping via mapFrom() and mapTo() — read from email_address in input, write to emailAddr in output
  • Default values for fields
  • Deprecation annotations (IDE warnings for deprecated fields)
  • Union types support (string|int)
  • Generic collection type hints (@return ArrayObject<int, ItemDto>)
  • Computed/derived fields via traits (getFullName() from firstName + lastName)
  • Schema importer (bootstrap DTOs from JSON schemas or OpenAPI 3.x specifications)
  • JSON serialization via serialize()/unserialize()
  • Doctrine mapper generation (--mapper) for SELECT NEW style constructors
  • Collection adapters (CakePHP, Laravel, Doctrine) via adapter registry

Real-World Patterns

API Response Transformation

class UserController

{

public function show(int $id): JsonResponse

{

$user = $this->repository->find($id);

$dto = UserDto::createFromArray($user->toArray());

// Snake case for JSON API

return new JsonResponse($dto->toArray(UserDto::TYPE_UNDERSCORED));

}

}

Form Handling with Partial Updates

public function update(Request $request, int $id): Response

{

$dto = new UserDto();

$dto->fromArray($request->all(), false, UserDto::TYPE_UNDERSCORED);

// Only update fields that were actually submitted

$this->repository->update($id, $dto->touchedToArray());

return new Response('Updated');

}

Event Sourcing with Immutable DTOs

$event = new OrderPlacedDto([

'eventId' => Uuid::uuid4()->toString(),

'aggregateId' => $orderId,

'occurredAt' => new DateTimeImmutable(),

'order' => $orderDto,

]);

// Create corrected version without mutating original

$corrected = $event->withVersion(2);

Performance: The Numbers

We ran comprehensive benchmarks comparing php-collective/dto against plain PHP, spatie/laravel-data, and cuyz/valinor. Test environment: PHP 8.4.17, 10,000 iterations per test.

Versions used: php-collective/dto dev-master (e4e1f9c), spatie/laravel-data 4.19.1, cuyz/valinor 2.3.2. A standalone comparison also includes spatie/data-transfer-object 3.9.1 and symfony/serializer 8.0.5.

Simple DTO Creation (User with 6 fields)

Library Avg Time Operations/sec Relative
Plain PHP readonly DTO 0.27 µs 3.64M/s 2.2x faster
php-collective/dto createFromArray() 0.60 µs 1.68M/s baseline
spatie/laravel-data from() 14.77 µs 67.7K/s 25x slower
cuyz/valinor 15.78 µs 63.4K/s 26x slower

Standalone benchmarks (using spatie/data-transfer-object instead of laravel-data, which requires a full Laravel app) show 52.8K/s and symfony/serializer 106K/s.

Complex Nested DTOs (Order with User, Address, 3 Items)

Library Avg Time Operations/sec Relative
Plain PHP nested DTOs 1.75 µs 571K/s 1.8x faster
php-collective/dto 3.10 µs 322K/s baseline
spatie/laravel-data 48.83 µs 20.5K/s 16x slower
cuyz/valinor 68.67 µs 14.6K/s 22x slower

Standalone nested results: spatie/data-transfer-object 10.6K/s, symfony/serializer 13.6K/s.

The gap widens with complexity. Runtime libraries pay reflection costs for every nested object. Generated code doesn’t.

Serialization (toArray)

Library Avg Time Operations/sec Relative
Plain PHP toArray() 0.68 µs 1.48M/s 1.8x faster
php-collective/dto 1.20 µs 832K/s baseline
spatie/laravel-data 26.95 µs 37.1K/s 22x slower

Property Access (10 reads)

Approach Avg Time Operations/sec
Plain PHP property access 0.11 µs 9.48M/s
php-collective/dto getters 0.20 µs 4.91M/s
Plain array access 0.15 µs 6.77M/s

Getter methods are nearly as fast as direct property access – the small overhead is negligible in real applications.

Mutable vs Immutable Operations

Operation Avg Time Operations/sec
Mutable: setName() 0.08 µs 13.1M/s
Immutable: withName() 0.12 µs 8.34M/s

Immutable operations are ~1.6x slower due to object cloning, but still extremely fast at 8.3 million operations per second.

JSON Serialization

Approach Avg Time Operations/sec
Plain array -> JSON 1.13 µs 888K/s
Plain PHP DTO -> JSON 2.07 µs 484K/s
php-collective/dto -> JSON 2.95 µs 339K/s

At 339K JSON documents per second, this is more than sufficient for any web application. A typical API handles 1K-10K requests/second.

Visual Comparison

Simple DTO Creation (ops/sec, higher is better):

┌──────────────────────────────────────────────────────────────────┐

│ Plain PHP ████████████████████████████████████ 3.64M/s │

│ php-collective ██████████████████ 1.68M/s │

│ laravel-data █ 67.7K/s │

│ valinor █ 63.4K/s │

└──────────────────────────────────────────────────────────────────┘

Complex Nested DTO (ops/sec, higher is better):

┌──────────────────────────────────────────────────────────────────┐

│ Plain PHP ██████████████████████████████████ 571K/s │

│ php-collective ███████████████████ 322K/s │

│ laravel-data ████ 20.5K/s │

│ valinor ███ 14.6K/s │

└──────────────────────────────────────────────────────────────────┘

Key Insights

  1. php-collective/dto is 25-26x faster than runtime DTO libraries for object creation
  2. Only ~2.2x slower than plain PHP — generated code approaches hand-written performance
  3. Serialization is ~22x faster than spatie/laravel-data — generated toArrayFast() avoids per-field metadata lookups
  4. The performance gap grows with nesting – more nested objects = more reflection overhead for runtime libraries
  5. Can process ~322K complex nested DTOs per second – sufficient for any batch processing scenario
  6. Property access and mutability operations are near-native speed

When to Use php-collective/dto

Choose php-collective/dto when:

  • Performance matters (API responses, batch processing)
  • You want excellent IDE and static analysis support
  • You prefer configuration files over code attributes
  • You need both mutable and immutable DTOs
  • You work with different key formats
  • You want to share types with TypeScript frontends
  • You value reviewable, inspectable generated code

Consider alternatives when:

  • You’re already deep in Laravel and want framework integration (laravel-data)
  • You need advanced validation like conditional rules or cross-field dependencies
  • You want runtime-only, no build step (valinor)

Summary

php-collective/dto brings the best of code generation to PHP DTOs:

Aspect php-collective/dto Runtime Libraries
Performance 25-26x faster Baseline
IDE Support Excellent Good
Static Analysis Native Requires plugins
Code Review Visible generated code Magic/runtime
Build Step Required None

The library is framework-agnostic, well-documented, and actively maintained.

For many apps the performance overhead of reflection might not be relevant.
After all, you might only have a few DTOs per template for simpler actions.
But in the case that you are handling a huge amount of DTOs, a less magic way could be a viable option. At least it will be more efficient than trying to nano-optimize on other parts of the application.

Migration Path: From Arrays to DTOs

Adopting DTOs doesn’t have to be a big-bang rewrite. Here’s a practical, incremental path from raw arrays to fully typed DTOs — each step delivers value on its own.

Stage 0: The Array Wilderness

This is where most legacy PHP projects start. Data flows as associative arrays, and every access is a leap of faith:

// Controller

public function view(int $id): Response

{

$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);

$data = $user->toArray();

// Pass array to service

$summary = $this->buildSummary($data);

return $this->response->withJson($summary);

}

private function buildSummary(array $data): array

{

return [

'full_name' => $data['first_name'] . ' ' . $data['last_name'],

'city' => $data['address']['city'] ?? 'Unknown', // exists?

'role_count' => count($data['roles'] ?? []), // array?

];

}

Problems: no autocomplete, no type safety, no way to know the shape without reading the query. A typo like $data['adress'] silently returns null.

Stage 1: Introduce DTOs at the Boundary

Start where it hurts most — the API response layer. Replace outgoing arrays with DTOs:

// Define the DTO config

Dto::create('UserSummary')->fields(

Field::string('fullName')->required(),

Field::string('city'),

Field::int('roleCount'),

);

vendor/bin/dto generate

// Controller — only the return type changes

public function view(int $id): Response

{

$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);

$summary = new UserSummaryDto([

'fullName' => $user->first_name . ' ' . $user->last_name,

'city' => $user->address?->city,

'roleCount' => count($user->roles),

]);

return $this->response->withJson($summary->toArray());

}

The entity query stays the same. The service layer stays the same. But the API contract is now explicit, typed, and autocomplete-friendly. If someone removes city from the DTO config, the generator catches it.

Stage 2: Move DTOs Inward to Service Methods

Once boundaries are typed, push DTOs into service signatures:

// Before: what does this array contain? Who knows.

public function calculateShipping(array $order): float

// After: explicit contract

public function calculateShipping(OrderDto $order): float

{

$weight = $order->getItems()

->filter(fn(OrderItemDto $item) => $item->getWeight() > 0)

->sum(fn(OrderItemDto $item) => $item->getWeight());

return $this->rateCalculator->forWeight($weight, $order->getAddress());

}

Every caller now gets a compile-time check (via PHPStan) that they’re passing the right data. The method signature is the documentation.

Stage 3: Replace Internal Array Passing

Target the most common pattern — methods that return arrays of mixed data:

// Before

public function getStats(): array

{

return [

'total_users' => $this->Users->find()->count(),

'active_today' => $this->Users->find('activeToday')->count(),

'revenue' => $this->Orders->find()->sumOf('total'),

];

}

// Template: $stats['total_users'] — typo-prone, no autocomplete

// After

public function getStats(): DashboardStatsDto

{

return new DashboardStatsDto([

'totalUsers' => $this->Users->find()->count(),

'activeToday' => $this->Users->find('activeToday')->count(),

'revenue' => $this->Orders->find()->sumOf('total'),

]);

}

// Template: $stats->getTotalUsers() — autocomplete, type-checked

Stage 4: Use Projection for Read-Only Queries

For CakePHP 5.3+, skip the entity entirely on read paths:

// Before: full entity hydration, then manual mapping

$users = $this->Users->find()

->select(['id', 'email', 'name', 'created'])

->contain(['Roles'])

->all()

->toArray();

// After: straight to DTO, no entity in between

$users = $this->Users->find()

->select(['id', 'email', 'name', 'created'])

->contain(['Roles'])

->projectAs(UserListDto::class)

->all()

->toArray();

The query result maps directly into UserListDto objects. No entity overhead, no intermediate array step.

What to Migrate First

Not everything needs a DTO. Prioritize based on pain:

Priority Where Why
High API responses External contract, most likely to break silently
High Service method params Most frequent source of “what keys does this array have?”
Medium Template variables Autocomplete in templates reduces bugs
Medium Queue/event payloads Serialization boundaries need explicit shapes
Low Internal helper returns If only one caller exists, the overhead isn’t worth it
Skip Simple key-value configs Arrays are fine for ['timeout' => 30]

Rules of Thumb

  • Don’t convert everything at once. Start with the file you’re already editing.
  • One DTO per PR. Each conversion is a small, reviewable change.
  • Let the pain guide you. If you’ve been burned by a missing array key, that’s where the DTO goes.
  • Keep entities for writes. Entities handle validation, callbacks, and persistence. DTOs handle data transfer. They coexist.
  • Generated DTOs can wrap entities. Use UserDto::createFromArray($entity->toArray()) as a bridge during migration — no need to refactor the query layer first.

Demo

A live demo is available in the sandbox.
Especially check out the “projection” examples that map the DB content 1:1 into speaking DTOs.
The needed DTOs can be (re-)generated from the backend with a single click from the DB structure if needed.


Generated code is boring. Predictable. Fast.
Sometimes boring is exactly what you need.

php-collective/dto is available on Packagist. MIT licensed. PRs welcome.