Skip to content

Relation factory wrapped in a private helper falls back to HasMany<Model, Model> template defaults #882

@alies-dev

Description

@alies-dev

Problem

When a relation method delegates to a private helper that builds the relation, the plugin's relation parser bails and Psalm falls back to the untemplated HasMany declaration. This collapses the inferred return to HasMany<Model, Model>, which then conflicts with a declared @return HasMany<RelatedModel, self>.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class Program extends Model
{
    /** @return HasMany<ProgramItem, self> */
    public function foundationalItems(): HasMany
    {
        return $this->itemsByCategory(ItemCategory::Foundational);
    }

    /** @return HasMany<ProgramItem, self> */
    public function coreItems(): HasMany
    {
        return $this->itemsByCategory(ItemCategory::Core);
    }

    private function itemsByCategory(ItemCategory $category): HasMany
    {
        return $this->hasMany(ProgramItem::class, 'program_id')
            ->where('category', $category->value)
            ->orderBy('sort_order');
    }
}
ERROR: MoreSpecificReturnType
The declared return type 'HasMany<App\Models\ProgramItem, App\Models\Program>' for App\Models\Program::foundationalItems
is more specific than the inferred return type 'HasMany<Model, Model>' (see https://psalm.dev/070)

ERROR: LessSpecificReturnStatement
The type 'HasMany<Model, Model>' is more general than the declared return type
'HasMany<App\Models\ProgramItem, App\Models\Program>' for App\Models\Program::foundationalItems (see https://psalm.dev/129)
    return $this->itemsByCategory(ItemCategory::Foundational);

If foundationalItems() were inlined as return $this->hasMany(ProgramItem::class, ...)->where(...)->orderBy(...), the plugin would produce HasMany<ProgramItem, self> and the declaration would line up.

Root cause

RelationMethodParser::findRelationCallInExpr() (src/Handlers/Eloquent/RelationMethodParser.php:175) only recognizes the names listed in FACTORY_TO_RELATION (hasOne, hasMany, belongsTo, ...). For any other method call it descends into $expr->var looking for a chained factory:

return self::findRelationCallInExpr($expr->var, $pivotModel, $accessor);

For $this->itemsByCategory(...), $expr->var is the $this Variable, the recursion bails at the instanceof MethodCall guard, and parseMethodBody() returns null. ModelRelationReturnTypeHandler::getReturnType() returns null in turn, so Psalm uses the untemplated HasMany from the helper's declared return type and fills the template slots with the stub defaults (Model, Model).

Differences vs #879

A fix for #879 doesn't cover this case.

Possible fix

Have the parser follow private/protected helper calls on $this when the outer expression is a non-factory MethodCall: look up the helper's ClassMethod AST in the same class hierarchy and re-enter parseMethodBody on it. Needs a guard against recursion (a small visited-set keyed on class::method) and should only fire when the helper's declared return type is a Relation subclass, to keep the cost bounded.

Workaround

Annotate the helper with the precise generics so the declared return at the public relation method matches:

-    private function itemsByCategory(ItemCategory $category): HasMany
+    /** @return HasMany<ProgramItem, self> */
+    private function itemsByCategory(ItemCategory $category): HasMany

Environment

  • psalm/plugin-laravel master (d181797)
  • vimeo/psalm 7.0.0-beta19

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions