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. 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.
3. 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);4. 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);5. 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');6. 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.
7. 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 creation8. Lazy Loading Alternative
php-collective/dto doesn't support lazy properties, but you can implement lazy loading in your service layer:
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)