RFC: Make find() always return entities — explicit type-tracked reshaping via map()
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 #19441 (
unhydratedFind()→ arrays). - DTO projection —
projectAs(class-string $dtoClass)shipped in #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 #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.
Source: cakephp/cakephp