Skip to content

PossiblyUnusedMethod: Detect controller methods referenced by Route::controller()/resource() string actions #876

@alies-dev

Description

@alies-dev

Problem

Controller methods referenced from routes/*.php via Route::controller(Foo::class)->group(fn() => Route::put('/', 'update')) are reported as PossiblyUnusedMethod (and UnusedMethod under findUnusedCode=true):

ERROR: PossiblyUnusedMethod
Cannot find any calls to method App\Http\Controllers\Admin\FooController::update (see https://psalm.dev/087)
    public function update(Foo $foo, Request $request): RedirectResponse

Reproducer (routes/web.php):

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\FooController;

Route::controller(FooController::class)
    ->prefix('foo')
    ->group(static function (): void {
        Route::get('/', 'index')->name('foo.index');
        Route::post('/', 'store')->name('foo.store');
        Route::put('{foo}', 'update')->name('foo.update');
        Route::delete('{foo}', 'destroy')->name('foo.destroy');
    });

In this form the controller is bound on the parent group and each child route's action is a string literal. Laravel resolves [FooController::class, 'update'] at runtime by combining the Route::controller(...) state with the string. From Psalm's reference graph, the string 'update' is just data — not a method reference — so the controller methods look unused.

The same gap affects:

Route::resource('foos', FooController::class);              // implicit index/show/create/store/edit/update/destroy
Route::singleton('settings', SettingsController::class);    // implicit show/edit/update/destroy
Route::apiResource('foos', FooController::class);           // implicit subset

The forms Psalm already handles natively (no plugin work needed) are:

Route::put('foos/{foo}', [FooController::class, 'update']);  // array-callable
Route::get('foos', FooController::class);                    // __invoke target

Proposed fix

Add a plugin handler that emits synthetic method references during analysis so the used-symbol pass sees the call, eliminating the false positive.

Detection paths:

  1. Route::controller(FooController::class)->...->group($closure) chains.

    • Recognise the Illuminate\Routing\Router::controller($fqcn) static / facade call.
    • Walk the chain to find the ->group($closure) call.
    • Inside the closure body, for each Route::<verb>($path, $action) (or Route::match($verbs, $path, $action)) where $action is a string literal, record a reference to $fqcn::$action.
    • Nested groups inherit the controller; reset on a nested controller(OtherController::class) rebind.
  2. Route::resource($name, $fqcn) / Route::apiResource(...) / Route::singleton(...).

    • Map to the implicit method set:
      • resourceindex, create, store, show, edit, update, destroy
      • apiResourceindex, store, show, update, destroy
      • singletonshow, edit, update, destroy
      • apiSingletonshow, update, destroy
    • Honour ->only([...]) / ->except([...]) chain modifiers when statically resolvable; if not resolvable, fall back to the full set (false-negative-free).
  3. Route::<verb>($path, [$fqcn, $method]) and invokable form. Already handled by Psalm natively — no work needed, but include a regression test to lock the behavior down.

Implementation sketch: a Psalm AfterClassLikeVisit or AfterFunctionLikeAnalysis style hook is unsuitable because we need to record references before Psalm computes used-symbols. The closest fit is AfterFunctionCallAnalysis / AfterMethodCallAnalysis plus emitting a MethodReference into the codebase. Worth checking: Psalm 7 may expose Codebase::file_reference_provider->addMethodReferenceToClassMethod() as the right entry point — confirm during implementation.

Visibility gating: route dispatch goes through the container and __invoke / reflection, requiring public methods. Non-public methods aren't valid route targets at runtime, so the synthetic reference should only fire for public methods. (The unused-method false positive doesn't fire on non-public methods anyway, so this is mostly a sanity guard.)

Out of scope (follow-up)

  • Route::group(['controller' => Foo::class], $closure) (associative-array form).
  • Class-string method references built dynamically (Route::get('/', $action) where $action is a variable) — same as today, remains a runtime-only resolution.
  • Route::fallback($action) if $action is a string + group-controller; unusual in practice.

Acceptance

  • New PHPT type test under tests/Type/tests/ covering:
    • Route::controller(Foo::class)->group(...) with string actions in nested closures
    • Route::resource('foos', Foo::class) (full set)
    • Route::resource(...)->only(['index', 'store']) (subset)
    • Route::apiResource(...) and Route::singleton(...)
    • Negative case: a controller method declared but never wired up to any route still reports PossiblyUnusedMethod.
  • Regression test for the already-working [Controller::class, 'method'] array-callable form.
  • No regression on existing route 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