Skip to content

RFC: Make find() always return entities — explicit type-tracked reshaping via map() #19482

Open

Description

@dereuromark

Summary

Follow-up discussion to #19441 (which split hydration into a type-honest unhydratedFind() / UnhydratedSelectQuery).

Today Table::find('x')->first() is statically typed EntityInterface|null, but the runtime result can be an array, a DTO, or any other shape when the query uses projectAs(), formatResults(..., OVERWRITE), or disableHydration(). The type is then a lie, and it is invisible at the call site — $x = $table->find('auth')->...->first() gives no hint that $x is actually an array or a DTO.

I see also hacks like this then in the code:

/** @var array|null $user */ // phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
// @phpstan-ignore-next-line varTag.type
$user = $this->fetchTable('Users')->find('auth')
        ->where(['username' => $request->getData('username')])
        ->first();

With the inline annotation we "hack" phpstan in some way, and we dont know what the real values returned are until we actually really test it.

This RFC proposes a path to make find() always honestly return entities, and to make any shape change explicit and type-tracked at the call site.

Root cause

Three distinct axes corrupt the result type:

  1. Hydration on/off — already addressed by Add Table::findUnhydrated() and UnhydratedSelectQuery for type-safe non-hydrated reads #19441 (unhydratedFind() → arrays).
  2. DTO projection — projectAs(class-string $dtoClass) shipped in Add DTO projection support via projectAs() query method #19135, already in 5.3. It hydrates rows into DTO objects but is annotated as returning $this, so the generic never rebinds. find()->projectAs(FooDto::class)->first() is typed EntityInterface|null while returning a FooDto at runtime. This taint is live in 5.x today.
  3. formatResults(OVERWRITE) reshaping — the formatter closure can return any shape. The generic system cannot track this, and crucially find(string $type): SelectQuery<TEntity> is fixed at the signature, so whatever a finder does internally is erased from the caller's static type (dispatch erasure).

Proposal

Two complementary, type-tracked rebinds, plus a finder reshape policy.

A. Fix projectAs() to rebind the generic (5.next quick win)

projectAs() takes a class-string, so PHPStan can infer the target type directly — no closure inference needed. This is a pure docblock change on already-shipped code:

/**
 * @template T of object
 * @param class-string<T> $dtoClass The DTO class name
 * @return static<T>
 */
public function projectAs(string $dtoClass)

This mirrors how disableHydration() already rebinds the generic via its return annotation (static<array<string,mixed>>) — projectAs is simply inconsistent today by returning a bare $this.

B. Add map() for arbitrary closure reshaping

For reshapes that have no class-string to infer from (array projections, computed shapes):

/**
 * @template TNew of \Cake\Datasource\EntityInterface|array
 * @param \Closure(\Cake\Datasource\ResultSetInterface<array-key, TSubject>, \Cake\ORM\Query\SelectQuery<TSubject>): iterable<TNew> $mapper
 * @return \Cake\ORM\Query\SelectQuery<TNew>
 */
public function map(Closure $mapper): SelectQuery;

Reshape becomes visible at the call site and the type follows it:

$users = $table->find('auth')->first();             // EntityInterface|null  (honest)
$dto   = $table->find('all')->projectAs(FooDto::class)->first();  // FooDto|null
$rows  = $table->find('all')->map(/* ... */)->first();           // array{...}|null

Prototype results (validated with PHPStan dumpType, level 8)

Pure docblock generics — no PHPStan extension required:

Call-site expression Inferred type
find('all')->first() EntityInterface|null
find('all')->projectAs(FooDto::class) SelectQuery<FooDto>
find('all')->projectAs(FooDto::class)->first() FooDto|null
find('all')->projectAs(FooDto::class)->firstOrFail() FooDto
find('all')->map(rows -> ['id'=>1]) SelectQuery<array{id: int}>
...->first() array{id: int}|null
find('all')->map(rows -> [1,2,3])->first() rejected (int not in the bound)
find('all')->map(rows -> $r)->first() (identity) EntityInterface|null

The call-site inference above is the solid, verified result. Two implementation notes from the spike, stated honestly:

  • The map() subtlety is the same-instance generic rebind (it mutates $this but returns a different generic); a small helper typed at the class bound resolves it cleanly, the same var-tag narrowing idiom Table::find() already uses.
  • Widening the class bound to include object (needed for projectAs, see below) changes the message of a pre-existing baselined entry at SelectQuery::find() — a static-covariance friction where callFinder()'s return can't be proven static. Changing the bound de-baselines it, so it resurfaces and needs handling (re-baseline or a narrowing annotation) as part of the implementation. This is not a blocker for the proposal, but it is real work, not "free".

The class bound is already violated — decision needed now, not later

The class template constrains TSubject to \Cake\Datasource\EntityInterface|array. DTOs are plain object, outside that bound. Since projectAs() already ships in 5.3, the result set legitimately holds out-of-bound values today. To type projectAs honestly the bound has to widen to include object:

@template-covariant TSubject of \Cake\Datasource\EntityInterface|array|object

This is no longer a hypothetical 6.x "if DTO happens" question — it is required to make the already-shipped DTO path type-honest. Caveat from the spike: widening the bound de-baselines the pre-existing SelectQuery::find() covariance entry noted above, so the implementation has to address that entry too.

Other constraints

Built-in reshaping finders (find('list'), find('threaded'), find('combolist')) still lie under a plain SelectQuery<TEntity> type. They need either a dedicated typed entry point (e.g. a toList() parallel to unhydratedFind()) or a small static finder-name type map. A general per-finder return-type PHPStan extension is not needed if finders are barred from reshaping.

Suggested sequencing

5.next (additive, BC-safe)

  • Fix projectAs() to rebind its return generic via class-string<T> (return static<T>) — docblock-only change to already-shipped code, makes the live DTO taint honest. Requires widening the class bound to include object, which in turn de-baselines the pre-existing SelectQuery::find() covariance entry (must be re-handled).
  • Add SelectQuery::map() for arbitrary closure reshaping.
  • Soft-deprecate shape-changing formatResults(..., OVERWRITE) (docblock only, no runtime trigger), pointing at map(). Decoration use (APPEND / PREPEND) stays supported.
  • Runtime deprecation when a hydrated finder yields a non-entity (cheap single-row check in first() / firstOrFail()), so existing lies surface in users' test suites before 6.0.
  • Leave built-in reshaping finders behavior untouched; document the planned 6.x carve-out.

6.x (breaking cleanup)

End state

find() always entities, unhydratedFind() arrays, projectAs() DTOs, map() explicit visible reshape, toList() typed built-in projection. No disableHydration(), no formatResults(OVERWRITE), no dispatch-erasure lies — and no PHPStan extension needed.

Open questions

  1. map() naming — map() vs transform() vs project() (note projectAs() already exists for DTOs)?
  2. Bound widening: EntityInterface|array|object, or just collapse to object since entities and arrays are both already objects/arrays? object would not cover array — so the three-way union is likely the minimal honest bound.
  3. Carve out find('list') into a typed entry, or keep it plus a small finder-name type map?
  4. How early in the 5.x line can the reshape deprecation land? It needs a full minor-version runway before 6.0 turns it into a hard error.

Activity

  1. added this to the 5.4.0 milestone on May 30, 2026
  2. LordSimal commented on May 30, 2026

    @LordSimal
    Member

    In general I very much welcome the direction to move our codebase to a more statically friendly typed direction.

    This definitely means, we need to move/change the way how e.g. find('list') works into a separate method instead of stuffing everything into the find() method.

    Not really sure how we should handle the formatResults situation as in general users can do whatever they want in that closure.

    About the map() name - I'd recommend not using collection related methods here as in the past we proxied those as you know. This would confuse long timers using this and maybe mixing this up. Why not reshape()? 😁

    About the DTO subject - I have not yet used it in any way or form, so can't really speak from experience. It just feels to me, that the difference between using a normaly hydrated query and a DTO projected query gets very fuzzy/minimal if we keep going into this more statically typed direction.

  3. bancer commented on Jun 30, 2026

    @bancer
    Contributor

    I am probably too late with my 2 cents. In terms of readability it would be nice to be able to specify the return type as the argument of first method: find()->first(FooDto::class) what would be read as plain English "find first FooDto". Or find()->first(MyEntity::class), find()->first('array').

  4. modified the milestones: 5.4.0, 5.5.0 on Jul 26, 2026
  5. nook24 commented on Aug 21, 2026

    @nook24
    Contributor

    Remove disableHydration()

    I run a grep over my project and found ~640 queries using ->disableHydration(). Removing this option would require to touch every single one of them.

    Also, should unhydratedFind() support all find types like list and threaded. For example would

    $threadedAcos = $AcosTable->find('threaded')
        ->disableHydration()
        ->all();

    become to this?

    $threadedAcos = $AcosTable->unhydratedFind('threaded')
        ->all();
  6. LordSimal commented on Aug 21, 2026

    @LordSimal
    Member

    @nook24 yes, thats exactly it.

    I'd recommend you give our upgrade tool a try because we have a custom rector rule which does that conversion for you automatically.
    See before
    https://github.com/cakephp/upgrade/blob/5.x/tests/test_apps/original/RectorCommand-testApply54/src/SomeTest.php
    and after
    https://github.com/cakephp/upgrade/blob/5.x/tests/test_apps/upgraded/RectorCommand-testApply54/src/SomeTest.php

    And yes, unhydratedFind('threaded') should work here.

    If there are issues with the ugprade tool feel free to create a issue or write in support chat.

  7. nook24 commented on Aug 21, 2026

    @nook24
    Contributor

    @LordSimal I didn't know that Rector could do this, but nice to see you guy had this in mind :) I'm currently still on CakePHP 5.2 but the migration to 5.4 is scheduled till the end of the year.

  8. LordSimal commented on Aug 21, 2026

    @LordSimal
    Member

    Rector is very powerful, but also very specific on how you configure it. With the help of AI creating custom rector rules has become much much easier and faster. But as always in AI, its not perfect and needs adjustment 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions