Skip to content

Use static instead of $this in relation factory return templates#917

Open
alies-dev wants to merge 4 commits into
masterfrom
worktree-913-belongs-to-without-global-scopes
Open

Use static instead of $this in relation factory return templates#917
alies-dev wants to merge 4 commits into
masterfrom
worktree-913-belongs-to-without-global-scopes

Conversation

@alies-dev
Copy link
Copy Markdown
Collaborator

Summary

\$this->belongsTo(X::class)->withoutGlobalScopes() (and similar chained calls on relation factories inside Model methods) raised MixedMethodCall because the intermediate factory call collapsed to mixed. The root cause sits in the stub: @return Relation<TRelated, \$this> in HasRelationships left the TDeclaringModel slot unsubstituted under Psalm 7. Swapping \$this for static substitutes correctly and produces the right concrete generic type.

The receiver of any relation factory is always the model itself, so static and \$this are semantically equivalent at the TDeclaringModel slot. This is a stub divergence from Laravel source documented inline in the trait.

Applies to all 11 factory methods in HasRelationships: hasOne, hasOneThrough, morphOne, belongsTo, morphTo, hasMany, hasManyThrough, morphMany, belongsToMany, morphToMany, morphedByMany.

Notes for reviewers

  • The stub change is documented at the trait level so future stub-sync passes will preserve the divergence (see MixedMethodCall on $this->belongsTo(...)->withoutGlobalScopes() chain in relation methods #913 for context).
  • The regression test asserts both the intermediate factory type (a load-bearing @psalm-check-type-exact, since a future regression that re-collapses the intermediate to mixed would still satisfy a declared return type) and the full chained-return shape for the four structurally distinct relation shapes:
    • 2-template (belongsTo, hasOne, hasMany)
    • 3-template through (hasManyThrough)
    • 4-template pivot-bearing (belongsToMany)
    • No-TRelatedModel (morphTo)
  • The remaining methods (morphOne, morphMany, hasOneThrough, morphToMany, morphedByMany) share the same template shapes and static-substitution path with covered methods.
  • Possible follow-up worth a separate pass: ModelRelationReturnTypeHandler was introduced specifically to work around the same \$this-in-template Psalm limitation for user-defined relation methods. Now that the factory stubs work directly, that handler might have some redundant logic to simplify.

Fixes #913

…mplates

Replace `@return Relation<TRelated, $this>` with `@return Relation<TRelated, static>` in
every HasRelationships factory method (hasOne, hasOneThrough, morphOne, belongsTo,
morphTo, hasMany, hasManyThrough, morphMany, belongsToMany, morphToMany, morphedByMany).

Empirically, Psalm 7 substitutes `static` with the late-static-bound class while leaving
`$this` in template position unsubstituted. That collapsed the intermediate of chained
calls inside relation methods (e.g. `$this->belongsTo(X::class)->withoutGlobalScopes()`)
to `mixed` and surfaced as MixedMethodCall. `static` and `$this` are semantically
equivalent at the TDeclaringModel slot, so the divergence from Laravel source is safe.

Adds a trait-level docblock pinning the divergence so future stub-sync passes preserve it,
and a regression test that asserts both the intermediate factory type and the chained
return type for the structurally distinct relation shapes (2-template, 3-template through,
4-template pivot, no-TRelatedModel morphTo).

Fixes #913
@alies-dev alies-dev changed the title fix(stubs): use static instead of $this in relation factory return templates (#913) Use static instead of $this in relation factory return templates May 11, 2026
@alies-dev alies-dev requested a review from Copilot May 11, 2026 17:58
@alies-dev alies-dev self-assigned this May 11, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a Psalm 7 template-substitution regression affecting fluent chaining on Eloquent relation factory calls inside model relation methods (e.g. belongsTo(...)->withoutGlobalScopes()), where the intermediate relation type previously collapsed to mixed and triggered MixedMethodCall.

Changes:

  • Updated HasRelationships relation factory stubs to use static (instead of $this) in the TDeclaringModel generic slot so Psalm 7 correctly substitutes the concrete model type.
  • Added a PHPT regression test that asserts the intermediate inferred relation types (via @psalm-check-type-exact) and verifies fluent chaining works across multiple relation template shapes.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
stubs/common/Database/Eloquent/Concerns/HasRelationships.phpstub Switches TDeclaringModel return template arguments from $this to static across all relation factories; adds a trait-level note documenting the intentional divergence from Laravel source.
tests/Type/tests/Relation/RelationChainInsideRelationMethodTest.phpt Adds a regression test covering intermediate relation typing and chained builder-mixin calls for several structurally distinct relation types.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@alies-dev
Copy link
Copy Markdown
Collaborator Author

/benchmark

@github-actions
Copy link
Copy Markdown
Contributor

Benchmark Results

Metric Base PR Delta
Wall time 29.9s ± 0.6s 28.6s ± 0.2s -1.4s (-4.5%)
Peak memory 1100MB 1100MB +0MB (+0.0%)
Issues found 1,905 1,905 +0
Type coverage 95.40% 96.13% +0.73%

3 run(s), 1 warmup. Measured by hyperfine.

Status: 🟢 PASS

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.

MixedMethodCall on $this->belongsTo(...)->withoutGlobalScopes() chain in relation methods

2 participants