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:
-
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.
-
Route::resource($name, $fqcn) / Route::apiResource(...) / Route::singleton(...).
- Map to the implicit method set:
resource → index, create, store, show, edit, update, destroy
apiResource → index, store, show, update, destroy
singleton → show, edit, update, destroy
apiSingleton → show, update, destroy
- Honour
->only([...]) / ->except([...]) chain modifiers when statically resolvable; if not resolvable, fall back to the full set (false-negative-free).
-
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.
Problem
Controller methods referenced from
routes/*.phpviaRoute::controller(Foo::class)->group(fn() => Route::put('/', 'update'))are reported asPossiblyUnusedMethod(andUnusedMethodunderfindUnusedCode=true):Reproducer (
routes/web.php):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 theRoute::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:
The forms Psalm already handles natively (no plugin work needed) are:
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:
Route::controller(FooController::class)->...->group($closure)chains.Illuminate\Routing\Router::controller($fqcn)static / facade call.->group($closure)call.Route::<verb>($path, $action)(orRoute::match($verbs, $path, $action)) where$actionis a string literal, record a reference to$fqcn::$action.controller(OtherController::class)rebind.Route::resource($name, $fqcn)/Route::apiResource(...)/Route::singleton(...).resource→index, create, store, show, edit, update, destroyapiResource→index, store, show, update, destroysingleton→show, edit, update, destroyapiSingleton→show, update, destroy->only([...])/->except([...])chain modifiers when statically resolvable; if not resolvable, fall back to the full set (false-negative-free).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
AfterClassLikeVisitorAfterFunctionLikeAnalysisstyle hook is unsuitable because we need to record references before Psalm computes used-symbols. The closest fit isAfterFunctionCallAnalysis/AfterMethodCallAnalysisplus emitting aMethodReferenceinto the codebase. Worth checking: Psalm 7 may exposeCodebase::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).Route::get('/', $action)where$actionis a variable) — same as today, remains a runtime-only resolution.Route::fallback($action)if$actionis a string + group-controller; unusual in practice.Acceptance
tests/Type/tests/covering:Route::controller(Foo::class)->group(...)with string actions in nested closuresRoute::resource('foos', Foo::class)(full set)Route::resource(...)->only(['index', 'store'])(subset)Route::apiResource(...)andRoute::singleton(...)PossiblyUnusedMethod.[Controller::class, 'method']array-callable form.