Skip to content

Suppress PossiblyUnusedMethod for Eloquent relationship methods #875

@alies-dev

Description

@alies-dev

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions