Problem
A relationship method on an Eloquent model is reported as PossiblyUnusedMethod even when the relation is consumed via property access (`$model->team`) or the dynamic relation API (`$model->team()->getResults()`):
ERROR: PossiblyUnusedMethod
Cannot find explicit calls to method App\Models\User::team
(but did find some potential callers) (see https://psalm.dev/087)
public function team(): BelongsTo
Reproducer:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class User extends Model
{
/** @return BelongsTo<Team, self> */
public function team(): BelongsTo
{
return \$this->belongsTo(Team::class);
}
}
// Property-access dispatch — Psalm doesn't see this as a call to ::team():
\$teamName = \$user->team->name;
// Eager loading and relation API also dispatch via __call:
User::with('team')->get();
\$user->team()->update(['active' => true]);
Eloquent reaches the relationship method through HasAttributes::getRelationshipFromMethod() (called from __get and getRelationValue) and through Model::__call() for the relation-builder form. Both paths are invisible to Psalm's reference graph, so the method looks unused.
The plugin already understands relationship methods for type purposes (see ModelRelationshipPropertyHandler, ModelRelationReturnTypeHandler, RelationMethodParser), but does not suppress the unused-method finding.
Proposed fix
Extend SuppressHandler (src/Handlers/SuppressHandler.php) with a pass over Eloquent model classes (analogous to suppressEloquentAccessorMethods()) that suppresses PossiblyUnusedMethod and UnusedMethod on any method whose declared return type resolves to a subclass of Illuminate\Database\Eloquent\Relations\Relation.
Detection options, in order of preference:
- Read
MethodStorage->signature_return_type / return_type and check whether it is a TNamedObject whose class extends Illuminate\Database\Eloquent\Relations\Relation. The plugin already loads relation metadata during model registration (see RelationMethodParser), so reuse that pass.
- Fallback to the docblock
@return if the native return type is missing (older codebases).
Visibility gating: relationship dispatch goes through Model::__call() / HasAttributes::getRelationshipFromMethod() from a foreign scope (Builder, Model parent), so the method must be public at runtime. Use suppressFrameworkHookMethod() (public-only); keep protected / private relationship methods flagged as the bug they are.
Out of scope (follow-up)
- Custom relation classes that the user authored extending
Relation directly. The check via class-hierarchy lookup should already cover them, but call out in the test suite.
- Dynamic relation strings (
\$model->{\$name}) are unrelated to the unused-method false positive and remain a separate type-inference concern.
Acceptance
- New PHPT type test under
tests/Type/tests/ covering a model with BelongsTo, HasMany, and MorphTo relations, asserting no PossiblyUnusedMethod is reported when the only call site is property-style (\$model->team) or eager loading (Model::with('team')).
- Negative case: a
protected / private relation method still reports (it would fatal at runtime under Eloquent's foreign-scope dispatch).
- No regression in existing relationship handler tests.
Problem
A relationship method on an Eloquent model is reported as
PossiblyUnusedMethodeven when the relation is consumed via property access (`$model->team`) or the dynamic relation API (`$model->team()->getResults()`):Reproducer:
Eloquent reaches the relationship method through
HasAttributes::getRelationshipFromMethod()(called from__getandgetRelationValue) and throughModel::__call()for the relation-builder form. Both paths are invisible to Psalm's reference graph, so the method looks unused.The plugin already understands relationship methods for type purposes (see
ModelRelationshipPropertyHandler,ModelRelationReturnTypeHandler,RelationMethodParser), but does not suppress the unused-method finding.Proposed fix
Extend
SuppressHandler(src/Handlers/SuppressHandler.php) with a pass over Eloquent model classes (analogous tosuppressEloquentAccessorMethods()) that suppressesPossiblyUnusedMethodandUnusedMethodon any method whose declared return type resolves to a subclass ofIlluminate\Database\Eloquent\Relations\Relation.Detection options, in order of preference:
MethodStorage->signature_return_type/return_typeand check whether it is aTNamedObjectwhose class extendsIlluminate\Database\Eloquent\Relations\Relation. The plugin already loads relation metadata during model registration (seeRelationMethodParser), so reuse that pass.@returnif the native return type is missing (older codebases).Visibility gating: relationship dispatch goes through
Model::__call()/HasAttributes::getRelationshipFromMethod()from a foreign scope (Builder,Modelparent), so the method must be public at runtime. UsesuppressFrameworkHookMethod()(public-only); keepprotected/privaterelationship methods flagged as the bug they are.Out of scope (follow-up)
Relationdirectly. The check via class-hierarchy lookup should already cover them, but call out in the test suite.\$model->{\$name}) are unrelated to the unused-method false positive and remain a separate type-inference concern.Acceptance
tests/Type/tests/covering a model withBelongsTo,HasMany, andMorphTorelations, asserting noPossiblyUnusedMethodis reported when the only call site is property-style (\$model->team) or eager loading (Model::with('team')).protected/privaterelation method still reports (it would fatal at runtime under Eloquent's foreign-scope dispatch).