Repository navigation
RFC: Make find() always return entities — explicit type-tracked reshaping via map() #19482
Description
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:
- Hydration on/off — already addressed by Add Table::findUnhydrated() and UnhydratedSelectQuery for type-safe non-hydrated reads #19441 (
unhydratedFind()→ arrays). - 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 typedEntityInterface|nullwhile returning aFooDtoat runtime. This taint is live in 5.x today. formatResults(OVERWRITE)reshaping — the formatter closure can return any shape. The generic system cannot track this, and cruciallyfind(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{...}|nullPrototype 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$thisbut returns a different generic); a small helper typed at the class bound resolves it cleanly, the same var-tag narrowing idiomTable::find()already uses. - Widening the class bound to include
object(needed forprojectAs, see below) changes the message of a pre-existing baselined entry atSelectQuery::find()— astatic-covariance friction wherecallFinder()'s return can't be provenstatic. 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|objectThis 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 viaclass-string<T>(returnstatic<T>) — docblock-only change to already-shipped code, makes the live DTO taint honest. Requires widening the class bound to includeobject, which in turn de-baselines the pre-existingSelectQuery::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 atmap(). 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)
- Remove shape-changing
formatResults(OVERWRITE); reshape only viamap(). - Remove
disableHydration()(already slated by Add Table::findUnhydrated() and UnhydratedSelectQuery for type-safe non-hydrated reads #19441). - Finders may no longer reshape under
find()(the 5.x deprecation becomes a hard error).find()is now honestlySelectQuery<TEntity>. - Carve out built-in reshapers (
find('list')-> typedtoList()entry, kept as a thin BC alias or removed).
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
map()naming —map()vstransform()vsproject()(noteprojectAs()already exists for DTOs)?- Bound widening:
EntityInterface|array|object, or just collapse toobjectsince entities and arrays are both already objects/arrays?objectwould not coverarray— so the three-way union is likely the minimal honest bound. - Carve out
find('list')into a typed entry, or keep it plus a small finder-name type map? - 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
LordSimal commentedon May 30, 2026
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 thefind()method.Not really sure how we should handle the
formatResultssituation 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 notreshape()? 😁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.
bancer commentedon Jun 30, 2026
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
firstmethod:find()->first(FooDto::class)what would be read as plain English "find first FooDto". Orfind()->first(MyEntity::class),find()->first('array').nook24 commentedon Aug 21, 2026
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 likelistandthreaded. For example wouldbecome to this?
LordSimal commentedon Aug 21, 2026
@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.
nook24 commentedon Aug 21, 2026
@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.
LordSimal commentedon Aug 21, 2026
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 😉