Performance Guide
Performance characteristics, benchmarks, and optimization tips.
Why Generated Code is Fast
php-collective/dto takes a fundamentally different approach than runtime DTO libraries:
| Operation | Runtime Libraries | php-collective/dto |
|---|---|---|
| Class loading | Parse attributes/annotations | Load pre-generated class |
| Property access | Reflection or magic methods | Direct property access |
| Type checking | Runtime validation | PHP native types |
| Key mapping | Runtime calculation | Pre-computed lookup table |
| Collection creation | Runtime type resolution | Pre-defined factory |
Result: Near-native PHP performance with zero reflection overhead per instantiation.
Benchmark Comparison
Simple DTO Creation (10,000 iterations)
php-collective/dto: ~15ms (baseline)
Native PHP readonly: ~12ms (0.8x)
spatie/laravel-data: ~85ms (5.7x slower)
cuyz/valinor: ~120ms (8x slower)Benchmarks are indicative. Actual performance varies by PHP version, opcache settings, and DTO complexity.
Key Insights
- First load: Generated DTOs are slightly slower on first load (more code to parse), but opcache eliminates this
- Subsequent access: Generated DTOs match or beat native PHP classes
- Complex DTOs: The performance gap widens with nested DTOs and collections
Optimization Tips
1. Use opcache in Production
Generated DTOs benefit significantly from opcache:
; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; Disable in production2. Enable strictTypes so Transforms Get Compiled
Fields using transformFrom / transformTo are compiled into a direct call in the generated fast-path methods:
// generated with strictTypes enabled
$value = \App\Transform\Email::normalize($value);
// generated without it
$value = $this->transformValue('App\Transform\Email::normalize', $value);The runtime form pays a method call, a null check, an is_callable() lookup and a dynamic invocation for every transformed field on every hydration and serialization - roughly 4x the cost of the direct call.
Inlining requires strictTypes, because an inlined call takes its argument coercion from the generated file rather than from the library, and dropping strict types there would silently change how scalar type hints on your transform callables behave.
TIP
A transform that cannot be resolved while generating (typo, class not autoloadable at bake time) keeps the runtime form on purpose, so the error still surfaces as InvalidArgumentException instead of a raw Error.
3. Prefer touchedToArray() for Partial Data
// Slower: serializes all fields
$data = $dto->toArray();
// Faster: only serializes fields that were set
$data = $dto->touchedToArray();Especially important for DTOs with many optional fields.
4. Use ignoreMissing for Partial Input
// Slower: validates every field exists
$dto = new UserDto($partialData);
// Faster: skips validation for missing fields
$dto = new UserDto($partialData, ignoreMissing: true);5. Batch Operations
When processing many items, create DTOs in batches:
// Less efficient: create one at a time
foreach ($items as $item) {
$dtos[] = new ItemDto($item);
}
// More efficient: use array_map (allows internal optimizations)
$dtos = array_map(fn($item) => new ItemDto($item), $items);6. Avoid Unnecessary Cloning
// Unnecessary clone
$modified = $original->clone();
$modified->setName('New');
// Better: modify directly if original isn't needed
$original->setName('New');
// Or use immutable DTOs if you need both versions
$modified = $original->withName('New');7. Use Appropriate Collection Types
// ArrayObject: Good for iteration, modification
<field name="items" type="Item[]" collection="true"/>
// Array: Lighter weight, but no object methods
<field name="tags" type="string[]" collection="true"/>For read-heavy workloads, array collections are slightly faster.
8. Minimize Nested Depth
Deeply nested DTOs have cumulative overhead:
// Each level adds instantiation cost
$order->getCustomer()->getAddress()->getCity()->getName();
// Consider flattening if you frequently access deep values
$order->getCustomerCity(); // Computed during creation9. Use Built-in Lazy DTO Fields When Nested Data Is Optional
php-collective/dto supports lazy DTO and collection fields via asLazy(), which lets you defer nested hydration until the data is actually accessed:
Dto::create('Order')->fields(
Field::int('id')->required(),
Field::dto('customer', 'Customer')->asLazy(),
Field::collection('items', 'OrderItem')->singular('item')->asLazy(),
)This is useful when you forward or serialize nested payloads without always traversing them. For larger application-level loading concerns, you can still combine DTOs with service-layer caching:
class OrderService
{
private ?CustomerDto $customer = null;
public function getCustomer(OrderDto $order): CustomerDto
{
if ($this->customer === null) {
$this->customer = $this->customerRepository->find($order->getCustomerId());
}
return $this->customer;
}
}Memory Considerations
Object vs Array Memory
Modern PHP (7.4+) handles objects efficiently:
// Memory usage is comparable
$array = ['name' => 'John', 'email' => 'john@example.com'];
$dto = new UserDto(['name' => 'John', 'email' => 'john@example.com']);
// DTOs may use slightly more memory due to metadata
// But the difference is negligible for most applicationsLarge Collections
For very large collections (10,000+ items), consider:
// Memory-efficient: process in chunks
foreach (array_chunk($largeArray, 1000) as $chunk) {
$dtos = array_map(fn($item) => new ItemDto($item), $chunk);
$this->process($dtos);
unset($dtos); // Free memory
}
// Or use generators
function createDtos(array $items): Generator
{
foreach ($items as $item) {
yield new ItemDto($item);
}
}Circular References
Self-referencing DTOs can cause memory issues:
// Potential memory issue with deep trees
$category = new CategoryDto([
'children' => [
['children' => [['children' => [...]]]]
],
]);
// Solution: limit depth or use lazy loadingProfiling DTOs
Using Xdebug Profiler
; php.ini
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%pUsing Blackfire
blackfire run php your-script.phpSimple Timing
$start = microtime(true);
for ($i = 0; $i < 10000; $i++) {
$dto = new UserDto(['name' => 'John', 'email' => 'john@example.com']);
}
$elapsed = microtime(true) - $start;
echo "Created 10,000 DTOs in {$elapsed}s\n";When Performance Matters
High-Performance Scenarios
- API responses with large datasets
- Batch data processing
- Real-time data streaming
- High-traffic endpoints
When to Optimize
- Profile first - identify actual bottlenecks
- Optimize hot paths - focus on frequently executed code
- Measure after - verify improvements
When NOT to Optimize
- Low-traffic internal tools
- One-time data migrations
- Development/testing environments
The generated code is already optimized. Focus on application-level optimizations before micro-optimizing DTO usage.
Comparison with Alternatives
vs Native PHP Classes
// Native PHP (fastest possible)
readonly class UserDto {
public function __construct(
public int $id,
public string $name,
) {}
}
// php-collective/dto (nearly as fast, more features)
// Generated with inflection, validation, collections, etc.Trade-off: ~20% overhead for significantly more functionality.
vs Runtime Libraries
| Library | Relative Speed | Notes |
|---|---|---|
| php-collective/dto | 1x (baseline) | Generated code |
| Native readonly | 0.8x faster | No features |
| spatie/laravel-data | 5-6x slower | Runtime reflection |
| cuyz/valinor | 6-8x slower | Runtime mapping |
When Runtime Libraries Win
- Rapid prototyping (no generation step)
- Dynamic schemas (runtime flexibility)
- Heavy framework integration (Laravel ecosystem)
When Generated Code Wins
- Production performance critical
- Static analysis requirements
- IDE support requirements
- Code review requirements (visible generated code)
Doctrine ORM: Entity vs DTO Benchmark
Using partial array hydration + DTOs significantly improves read performance compared to full entity hydration.
Benchmark Setup
// Entity with relations
#[Entity]
class User {
#[Id, Column]
private int $id;
#[Column]
private string $name;
#[Column]
private string $email;
#[OneToMany(targetEntity: Role::class)]
private Collection $roles;
#[ManyToOne(targetEntity: Department::class)]
private Department $department;
// ... 15 more fields
}
// Lightweight DTO (generated)
class UserSummaryDto extends AbstractDto {
protected int $id;
protected string $name;
protected string $email;
}Results: Fetching 1,000 Users
Full Entity Hydration: ~45ms
- Hydrates all 18 fields
- Creates proxy objects for relations
- Tracks in UnitOfWork
Array + DTO Hydration: ~12ms (3.7x faster)
- Fetches only 3 needed fields
- No proxy objects
- No UnitOfWork tracking
DTO from Entity->toArray(): ~52ms (slower than entity alone)
- Full entity hydration + conversion overheadMemory Usage (1,000 Users)
Full Entity Hydration: ~2.8 MB
Array + DTO Hydration: ~0.4 MB (7x less memory)When to Use Each Approach
| Scenario | Recommendation | Why |
|---|---|---|
| API list endpoints | Array + DTO | Only fetch displayed fields |
| Admin dashboards | Array + DTO | Read-only, specific columns |
| Entity updates | Full Entity | Need UnitOfWork for persistence |
| Complex business logic | Full Entity | Need relation traversal |
| Export/reports | Array + DTO | Memory efficient for large sets |
| Real-time feeds | Array + DTO | Minimal latency |
Benchmark Code
// Full entity hydration
$start = microtime(true);
$users = $em->getRepository(User::class)->findAll();
foreach ($users as $user) {
$data[] = [
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail(),
];
}
$entityTime = microtime(true) - $start;
// Array + DTO hydration
$start = microtime(true);
$rows = $em->createQuery(
'SELECT u.id, u.name, u.email FROM App\Entity\User u'
)->getArrayResult();
$users = array_map(fn($row) => new UserSummaryDto($row), $rows);
$dtoTime = microtime(true) - $start;
echo "Entity: {$entityTime}s, DTO: {$dtoTime}s\n";
echo "Speedup: " . round($entityTime / $dtoTime, 1) . "x\n";Real-World Example: API Endpoint
// Before: Full entity hydration (slow)
#[Route('/api/users')]
public function list(): JsonResponse
{
$users = $this->userRepository->findAll();
return $this->json(array_map(fn($u) => [
'id' => $u->getId(),
'name' => $u->getName(),
], $users));
}
// After: Array + DTO hydration (3-4x faster)
#[Route('/api/users')]
public function list(): JsonResponse
{
$users = $this->userRepository->findSummaries(); // Returns UserSummaryDto[]
return $this->json(array_map(fn($u) => $u->toArray(), $users));
}Combined with Pagination
/**
* @return UserSummaryDto[]
*/
public function findPaginatedSummaries(int $page, int $limit): array
{
$rows = $this->createQueryBuilder('u')
->select('u.id', 'u.name', 'u.email')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getArrayResult();
return array_map(fn($row) => new UserSummaryDto($row), $rows);
}The performance gains compound with:
- More fields on the entity (partial select fetches fewer)
- More relations (array hydration avoids proxy creation)
- Larger result sets (memory savings)