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
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
HasManydeclaration. This collapses the inferred return toHasMany<Model, Model>, which then conflicts with a declared@return HasMany<RelatedModel, self>.If
foundationalItems()were inlined asreturn $this->hasMany(ProgramItem::class, ...)->where(...)->orderBy(...), the plugin would produceHasMany<ProgramItem, self>and the declaration would line up.Root cause
RelationMethodParser::findRelationCallInExpr()(src/Handlers/Eloquent/RelationMethodParser.php:175) only recognizes the names listed inFACTORY_TO_RELATION(hasOne,hasMany,belongsTo, ...). For any other method call it descends into$expr->varlooking for a chained factory:For
$this->itemsByCategory(...),$expr->varis the$thisVariable, the recursion bails at theinstanceof MethodCallguard, andparseMethodBody()returns null.ModelRelationReturnTypeHandler::getReturnType()returns null in turn, so Psalm uses the untemplatedHasManyfrom the helper's declared return type and fills the template slots with the stub defaults (Model, Model).Differences vs #879
self::class/static::class/parent::classleak the keyword as TRelatedModel #879: the parser succeeds but emitsselfas the related FQCN becauseself::classisn't substituted. Wrong template content.A fix for #879 doesn't cover this case.
Possible fix
Have the parser follow private/protected helper calls on
$thiswhen the outer expression is a non-factoryMethodCall: look up the helper'sClassMethodAST in the same class hierarchy and re-enterparseMethodBodyon it. Needs a guard against recursion (a small visited-set keyed onclass::method) and should only fire when the helper's declared return type is aRelationsubclass, to keep the cost bounded.Workaround
Annotate the helper with the precise generics so the declared return at the public relation method matches:
Environment
psalm/plugin-laravelmaster (d181797)vimeo/psalm7.0.0-beta19