Skip to content

Add DTO projection support via projectAs() query method#19135

Merged
markstory merged 6 commits into
5.nextfrom
5.next-dto-projection
Dec 31, 2025
Merged

Add DTO projection support via projectAs() query method#19135
markstory merged 6 commits into
5.nextfrom
5.next-dto-projection

Conversation

@dereuromark
Copy link
Copy Markdown
Member

@dereuromark dereuromark commented Dec 17, 2025

Summary

  • Add projectAs() method on SelectQuery to project results directly into readonly DTOs instead of Entity objects
  • Support two DTO creation styles: DtoMapper (reflection-based) and createFromArray() factory method
  • Add #[CollectionOf] attribute for specifying collection element types
  • Handle nested association hydration into DTOs

Usage

// Simple DTO projection
$articles = $articlesTable->find()
    ->projectAs(ArticleDto::class)
    ->toArray();

// With associations
$articles = $articlesTable->find()
    ->contain(['Authors', 'Comments'])
    ->projectAs(ArticleDto::class)
    ->toArray();

DTO classes can use either constructor parameters (mapped via reflection):

readonly class ArticleDto {
    public function __construct(
        public int $id,
        public string $title,
        public ?AuthorDto $author = null,
        #[CollectionOf(CommentDto::class)]
        public array $comments = [],
    ) {}
}

Or a createFromArray() factory method for full control over mapping:

readonly class ArticleDto {
    public function __construct(
        public int $id,
        public string $title,
        public ?AuthorDto $author = null,
    ) {}
    
    public static function createFromArray(array $data, bool $ignoreMissing = false): self {
        return new self(
            id: $data['id'],
            title: $data['title'],
            author: isset($data['author']) 
                ? AuthorDto::createFromArray($data['author'], $ignoreMissing)
                : null,
        );
    }
}

Performance

DTOs use approximately 3x less memory than Entity objects while providing similar hydration speed. The createFromArray() method is ~2.5x faster than Entity hydration.

Refs #19131

Adds ability to project ORM query results directly into readonly DTOs
instead of Entity objects. This provides a more memory-efficient and
immutable way to fetch read-only data.

New features:
- projectAs() method on SelectQuery to specify a DTO class
- DtoMapper using PHP 8 reflection with named constructor arguments
- CollectionOf attribute for specifying array collection element types
- Support for createFromArray() factory method (cakephp-dto style)
- Nested association hydration into DTOs
@dereuromark dereuromark added this to the 5.3.0 milestone Dec 17, 2025
* public function __construct(
* public int $id,
* public string $title,
* #[CollectionOf(CommentDto::class)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be a good test to find out how the community likes APIs using attributes.

Comment thread src/ORM/ResultSetFactory.php
@dereuromark dereuromark marked this pull request as ready for review December 17, 2025 11:32
Comment thread src/ORM/DtoMapper.php
Comment thread src/ORM/Attribute/CollectionOf.php
@ADmad
Copy link
Copy Markdown
Member

ADmad commented Dec 17, 2025

How will fields like _joinData which are auto added to entities for BTM association be handled when using a DTO? Will they be dropped/ignored?

@dereuromark
Copy link
Copy Markdown
Member Author

They will be mapped if present in the dto specs as property. I will check if there is a test.

@ADmad
Copy link
Copy Markdown
Member

ADmad commented Dec 17, 2025

readonly class ArticleDto {
    public function __construct(
        public int $id,
        public string $title,
        public ?AuthorDto $author = null,
        #[CollectionOf(CommentDto::class)]
        public array $comments = [],
    ) {}
}

This looks similar to how an entity would look for the entity implementation I have made https://github.com/ADmad/cakephp-entity/blob/master/tests/test_app/TestApp/Model/Entity/Article.php

<?php
declare(strict_types=1);

namespace TestApp\Model\Entity;

use ADmad\Entity\Datasource\Entity;
use BackedEnum;
use DateTimeInterface;

class Article extends Entity
{
    protected ?int $id;
    protected ?int $author_id;
    protected $user_id;
    protected string $title;
    protected ?string $body;
    protected string|BackedEnum $published;
    protected ?DateTimeInterface $created;
    protected ?DateTimeInterface $modified;
    protected ?DateTimeInterface $updated;
    protected ?DateTimeInterface $date_specialed;
    protected array $tags;
    protected ?Author $author;
    protected array $comments;
    protected array $unaproved_comments;
    protected $article;
    protected $not_in_schema;

    protected $comments_notype;
    protected $user;

    public function isRequired(): bool
    {
        return true;
    }
}

public string $class,
) {
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have a simple attribute with all possible configs? Here is an example with name and collectionOf, both are optional

 ```php
  readonly class ArticleDto {
      public function __construct(
          public int $id,
          #[FieldDto(name: "main_title")] public string $title,
          #[FieldDto(collectionOf: CommentDto::class)]
          public array $comments = [],
      ) {}
  } ```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt that bit of scope creep? Renaming the fields is a bit of different concern
Usually thats handled in the DTOs using from/to kind of mapping configs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing attribute aliasing/inflection is a good scenario for createFromArray() as well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you propose here @markstory ?
Maybe you can clarify

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming the fields is a bit of different concern

Being able to rename fields would be quite handy for tasks like report generation. For e.g. being able to represent author.name as author_name in xls/csv rendering.

Copy link
Copy Markdown
Member Author

@dereuromark dereuromark Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, what would be the naming/signature be then for this attribute?
If we brainstorm on full specs now first, I can finish this up in one batch.

And:
Should I implement this as a replacement for #[CollectionOf], or keep both (with #[CollectionOf] as shorthand)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can clarify

My thought here is that we build provide simple constructor support, and if userland code need attribute renames, inflections or anything along those lines they can implement createFromArray(). We can add more attributes and more 'configuration' to DTO mapping later when we better understand the gaps/limitations in our simple solution.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so we address this later if needed.

DateTime and other objects type-hinted on constructor parameters were
incorrectly being passed to map() as if they were arrays for nested DTO
mapping. Added is_array() check to only map actual arrays.

Added test cases for DateTime object handling.
Previously, DTO mapping happened in ResultSetFactory::groupResult()
during hydration, BEFORE formatters ran. This caused issues with
behaviors like TagBehavior that add formatters expecting arrays/entities.

Now DTO projection:
1. Returns arrays from groupResult() when projectAs() is used
2. Applies DTO mapping as a FINAL formatter after all others
3. Allows behavior formatters to work with arrays/entities first

This aligns with the user-space approach suggested in PR review:
disableHydration()->formatResults() but built-in for convenience.
Use hydrateDto() (now public) instead of getDtoMapper()->map()
directly so that DTOs with static createFromArray() factory methods
(cakephp-dto style) continue to work.
@dereuromark
Copy link
Copy Markdown
Member Author

@ADmad
The sandbox examples show it live if run locally:
dereuromark/cakephp-sandbox@b5ece85
ignored unless you set a field for it, then it will be included as array/DTO:

Option 1: ?array (simple)

  readonly class UserWithMatchingDto {
      public function __construct(
          public int $id,
          public string $username,
          public ?array $_matchingData = null,
      ) {}
  }

Option 2: ?MatchingDataDto (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,
      ) {}
  }

@dereuromark
Copy link
Copy Markdown
Member Author

dereuromark commented Dec 17, 2025

To support a wider range of DTO libs, we might want to create a DtoRegistry or some Configure approach on how to map them.
Or simply allow a factory to be passed:

  // For Valinor (external mapper)
  ->projectAs(UserDto::class, fn($data) => $valinorMapper->map(UserDto::class, $data))

Implementation:

  public function projectAs(string $dtoClass, ?callable $factory = null): static {
      $this->_dtoClass = $dtoClass;
      $this->_dtoFactory = $factory;
      return $this;
  }

@markstory
Copy link
Copy Markdown
Member

->projectAs(UserDto::class, fn($data) => $valinorMapper->map(UserDto::class, $data))

Is this something that is needed now? I think we should see if we can get the design right and get some adoption before we need to make it pluggable.

@dereuromark
Copy link
Copy Markdown
Member Author

Providing the factory possibility wouldn't hurt IMO, since it would not be used in default cases anyway.
Without it using custom DTO libraries or any mapping library would be not easy to achieve I assume.

@rochamarcelo
Copy link
Copy Markdown
Contributor

Does this feature works with column type Enum?

//In table initialize
 $schema->setColumnType('status', EnumType::from(\App\Enum\Status::class));

//In DTO class
`public \App\Enum\Status $status` 

@dereuromark
Copy link
Copy Markdown
Member Author

dereuromark commented Dec 18, 2025

I would think so. Enums are also just classes. The DTO just has to define them. See test cases in cake-dto.

//EDIT @rochamarcelo I added a real life test in sandbox for enums, works.

@dereuromark
Copy link
Copy Markdown
Member Author

Are we then able to move forward?

Copy link
Copy Markdown
Member

@markstory markstory left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good to me. I think this feature will require documentation on what a DTO is, and give developers context on when/why they would use projectAs() instead of getting results as entities & arrays.

Comment on lines +22 to +23
public ?DateTime $created = null,
public ?DateTime $modified = null,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need special casing for application enums or other custom application type mappings?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per my sandbox example code this all works as is.

dereuromark added a commit to cakephp/docs that referenced this pull request Dec 30, 2025
Documents the new projectAs() method for projecting ORM results
into DTOs instead of Entity objects, including the #[CollectionOf]
attribute for nested associations.

Refs cakephp/cakephp#19135
@markstory markstory merged commit 24448d7 into 5.next Dec 31, 2025
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants