Skip to content

Resolve self::class/static::class/parent::class in relation factories#887

Merged
alies-dev merged 1 commit intomasterfrom
worktree-879-self-static-parent-class-relations
May 6, 2026
Merged

Resolve self::class/static::class/parent::class in relation factories#887
alies-dev merged 1 commit intomasterfrom
worktree-879-self-static-parent-class-relations

Conversation

@alies-dev
Copy link
Copy Markdown
Collaborator

@alies-dev alies-dev commented May 6, 2026

Issue to Solve

Relation factories called with self::class / static::class / parent::class were leaking the literal keyword as TRelatedModel. PhpParser leaves these keywords unresolved (they're context-sensitive), so the parser stored the string 'self' / 'static' / 'parent' as the related-model template parameter. At the call site, Psalm then substituted self with the enclosing class, producing nonsensical errors like expects MergeTagsAction on ->save() / ->associate().

final class Tag extends Model {
    /** @return BelongsTo<Tag, self> */
    public function parentTag(): BelongsTo {
        return $this->belongsTo(self::class, 'parent_tag_id');
    }
}

// elsewhere:
$alias->parentTag()->associate($keep);
// ImplicitToStringCast: Argument 1 expects App\Actions\MergeTagsAction|int|null|string,
// but App\Models\Tag provided

Affects every relation factory that takes a class-string (hasMany, hasOne, belongsTo, belongsToMany, morphMany, morphOne, morphToMany, morphedByMany, hasOneThrough, hasManyThrough) plus ->using(self::class) on BelongsToMany / MorphToMany.

Regression introduced in fb49d583 (#760). Before that commit, relation accessors collapsed to Rel<Model, Model> and the keyword never reached the template slot.

Related

Closes #879.

Solution Description

Thread the declaring class FQCN (and a pre-resolved parent FQCN for parent::class) through the parser pipeline (parse -> doParse -> parseMethodBody -> findRelationCallInExpr -> extractClassStringArg / firstClassStringArg -> resolveClassConstFetch). In resolveClassConstFetch, gate on PhpParser\Node\Name::isSpecialClassName() and substitute:

  • self::class -> declaring class
  • static::class -> declaring class (conservative; the parser cache is keyed on declaring class only, so late-static-binding-correct resolution is out of scope; documented in the docblock and pinned by a regression test)
  • parent::class -> parent class FQCN (or null when no parent, matching the dynamic-arg path so the upstream handler defers)

The parent FQCN is resolved once in doParse via $codebase->classlike_storage_provider->get($className)->parent_class, with InvalidArgumentException caught and a debug message logged (matching the surrounding fail-soft style).

Tests

PHPT regression at tests/Type/tests/Relation/Issue879SelfStaticParentClassTest.phpt covering:

  • Both reproducers from the issue (HasMany::save() after hasMany(self::class), BelongsTo::associate() after belongsTo(self::class))
  • static::class resolves to the declaring class (pinned conservative behavior)
  • parent::class from a subclass resolves to the parent FQCN
  • hasManyThrough(Mechanic::class, self::class) (intermediate at positionalIndex=1)
  • hasManyThrough(related: Mechanic::class, through: parent::class) (named-arg branch)
  • ->using(self::class) on a BelongsToMany (firstClassStringArg path)

Plus DataProvider unit tests for resolveClassConstFetch covering self, case-insensitive SELF, static, parent (with and without parent), and a regular Foo::class.

Disk fixtures (tests/Application/app/Models/WorkOrderNote.php, Tool.php, PowerTool.php, PartReplacement.php) live in the existing car repair shop domain and are required because ModelRegistrationHandler::class_exists() skips models not loadable by the autoloader. WorkOrderNote covers the self::class self-referential pattern (threaded service notes); Tool / PowerTool extends Tool covers parent::class; PartReplacement extends Pivot covers ->using(self::class).

Checklist

  • Tests cover the change (type test in tests/Type/ and unit test in tests/Unit/)

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 regression in the Eloquent relation-method parser where self::class / static::class / parent::class in relation factory arguments were being captured as literal keywords (e.g. 'self') and then incorrectly substituted at consumer call sites, producing invalid relation generic types and downstream false positives.

Changes:

  • Thread the declaring class FQCN and its immediate parent FQCN through RelationMethodParser so self/static/parent class-const fetches can be resolved to real FQCNs during parsing.
  • Add unit coverage for resolveClassConstFetch() keyword handling and a PHPT regression test covering multiple relation factories and the ->using(self::class) chain.
  • Add disk-backed model fixtures required for autoload-based model registration during type tests.

Reviewed changes

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

Show a summary per file
File Description
src/Handlers/Eloquent/RelationMethodParser.php Resolves self/static/parent in ClassConstFetch by threading declaring/parent FQCN context through the parser pipeline.
tests/Unit/Handlers/Eloquent/RelationMethodParserTest.php Adds data-provider unit tests validating resolveClassConstFetch() behavior for self/static/parent and non-special names.
tests/Type/tests/Relation/Issue879SelfStaticParentClassTest.phpt Adds a PHPT regression test covering keyword resolution across multiple relation factories and ->using(self::class).
tests/Application/app/Models/Animal.php Adds an autoloadable fixture model to exercise self::class and static::class relation factory arguments.
tests/Application/app/Models/Comment.php Adds an autoloadable self-referential fixture model used by the PHPT regression.
tests/Application/app/Models/Dog.php Adds an autoloadable subclass fixture to exercise parent::class resolution (including named-arg through relations).
tests/Application/app/Models/SelfReferentialPivot.php Adds an autoloadable pivot fixture to exercise ->using(self::class) via the firstClassStringArg path.

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

Relation factories called with self::class / static::class / parent::class
were leaking the literal keyword as TRelatedModel. PhpParser leaves these
keywords unresolved (they're context-sensitive), so the parser stored the
string 'self' / 'static' / 'parent' as the related-model template parameter.
At the call site, Psalm then substituted self with the enclosing class,
producing nonsensical "expects MergeTagsAction" errors on save() / associate().

Thread the declaring class FQCN (and, for parent::class, a pre-resolved
parent FQCN) through parse → doParse → parseMethodBody → findRelationCallInExpr
→ extractClassStringArg / firstClassStringArg → resolveClassConstFetch.
Substitute self/static to the declaring class and parent to the parent class.

static::class is conservatively resolved to the declaring class — strictly
better than leaking 'static' but not late-static-binding-correct (the parser
cache is keyed on declaring class only). Documented in the resolveClassConstFetch
docblock and pinned by a regression test so a future fix cannot drift silently.
@alies-dev alies-dev force-pushed the worktree-879-self-static-parent-class-relations branch from 47eabea to a3ff5e0 Compare May 6, 2026 20:17
@alies-dev
Copy link
Copy Markdown
Collaborator Author

/benchmark

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Benchmark Results

Metric Base PR Delta
Wall time 30.9s ± 0.6s 31.3s ± 0.5s +0.4s (+1.3%)
Peak memory 1097MB 1097MB +0MB (+0.0%)
Issues found 1,907 1,907 +0
Type coverage 95.36% 95.36% +0.00%

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

Status: 🟢 PASS

@alies-dev alies-dev added the release:skip-changelog for PRs only (used by release-drafter) label May 6, 2026
@alies-dev alies-dev merged commit bcb0ea9 into master May 6, 2026
34 checks passed
@alies-dev alies-dev deleted the worktree-879-self-static-parent-class-relations branch May 6, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:skip-changelog for PRs only (used by release-drafter)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Relation factories with self::class/static::class/parent::class leak the keyword as TRelatedModel

2 participants