+ This demo shows projectAs() with Posts having BelongsToMany Tags.
+ The pivot table data (_joinData) is automatically hydrated into the DTO.
+
id}: {$entity->title}\n";
+ echo " Tags: " . count($entity->tags) . "\n";
+ foreach ($entity->tags as $tag) {
+ echo " - {$tag->label}";
+ if ($tag->_joinData) {
+ echo " (_joinData id: {$tag->_joinData->id}, type: " . get_class($tag->_joinData) . ")";
+ }
+ echo "\n";
+ }
+}
+?>
+
+id}: {$dto->title}\n";
+ echo " Tags: " . count($dto->tags) . "\n";
+ foreach ($dto->tags as $tag) {
+ echo " - {$tag->label}";
+ if ($tag->_joinData) {
+ echo " (_joinData id: {$tag->_joinData->id}, type: " . get_class($tag->_joinData) . ")";
+ }
+ echo "\n";
+ }
+}
+?>
+
+// PostDto with tags collection
+readonly class PostDto
+{
+ public function __construct(
+ public int $id,
+ public string $title,
+ public ?string $content = null,
+ #[CollectionOf(TagDto::class)]
+ public array $tags = [],
+ ) {}
+}
+
+// TagDto with _joinData for pivot table
+readonly class TagDto
+{
+ public function __construct(
+ public int $id,
+ public string $label,
+ public ?TaggedDto $_joinData = null, // Pivot table data
+ ) {}
+}
+
+// TaggedDto for the pivot/junction table
+readonly class TaggedDto
+{
+ public function __construct(
+ public int $id,
+ public int $tag_id,
+ public int $fk_id,
+ public ?string $fk_model = null,
+ ) {}
+}
diff --git a/templates/DtoProjection/benchmark.php b/templates/DtoProjection/benchmark.php
new file mode 100644
index 00000000..74e97ede
--- /dev/null
+++ b/templates/DtoProjection/benchmark.php
@@ -0,0 +1,73 @@
+ $results
+ * @var int $iterations
+ */
+?>
+
+ Comparing performance of different hydration approaches.
+ Query: Users->find()->contain(['Roles'])->limit(50) × = $iterations ?> iterations.
+
| Method | +Time (ms) | +Memory Delta | +Notes | +
|---|---|---|---|
| = h($method) ?> | += number_format($data['time'], 2) ?> ms | += number_format($data['memory']) ?> bytes | ++ 0 ? $data['time'] / $baseTime : 0; + if ($ratio < 1) { + echo sprintf('%.1fx faster than Entity', 1 / $ratio); + } elseif ($ratio > 1) { + echo sprintf('%.1fx slower than Entity', $ratio); + } else { + echo 'baseline'; + } + ?> + | +
+ This demo shows projectAs() with Roles containing HasMany Users.
+ The #[CollectionOf] attribute specifies the DTO type for array collections.
+
id}: {$entity->name}\n";
+ echo " Users: " . count($entity->users) . "\n";
+ foreach ($entity->users as $user) {
+ echo " - {$user->username} (" . get_class($user) . ")\n";
+ }
+}
+?>
+
+id}: {$dto->name}\n";
+ echo " Users: " . count($dto->users) . "\n";
+ foreach ($dto->users as $user) {
+ echo " - {$user->username} (" . get_class($user) . ")\n";
+ }
+}
+?>
+
+readonly class SimpleRoleDto
+{
+ public function __construct(
+ public int $id,
+ public string $name,
+ #[CollectionOf(SimpleUserDto::class)] // Required for array collections
+ public array $users = [],
+ ) {}
+}
diff --git a/templates/DtoProjection/index.php b/templates/DtoProjection/index.php
new file mode 100644
index 00000000..cf630f04
--- /dev/null
+++ b/templates/DtoProjection/index.php
@@ -0,0 +1,73 @@
+ $entities
+ * @var array<\App\Dto\SimpleUserDto> $dtosSimple
+ * @var array<\App\Dto\UserProjectionDto> $dtosPlugin
+ */
+?>
+
+ This demo shows projectAs() with Users containing BelongsTo Roles.
+
id}: {$entity->username}";
+ if ($entity->role) {
+ echo " (Role: {$entity->role->name})";
+ }
+ echo "\n";
+ echo " Type: " . get_class($entity) . "\n";
+}
+?>
+
+Uses reflection-based mapping with typed constructor parameters.
+id}: {$dto->username}";
+ if ($dto->role) {
+ echo " (Role: {$dto->role->name})";
+ }
+ echo "\n";
+ echo " Type: " . get_class($dto) . "\n";
+}
+?>
+
+Uses generated createFromArray() factory method.
getId()}: {$dto->getUsername()}";
+ if ($dto->getRole()) {
+ echo " (Role: {$dto->getRole()->getName()})";
+ }
+ echo "\n";
+ echo " Type: " . get_class($dto) . "\n";
+}
+?>
+
+// DtoMapper style (readonly class with typed constructor)
+$users = $usersTable->find()
+ ->contain(['Roles'])
+ ->projectAs(SimpleUserDto::class)
+ ->toArray();
+
+// cakephp-dto style (generated class with createFromArray)
+$users = $usersTable->find()
+ ->contain(['Roles'])
+ ->projectAs(UserProjectionDto::class)
+ ->toArray();
diff --git a/templates/DtoProjection/matching.php b/templates/DtoProjection/matching.php
new file mode 100644
index 00000000..b2521bc8
--- /dev/null
+++ b/templates/DtoProjection/matching.php
@@ -0,0 +1,111 @@
+ $entities
+ * @var array<\App\Dto\SimpleUserDto> $dtosWithout
+ * @var array<\App\Dto\UserWithMatchingDto> $dtosWithArray
+ * @var array<\App\Dto\UserWithMatchingDtoTyped> $dtosWithTyped
+ * @var array $rawArrays
+ */
+?>
+
+ This demo shows projectAs() with matching() queries.
+ Key insight: _matchingData can be typed as array
+ OR as a nested DTO for full type safety.
+
id}: {$entity->username}\n";
+ if (isset($entity->_matchingData['Roles'])) {
+ $role = $entity->_matchingData['Roles'];
+ echo " _matchingData[Roles]: {$role->name} (" . get_class($role) . ")\n";
+ }
+}
+?>
+
+Using SimpleUserDto - matching data is silently ignored.
id}: {$dto->username}\n";
+ echo " _matchingData: not available (no property)\n";
+}
+?>
+
+arrayUsing UserWithMatchingDto - matching data as raw array.
id}: {$dto->username}\n";
+ if ($dto->_matchingData !== null) {
+ foreach ($dto->_matchingData as $alias => $data) {
+ $type = is_array($data) ? 'array' : get_class($data);
+ $name = is_array($data) ? ($data['name'] ?? '?') : $data->name;
+ echo " _matchingData[{$alias}]: {$name} ({$type})\n";
+ }
+ }
+}
+?>
+
+MatchingDataDtoUsing UserWithMatchingDtoTyped - fully typed nested DTOs!
id}: {$dto->username}\n";
+ if ($dto->_matchingData !== null) {
+ echo " _matchingData type: " . get_class($dto->_matchingData) . "\n";
+ if ($dto->_matchingData->Roles !== null) {
+ $role = $dto->_matchingData->Roles;
+ echo " _matchingData->Roles: {$role->name} (" . get_class($role) . ")\n";
+ }
+ }
+}
+?>
+
+// Option 1: _matchingData as array (simple)
+readonly class UserWithMatchingDto
+{
+ public function __construct(
+ public int $id,
+ public string $username,
+ public ?array $_matchingData = null,
+ ) {}
+}
+
+// Option 2: _matchingData as typed DTO (full type safety)
+readonly class MatchingDataDto
+{
+ public function __construct(
+ public ?SimpleRoleDto $Roles = null, // Property name = association alias
+ ) {}
+}
+
+readonly class UserWithMatchingDtoTyped
+{
+ public function __construct(
+ public int $id,
+ public string $username,
+ public ?MatchingDataDto $_matchingData = null,
+ ) {}
+}
+
+?array - Quick and simple, data stays as arrays?MatchingDataDto - Full type safety, nested DTOs auto-mappedRoles)getId() . "\n"; + echo 'Username: ' . $user->getUsername() . "\n"; + echo 'Email: ' . $user->getEmail() . "\n"; + echo "---\n"; +} +?>+ +
getId() . "\n";
+ echo 'Username: ' . $user->getUsername() . "\n";
+ $role = $user->getRole();
+ if ($role) {
+ echo 'Role Type: ' . get_class($role) . "\n";
+ echo 'Role ID: ' . $role->getId() . "\n";
+ echo 'Role Name: ' . $role->getName() . "\n";
+ } else {
+ echo "Role: NULL\n";
+ }
+ echo "---\n";
+}
+?>
+
+getId() . "\n";
+ echo 'Name: ' . $role->getName() . "\n";
+ $users = $role->getUsers();
+ echo 'Users count: ' . count($users) . "\n";
+ foreach ($users as $user) {
+ echo ' - User Type: ' . get_class($user) . "\n";
+ echo ' User: ' . $user->getUsername() . "\n";
+ }
+ echo "---\n";
+}
+?>
+
+id . "\n";
+ echo 'Username: ' . $user->username . "\n";
+ if ($user->role) {
+ echo 'Role Type: ' . get_class($user->role) . "\n";
+ echo 'Role Name: ' . $user->role->name . "\n";
+ }
+ echo "---\n";
+}
+?>
+
+This demonstrates DTO projection with CakePHP's BackedEnum type casting. The status field uses \Sandbox\Model\Enum\UserStatus enum.
getId() . "\n";
+ echo 'Username: ' . $user->getUsername() . "\n";
+ echo 'Email: ' . $user->getEmail() . "\n";
+ $status = $user->getStatus();
+ if ($status) {
+ echo 'Status Type: ' . get_class($status) . "\n";
+ echo 'Status Name: ' . $status->name . "\n";
+ echo 'Status Value: ' . $status->value . "\n";
+ echo 'Status Label: ' . $status->label() . "\n";
+ } else {
+ echo "Status: NULL\n";
+ }
+ echo "---\n";
+}
+?>
+
+id . "\n";
+ echo 'Username: ' . $user->username . "\n";
+ echo 'Email: ' . $user->email . "\n";
+ $status = $user->status;
+ if ($status) {
+ echo 'Status Type: ' . get_class($status) . "\n";
+ echo 'Status Name: ' . $status->name . "\n";
+ echo 'Status Value: ' . $status->value . "\n";
+ echo 'Status Label: ' . $status->label() . "\n";
+ } else {
+ echo "Status: NULL\n";
+ }
+ echo "---\n";
+}
+?>