Problem
Third-party packages ship Facade subclasses whose accessor is a string alias bound by the package's own ServiceProvider::register(). After #789, two resolver paths exist:
@see / @mixin docblock pointer — fails when the package author didn't write one.
- Runtime
Facade::getFacadeRoot() probe — fails because the package's service provider doesn't run inside the plugin's Testbench app, so the accessor isn't bound. BindingResolutionException fires and a warning is emitted.
When both paths fail, the plugin falls back to the facade's @method catalogue. If the catalogue is complete the user sees no UndefinedMagicMethod, but they do see noisy probe-failure warnings on every run, and the resolver gives up on deriving types from the actual service class (which would supersede the hand-maintained catalogue).
Repro
imdhemy/laravel-in-app-purchases ships two such facades:
namespace Imdhemy\Purchases\Facades;
/**
* @method static \Imdhemy\Purchases\Subscription googlePlay(?ClientInterface $client = null)
* @method static \Imdhemy\Purchases\Subscription appStore(?ClientInterface $client = null)
*/
class Subscription extends Facade
{
protected static function getFacadeAccessor() { return 'subscription'; }
}
'subscription' and 'product' are bound in Imdhemy\Purchases\PurchasesServiceProvider::registerSubscriptionClient() / registerProductClient() (or equivalent). That provider never runs in Testbench, so Subscription::getFacadeRoot() throws Target class [subscription] does not exist.
Result on vendor/bin/psalm:
Warning: Laravel plugin: getFacadeRoot() failed for 'Imdhemy\Purchases\Facades\Subscription': Target class [subscription] does not exist.
Warning: Laravel plugin: getFacadeRoot() failed for 'Imdhemy\Purchases\Facades\Product': Target class [product] does not exist.
(Rendering of the warning itself is broken — see #941. This issue is about the resolver, not the print path.)
Same pattern in many ecosystem packages: anything that ships its own facade and binds the accessor by string alias in its provider (Livewire's Livewire::, Filament's Filament::, Spatie's Signal::, Pennant's Feature::, etc.).
Why the existing paths don't cover this
The result is that for any installed package whose facade lacks @see, the plugin's behaviour today is: noisy warning during scan, fall back to whatever @method catalogue the package ships. If the catalogue drifts from the underlying service the user gets stale types or UndefinedMagicMethod (covered by #761).
Proposal
Add a third resolver path: statically parse vendor service providers for accessor → class bindings.
During scan, for every class implementing Illuminate\Contracts\Support\DeferrableProvider or extending Illuminate\Support\ServiceProvider, walk the register() method AST and harvest:
$this->app->bind('subscription', SubscriptionClient::class);
$this->app->singleton('subscription', fn (...) => new SubscriptionClient(...));
$this->app->alias(SubscriptionClient::class, 'subscription');
app()->bind('subscription', ...);
Three shapes worth covering:
- Class-string second argument —
bind('subscription', SubscriptionClient::class). Trivial AST lookup, direct map.
- Closure returning an instantiation —
bind('subscription', fn () => new SubscriptionClient(...)). Inspect the closure body's return new X(...) for the class.
alias() form — alias(SubscriptionClient::class, 'subscription'). Reverse direction, also a direct map.
Anything more complex (factories that branch, providers that read config to pick a class, deferred bindings via container interfaces) falls through to the existing paths.
Mapping is registered with FacadeMapProvider::registerCustomFacade() exactly as path 1 does today, so downstream FacadeMethodHandler and getFacadeClasses() lookups need no further changes.
Scope and tradeoffs
- Stops the warning. Most third-party facades resolve through static parsing, the runtime probe is never reached, no noise.
- Improves types over hand-maintained
@method. Once the root class is known, FacadeMethodHandler proxies the service's actual reflected signatures. @method precedence is preserved (Psalm checks pseudo-methods first), so package authors who deliberately narrow types via @method are unaffected.
- Cost is one-time at scan: walk
vendor/*/src/**/ServiceProvider.php AST once, cache by file mtime in .psalm-cache. Comparable to the cost of the existing model registration pass.
- Limits: dynamic bindings (
bind(config('foo.driver'), ...)) and runtime-conditional registration are not covered. Those still need @see or a future runtime hook.
Related
Problem
Third-party packages ship
Facadesubclasses whose accessor is a string alias bound by the package's ownServiceProvider::register(). After #789, two resolver paths exist:@see/@mixindocblock pointer — fails when the package author didn't write one.Facade::getFacadeRoot()probe — fails because the package's service provider doesn't run inside the plugin's Testbench app, so the accessor isn't bound.BindingResolutionExceptionfires and a warning is emitted.When both paths fail, the plugin falls back to the facade's
@methodcatalogue. If the catalogue is complete the user sees noUndefinedMagicMethod, but they do see noisy probe-failure warnings on every run, and the resolver gives up on deriving types from the actual service class (which would supersede the hand-maintained catalogue).Repro
imdhemy/laravel-in-app-purchases ships two such facades:
'subscription'and'product'are bound inImdhemy\Purchases\PurchasesServiceProvider::registerSubscriptionClient()/registerProductClient()(or equivalent). That provider never runs in Testbench, soSubscription::getFacadeRoot()throwsTarget class [subscription] does not exist.Result on
vendor/bin/psalm:(Rendering of the warning itself is broken — see #941. This issue is about the resolver, not the print path.)
Same pattern in many ecosystem packages: anything that ships its own facade and binds the accessor by string alias in its provider (Livewire's
Livewire::, Filament'sFilament::, Spatie'sSignal::, Pennant'sFeature::, etc.).Why the existing paths don't cover this
@see) requires authors to add the tag. Most third-party packages don't. The koel example in App-owned Facade with string-alias accessor emits UndefinedMagicMethod #787 was first-party application code where the author can be asked to add it; the bar is higher for vendored code we don't control.app('package.alias')returns mixed — string aliases registered by the analysed package's service provider are unknown to the Testbench app #766 /ApplicationProvider::doGetApp()for good reasons (it would explode startup time, leak bindings, and break for providers that need real env config).FacadeMapProvider(AliasLoaderscan) never sees these classes — they're consumed by FQN, not aliased.The result is that for any installed package whose facade lacks
@see, the plugin's behaviour today is: noisy warning during scan, fall back to whatever@methodcatalogue the package ships. If the catalogue drifts from the underlying service the user gets stale types orUndefinedMagicMethod(covered by #761).Proposal
Add a third resolver path: statically parse vendor service providers for accessor → class bindings.
During scan, for every class implementing
Illuminate\Contracts\Support\DeferrableProvideror extendingIlluminate\Support\ServiceProvider, walk theregister()method AST and harvest:Three shapes worth covering:
bind('subscription', SubscriptionClient::class). Trivial AST lookup, direct map.bind('subscription', fn () => new SubscriptionClient(...)). Inspect the closure body'sreturn new X(...)for the class.alias()form —alias(SubscriptionClient::class, 'subscription'). Reverse direction, also a direct map.Anything more complex (factories that branch, providers that read config to pick a class, deferred bindings via container interfaces) falls through to the existing paths.
Mapping is registered with
FacadeMapProvider::registerCustomFacade()exactly as path 1 does today, so downstreamFacadeMethodHandlerandgetFacadeClasses()lookups need no further changes.Scope and tradeoffs
@method. Once the root class is known,FacadeMethodHandlerproxies the service's actual reflected signatures.@methodprecedence is preserved (Psalm checks pseudo-methods first), so package authors who deliberately narrow types via@methodare unaffected.vendor/*/src/**/ServiceProvider.phpAST once, cache by file mtime in.psalm-cache. Comparable to the cost of the existing model registration pass.bind(config('foo.driver'), ...)) and runtime-conditional registration are not covered. Those still need@seeor a future runtime hook.Related
UndefinedMagicMethodon third-party facades — FacadeMapProvider only scans Testbench's AliasLoader #761 —UndefinedMagicMethodon third-party facades. This proposal subsumes most ofUndefinedMagicMethodon third-party facades — FacadeMapProvider only scans Testbench's AliasLoader #761's repros once the package author hasn't shipped a complete@methodcatalogue.app('package.alias')returns mixed — string aliases registered by the analysed package's service provider are unknown to the Testbench app #766 — string-alias container resolution outside Testbench. Same root cause family; static parsing of providers is the version ofapp('package.alias')returns mixed — string aliases registered by the analysed package's service provider are unknown to the Testbench app #766 that doesn't need to boot user providers.