Skip to content

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:

OperationRuntime Librariesphp-collective/dto
Class loadingParse attributes/annotationsLoad pre-generated class
Property accessReflection or magic methodsDirect property access
Type checkingRuntime validationPHP native types
Key mappingRuntime calculationPre-computed lookup table
Collection creationRuntime type resolutionPre-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

  1. First load: Generated DTOs are slightly slower on first load (more code to parse), but opcache eliminates this
  2. Subsequent access: Generated DTOs match or beat native PHP classes
  3. Complex DTOs: The performance gap widens with nested DTOs and collections

Optimization Tips

1. Use opcache in Production

Generated DTOs benefit significantly from opcache:

ini
; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0  ; Disable in production

2. Enable strictTypes so Transforms Get Compiled

Fields using transformFrom / transformTo are compiled into a direct call in the generated fast-path methods:

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

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

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

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

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

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

php
// Each level adds instantiation cost
$order->getCustomer()->getAddress()->getCity()->getName();

// Consider flattening if you frequently access deep values
$order->getCustomerCity();  // Computed during creation

9. 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:

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

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

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

Large Collections

For very large collections (10,000+ items), consider:

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

php
// Potential memory issue with deep trees
$category = new CategoryDto([
    'children' => [
        ['children' => [['children' => [...]]]]
    ],
]);

// Solution: limit depth or use lazy loading

Profiling DTOs

Using Xdebug Profiler

ini
; php.ini
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%p

Using Blackfire

bash
blackfire run php your-script.php

Simple Timing

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

  1. Profile first - identify actual bottlenecks
  2. Optimize hot paths - focus on frequently executed code
  3. 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

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

LibraryRelative SpeedNotes
php-collective/dto1x (baseline)Generated code
Native readonly0.8x fasterNo features
spatie/laravel-data5-6x slowerRuntime reflection
cuyz/valinor6-8x slowerRuntime 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

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

Memory Usage (1,000 Users)

Full Entity Hydration:     ~2.8 MB
Array + DTO Hydration:     ~0.4 MB  (7x less memory)

When to Use Each Approach

ScenarioRecommendationWhy
API list endpointsArray + DTOOnly fetch displayed fields
Admin dashboardsArray + DTORead-only, specific columns
Entity updatesFull EntityNeed UnitOfWork for persistence
Complex business logicFull EntityNeed relation traversal
Export/reportsArray + DTOMemory efficient for large sets
Real-time feedsArray + DTOMinimal latency

Benchmark Code

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

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

php
/**
 * @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)

Released under the MIT License.