Skip to content

Resolve in-package facades via static parsing of vendor service providers #942

@alies-dev

Description

@alies-dev

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:

  1. @see / @mixin docblock pointer — fails when the package author didn't write one.
  2. 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:

  1. Class-string second argumentbind('subscription', SubscriptionClient::class). Trivial AST lookup, direct map.
  2. Closure returning an instantiationbind('subscription', fn () => new SubscriptionClient(...)). Inspect the closure body's return new X(...) for the class.
  3. alias() formalias(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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions