Skip to content

Resolve Builder::find($mixed) widening to Collection|TModel|null#1002

Open
alies-dev wants to merge 3 commits into
masterfrom
975-builder-find-mixed-fix
Open

Resolve Builder::find($mixed) widening to Collection|TModel|null#1002
alies-dev wants to merge 3 commits into
masterfrom
975-builder-find-mixed-fix

Conversation

@alies-dev
Copy link
Copy Markdown
Collaborator

Issue to Solve

Builder::find/findOrFail/findOrNew/findOr widen to Collection<int, TModel>|TModel|null when the argument is mixed. Psalm's TemplateInferredTypeReplacer cannot place mixed in either branch of the stub's T is (array|Arrayable) conditional, so it combines both. Downstream $model->prop = ... then surfaces as UndefinedPropertyAssignment on the spurious Collection branch.

The motivating real-world case is invoiceninja's Client::withTrashed()->find($_client->client_id) where $_client is a \stdClass from \DB::select() — every property read is mixed even though the runtime value is scalar.

Related

Closes #975

Solution Description

Larastan ships an @method overload pair on the stub. Psalm stores pseudo-methods last-write-wins in $storage->pseudo_methods, so the overload approach is not portable. New BuilderFindMixedHandler (MethodReturnTypeProviderInterface) collapses the mixed case to the scalar-id branch via the same trade-off:

  • findTModel|null
  • findOrFail / findOrNewTModel
  • findOrTModel|TValue (TValue from the callback closure at arg 1 or 2)

Concrete scalar/array arguments still narrow through the stub conditional.

Covers Builder and the four relation classes that re-declare the methods. BelongsToMany's &object{pivot: TPivotModel} intersection is reconstructed from template index 2; other relations have no pivot.

The accepted soundness gap (mixed-truly-array → wrong narrowing) is pinned in FindMixedTradeOffTest.phpt so future readers see it.

Verification: 423/423 type tests, 632 unit, app exit 0, psalm self-check clean, cs clean.

Checklist

  • Tests cover the change (4 new .phpt files under tests/Type/tests/Builder/)
  • Documentation: handler docblock + test header docs describe the trade-off

Builder::find/findOrFail/findOrNew/findOr widen to
`Collection<int, TModel>|TModel|null` when the argument is `mixed`,
because Psalm's `TemplateInferredTypeReplacer` cannot place `mixed` in
either branch of the stub's `T is (array|Arrayable)` conditional and
combines both. Downstream `$model->prop = ...` then surfaces as
`UndefinedPropertyAssignment` on the spurious Collection branch — the
exact false positive invoiceninja hit on
`Client::withTrashed()->find($_client->client_id)` where
`$_client->client_id` is `mixed` from `\DB::select()`'s `list<stdClass>`.

Larastan ships an `@method` overload pair on the stub, but Psalm stores
pseudo-methods last-write-wins in `$storage->pseudo_methods` — overloads
are not portable. `BuilderFindMixedHandler` makes the same trade-off via
a return-type provider: when `$id` is `mixed`, collapse to the
scalar-id branch (`TModel|null` / `TModel` / `TModel|TValue` for
`findOr`). Concrete scalar/array arguments still narrow through the
stub conditional.

Covers `Builder` and the relation classes that re-declare the methods:
`BelongsToMany` (with the `&object{pivot: TPivotModel}` intersection
reconstructed from template index 2), `HasManyThrough`,
`HasOneOrManyThrough`, `HasOneOrMany`. `findOr`'s `TValue` is extracted
from the callback closure at arg 1 or arg 2.

The accepted soundness gap (caller passes `mixed` that is actually an
array → handler reports the scalar branch) is pinned in
`FindMixedTradeOffTest.phpt` so it stays visible. The taint regression
guard in `SafeSsrfEloquentFetch.phpt` drops the now-redundant
`MixedArgument` suppression; the `$id = \$request->input('id')`
assignment still needs `MixedAssignment`.

Closes #975
@alies-dev alies-dev self-assigned this May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stub: Builder::find($mixed) widens to Collection|TModel|null

2 participants