Skip to content

Latest commit

 

History

History
751 lines (596 loc) · 31.8 KB

File metadata and controls

751 lines (596 loc) · 31.8 KB

PHPantom — Laravel

Known gaps and missing features in PHPantom's Laravel Eloquent support. For the general architecture and virtual member provider design, see ARCHITECTURE.md.

Items are ordered by impact (descending), then effort (ascending) within the same impact tier.

Label Scale
Impact Critical, High, Medium-High, Medium, Low-Medium, Low
Effort Low (≤ 1 day), Medium (2-5 days), Medium-High (1-2 weeks), High (2-4 weeks), Very High (> 1 month)

Out of scope (and why)

Item Reason
Container string aliases Requires booting the application. Use ::class references instead.
Facade getFacadeAccessor() with string aliases Requires booting the application. @method static tags provide a workable fallback.
Blade templates Separate project. See blade.md for the implementation plan.
Model column types from DB/migrations Unreasonable complexity. Require @property annotations (via ide-helper or hand-written).
Legacy Laravel versions We target current Larastan-style annotations. Older code may degrade gracefully.
Application provider scanning Low-value, high-complexity.
Macro discovery (Macroable trait) Requires booting the application to inspect runtime $macros static property. @method tags provide a workable fallback.
Auth model from config Requires reading runtime config (config/auth.php). Larastan boots the app for this.
Facade → concrete resolution via booting Requires booting (getFacadeRoot()). When getFacadeAccessor() returns a ::class reference, static resolution is possible without booting. See "Facade completion" section below.
Contract → concrete resolution Requires container bindings at runtime.
Manager → driver resolution Requires instantiating the manager at runtime.

Philosophy (unchanged)

  • No application booting. We never boot a Laravel application to resolve types.
  • No SQL/migration parsing. Model column types are not inferred from database schemas or migration files.
  • Larastan-style hints preferred. We expect relationship methods to be annotated in the style that Larastan expects. Fallback heuristics are best-effort.
  • Facades rely on @method tags. Laravel's facades ship with comprehensive @method static tags (2,047 across all facades as of Laravel 12.x) generated by a script. Our PHPDoc provider already parses these. The main gap is that the generator flattens template parameters, so @method static mixed make(string $abstract) loses the @template T that would let App::make(Foo::class) resolve to Foo. The preferred fix is upstream: improve the generator to emit @method static T make<T of object>(class-string<T> $abstract). PHPStan already supports this syntax. See L1 for details.

L1. Facade completion — upstream @method template improvement

Facades are the primary way Laravel developers interact with framework services (Cache::get(...), DB::table(...), Route::get(...), etc.). The facade pattern works by forwarding static calls on the facade class to instance methods on a concrete service class via __callStatic().

Every facade already ships with comprehensive @method static tags (2,047 tags across all facades in Laravel 12.x), generated by Laravel's facade-documenter script. Our PHPDoc provider already parses these and provides completion for all facade methods. Basic facade completion already works.

The problem: flattened templates

The generator strips @template parameters when emitting @method tags. For example, the Application class has:

/**
 * @template T of object
 * @param class-string<T> $abstract
 * @return T
 */
public function make($abstract) { ... }

But the App facade emits:

@method static mixed make(string $abstract)

This means App::make(MyService::class)->doSomething() resolves to mixed instead of MyService. The same flattening affects conditional return types and other template-dependent signatures.

Why getFacadeAccessor() resolution is not the right fix

The original L1 plan proposed resolving facades by parsing getFacadeAccessor() to find the concrete class and forwarding its methods. This has fundamental problems:

  1. Half the facades use string aliases. Auth returns 'auth', Cache returns 'cache', DB returns 'db', etc. These require booting the container to resolve, which is out of scope.
  2. The @see annotations are informal. Facades list multiple @see classes (e.g. Auth references both AuthManager and SessionGuard), and the relationship between them isn't formal (one may be a @mixin of the other). We'd be reverse-engineering an undocumented script's output logic.
  3. Only PHPantom would benefit. Any resolution technique we build is specific to our tool. Other IDEs and LSPs face the same problem.
  4. Manager pattern complicates things. Many facades delegate to a "manager" class (AuthManager, CacheManager) which in turn delegates to a "driver" (SessionGuard, FileStore). The @method tags are already a flattened merge of both levels. We'd need to replicate that merging logic.

Preferred approach: upstream generator improvement

PHPStan already supports @template on @method tags:

/**
 * @method static T make<T of object>(class-string<T> $abstract)
 */
class App extends Facade { ... }

This syntax is documented in PHPStan's test suite (tests/PHPStan/Rules/Generics/data/method-tag-template.php) and supported by the type checker. If Laravel's facade-documenter script preserved template parameters instead of stripping them, the @method tags would be self-sufficient for all static analysis tools.

Proposed upstream changes:

  1. When a method has @template parameters, emit them in the @method tag using <T> syntax after the method name.
  2. When a method has conditional return types, emit them using PHPStan's ($param is Type ? A : B) syntax instead of flattening to the widest type.
  3. For facades using string aliases: the @method tags already list all methods. The template preservation just makes them richer. No container resolution needed.

Impact: High · Effort: Low (for us) / Medium (upstream PR)

On our side, we need to:

  • Parse @template parameters on @method tags (the <T> syntax after the method name). Our extract_method_tags in docblock/virtual_members.rs currently ignores this syntax.
  • Apply template substitution at call sites for @method tags, the same way we do for regular generic methods.

This is a smaller, more maintainable change than building a facade resolution provider, and it benefits the entire PHP tooling ecosystem.

Upstream PR target: laravel/facade-documenter repository (https://github.com/laravel/facade-documenter). The entire generator is a single file (facade.php, ~600 lines) that uses PHPStan's own phpDoc parser. The specific functions that flatten templates:

  • resolveDocblockTypes() — the GenericTypeNode branch strips generic arguments entirely (Collection<int, User>Collection). Needs to preserve args in the output string.
  • handleUnknownIdentifierType() — when encountering a template parameter like T, resolves it to its bound (T of objectobject) or mixed. Needs to instead emit T as-is and add a <T of object> clause to the @method tag.
  • ConditionalTypeForParameterNode handling — unions the if/else branches (($id is int ? static : Collection)static|Collection). Needs to preserve the conditional syntax verbatim.
  • The class-string case in IdentifierTypeNode handling — maps class-string to string, losing the generic arg that carries the template binding. Needs to preserve class-string<T>.

Potential adoption concern: Laravel may be reluctant to emit richer @method tags if major IDEs choke on the syntax. Before submitting the upstream PR, we should research how the following tools handle @method static T make<T>(class-string<T> $abstract):

  • PhpStorm — the dominant PHP IDE. Does it parse <T> on @method tags? Does it resolve the template at call sites? If it ignores the template but still shows the method, that's acceptable (graceful degradation). If it errors or hides the method, that's a blocker.
  • Psalm — supports @method with templates via @template tags on the class docblock, but does it support inline <T> on the @method tag itself?
  • Intelephense — popular VS Code PHP extension. Same questions as PhpStorm.
  • PHPStan — already supports this syntax (confirmed via test suite). No concern here.

If PhpStorm and Intelephense gracefully degrade (show the method but ignore the template), the PR has a strong case: existing tools lose nothing, and tools that support the syntax gain full generic resolution. If any major tool breaks on the syntax, we'd need to propose a fallback mechanism (e.g. @phpstan-method variant that coexists with the plain @method).

Fallback: getFacadeAccessor resolution (if upstream declines)

If Laravel is unwilling to change their generator, we could fall back to the original L1 plan for the subset of facades that return a ::class reference (~half of all facades). This would be a new virtual member provider that:

  1. Detects facade classes (extends Illuminate\Support\Facades\Facade).
  2. Parses getFacadeAccessor() for a ::class return value.
  3. Loads the concrete service class and forwards its instance methods as static virtual methods, preserving full signatures.
  4. Runs at higher priority than PHPDocProvider to shadow the flattened @method tags.

String-alias facades would still rely on the (flattened) @method tags. This is a worse outcome than the upstream fix because it only helps PHPantom users, only covers half the facades, and requires ongoing maintenance as Laravel's facade internals evolve.


Model property source gaps

The LaravelModelProvider synthesizes virtual properties from several sources on Eloquent models. The table below summarises what we handle today and what is still missing.

What we cover

Source Type info Notes
$casts / casts() Rich (built-in map, custom cast get() return type, enum, Castable, CastsAttributes<TGet> generics fallback)
$attributes defaults Literal type inference (string, bool, int, float, null, array) Fallback when no $casts entry
$fillable, $guarded, $hidden, $visible mixed Last-resort column name fallback
Legacy accessors (getXAttribute()) Method's return type
Modern accessors (returns Attribute) First generic arg of Attribute<TGet>, or mixed when unparameterised
Relationship methods Generic params or body inference
Relationship *_count properties int {snake_name}_count for each relationship method

Gaps (ranked by impact ÷ effort)


L2. morphedByMany missing from relationship method map

Impact: Low-Medium · Effort: Low

Any model using morphedByMany (the inverse of a polymorphic many-to-many) gets no virtual property or _count property for that relationship. One-line addition to RELATIONSHIP_METHOD_MAP.

morphedByMany is the inverse side of a polymorphic many-to-many relationship. It returns a MorphToMany instance (the same class as morphToMany), but the method name is not listed in RELATIONSHIP_METHOD_MAP. This means body inference (infer_relationship_from_body) does not recognise $this->morphedByMany(Tag::class) calls, so no virtual property or _count property is synthesized.

Where to change: Add ("morphedByMany", "MorphToMany") to RELATIONSHIP_METHOD_MAP in src/virtual_members/laravel.rs. No other changes needed since MorphToMany is already in COLLECTION_RELATIONSHIPS.

L4. Custom Eloquent builders (HasBuilder / #[UseEloquentBuilder])

Impact: High · Effort: Medium

Custom builders are the recommended pattern for complex query scoping in modern Laravel. Without this, users get zero completions for builder-specific methods via static model calls.

Laravel 11+ introduced the HasBuilder trait and #[UseEloquentBuilder(UserBuilder::class)] attribute to let models declare a custom builder class. When present, User::query() and all static builder-forwarded calls should resolve to the custom builder instead of the base Illuminate\Database\Eloquent\Builder.

/** @extends Builder<User> */
class UserBuilder extends Builder {
    /** @return $this */
    public function active(): static { ... }
}

class User extends Model {
    /** @use HasBuilder<UserBuilder> */
    use HasBuilder;
}

User::query()->active()->get(); // active() should resolve on UserBuilder

Larastan handles this via BuilderHelper::determineBuilderName(), which inspects newEloquentBuilder()'s return type or the #[UseEloquentBuilder] attribute to find the custom builder class.

Where to change: In build_builder_forwarded_methods, before loading the standard Eloquent\Builder, check whether the model declares a custom builder via @use HasBuilder<X> in use_generics or a newEloquentBuilder() method with a non-default return type. If found, load and resolve that builder class instead.

L5. abort_if/abort_unless type narrowing

Impact: High · Effort: Medium

These are the standard guard patterns in Laravel controllers and middleware. Without narrowing, variables keep their wider type, causing false "unknown member" warnings and missing completions.

After abort_if($user === null, 404), the type of $user should be narrowed to exclude null in subsequent code. Similarly, abort_unless($user instanceof Admin, 403) should narrow $user to Admin.

abort_if($user === null, 404);
$user->email;  // $user should be non-null here

abort_unless($user instanceof Admin, 403);
$user->grantPermission('edit');  // $user should be Admin here

Larastan handles this via AbortIfFunctionTypeSpecifyingExtension, a PHPStan-specific TypeSpecifyingExtension mechanism. The framework does not annotate these functions with @phpstan-assert — there are no stubs for this either.

Our guard clause narrowing already handles the pattern if ($x === null) { return; } + subsequent code, and we support @phpstan-assert-if-true/false. However, abort_if / abort_unless / throw_if / throw_unless don't follow either pattern: they are standalone function calls (not if-conditions) that conditionally throw.

Where to change: In type_narrowing.rs, add special-case handling for standalone abort_if(), abort_unless(), throw_if(), and throw_unless() calls. When the first argument is a type check expression (instanceof, === null, etc.), apply the inverse narrowing to subsequent code:

  • abort_if($x === null, ...) → narrow $x to non-null after
  • abort_unless($x instanceof Foo, ...) → narrow $x to Foo after
  • throw_if(...) / throw_unless(...) → same logic

This is similar to the existing guard clause narrowing but triggered by specific function names rather than if + early return.

L6. Factory has*/for* relationship methods

Impact: Low-Medium · Effort: Medium

Convenience for factory-heavy test suites. Without this, no completion after ->has or ->for on factory instances.

Laravel's Factory class supports dynamic has{Relationship}() and for{Relationship}() calls via __call(). For example, UserFactory::new()->hasPosts(3) checks that posts is a valid relationship on the User model, and UserFactory::new()->forAuthor($state) delegates to the for() method.

UserFactory::new()->hasPosts(3)->create();     // works at runtime
UserFactory::new()->forAuthor(['name' => 'J'])->create(); // works at runtime

The framework has no @method annotations for these — they are purely __call magic. Larastan handles this in ModelFactoryMethodsClassReflectionExtension, which inspects the factory's TModel template type, checks whether the camelCase remainder (after stripping has/for) is a valid relationship method, and synthesizes the method reflection dynamically.

Our LaravelFactoryProvider currently only synthesizes create() and make() methods.

Where to change: In LaravelFactoryProvider::provide, after synthesizing create()/make(), load the associated model class. For each relationship method on the model, push a has{Relationship} and for{Relationship} virtual method (PascalCase of the method name) that returns static (i.e. the factory class itself).

Larastan's ModelFactoryMethodsClassReflectionExtension reveals the exact parameter signatures to synthesize:

  • has{Rel}() — four overloads: no args, int $count, array|callable $state, or int $count, array|callable $state.
  • for{Rel}() — two overloads: no args, or array|callable $state.
  • trashed() — only synthesized when the model uses SoftDeletes. No parameters, returns static.

The strip-and-match algorithm: strip the has/for prefix, convert the remainder to camelCase, and check whether the model has a relationship method with that name. If not, the dynamic method is not offered.

L7. $pivot property on BelongsToMany related models

Impact: Medium · Effort: Medium-High

Pivot access is common in apps with many-to-many relationships. However, Larastan doesn't handle this either, and @property on custom Pivot classes covers most needs.

When a model is accessed through a BelongsToMany (or MorphToMany) relationship, each related model instance gains a $pivot property at runtime that provides access to intermediate table columns.

/** @return BelongsToMany<Role, $this> */
public function roles(): BelongsToMany {
    return $this->belongsToMany(Role::class)->withPivot('expires_at');
}

$user->roles->first()->pivot;           // Pivot instance — we know nothing about it
$user->roles->first()->pivot->expires_at; // accessible at runtime, invisible to us

There are several layers of complexity here:

  1. Basic $pivot property. Related models accessed through a BelongsToMany or MorphToMany relationship should have a $pivot property typed as \Illuminate\Database\Eloquent\Relations\Pivot (or the custom pivot class when ->using(CustomPivot::class) is used). We don't currently synthesize this property at all.

  2. withPivot() columns. The withPivot('col1', 'col2') call declares which extra columns are available on the pivot object. Tracking these requires parsing the relationship method body for chained withPivot calls — similar in difficulty to the withCount call-site problem (gap 5).

  3. Custom pivot models (using()). When ->using(OrderItem::class) is declared, the pivot is an instance of that custom class, which may have its own properties, casts, and accessors. Detecting this requires parsing the ->using() call in the relationship body.

Note: Larastan does not handle pivot properties either — the $pivot property comes from Laravel's own @property annotations on the BelongsToMany relationship stubs. If the user's stub set includes these annotations, it already works through our PHPDoc provider.

L8. withSum() / withAvg() / withMin() / withMax() aggregate properties

Impact: Low-Medium · Effort: Medium-High

Less common than withCount; only affects codebases using aggregate eager-loading. Cannot be inferred declaratively from the model alone; requires tracking call-site string arguments.

Similar to withCount, these aggregate methods produce virtual properties named {relation}_{function} (e.g. Order::withSum('items', 'price')$order->items_sum). The same call-site tracking challenge applies, and the type depends on the aggregate function (withSum/withAvgfloat, withMin/withMaxmixed).

The @property workaround applies here too.

L9. Higher-order collection proxies

Impact: Low-Medium · Effort: Medium-High

Larastan reference: higher-order-collection-proxy-methods.php (58 assertions) tests patterns like $users->map->name, $users->filter->isActive(), $users->avg->id(), etc. These will be useful as integration tests when implementing this feature.

Convenience syntax; most users prefer closures. Niche usage. Requires synthesizing virtual properties on collection classes that return a proxy type parameterised with the collection's value type.

Laravel collections support higher-order proxies via magic properties like $users->map->name or $users->filter->isActive(). These produce a HigherOrderCollectionProxy that delegates property access / method calls to each item in the collection.

$users->map->email;           // Collection<int, string>
$users->filter->isVerified(); // Collection<int, User>
$users->each->notify();       // void (side-effect)

Larastan handles this with HigherOrderCollectionProxyPropertyExtension and HigherOrderCollectionProxyExtension, which resolve the proxy's template types and delegate property/method lookups to the collection's value type.

L10. View::withX() and RedirectResponse::withX() dynamic methods

Impact: Low · Effort: Low

Most code uses ->with('key', $value) instead of the dynamic ->withKey($value) form. Explicitly declared methods (withErrors, withInput, etc.) already work.

Both Illuminate\View\View and Illuminate\Http\RedirectResponse support dynamic with*() calls via __call(). For example, view('home')->withUser($user) is equivalent to ->with('user', $user).

view('home')->withUser($user);         // dynamic, no @method annotation
redirect('/')->withErrors($errors);    // has explicit withErrors(), but withFoo() is dynamic

The framework provides no @method annotations for arbitrary with* calls — only specific ones like withErrors(), withInput(), withCookies() etc. are declared as real methods. Larastan handles the dynamic case in ViewWithMethodsClassReflectionExtension and RedirectResponseMethodsClassReflectionExtension, which treat any with* call as valid and returning $this.

Where to change: This could be handled with a lightweight virtual member provider that detects classes with a __call method whose body checks str_starts_with($method, 'with'), or by hard-coding the two known classes. A simpler approach: add @method tags to bundled stubs for the most common dynamic with* methods, or document this as a known limitation.


L11. Relation dot-notation string completion and column name string completion

Impact: Medium-High · Effort: Medium-High

Many Eloquent methods accept string arguments that refer to relationship names (with dot-notation for nested eager loads) or column/attribute names. PHPantom should offer completion inside these string literals.

Relation string completion

Eloquent methods like with(), load(), has(), whereHas(), doesntHave(), whereDoesntHave(), withCount(), loadCount(), loadMissing(), and loadMorph() accept relationship names as string arguments. These names correspond to public methods on the model that return a relationship instance (HasMany, BelongsTo, etc.).

Dot-notation chains traverse nested relationships:

// Eager-load: User → mother() → sister() → son()
$users = User::with(['mother.sister.son'])->get();

// Constrained eager load:
User::with(['posts' => function ($q) { ... }])->get();

// Nested with counts:
User::withCount(['posts', 'comments.replies'])->get();

Implementation:

  1. Detect when the cursor is inside a string argument to one of the target methods (listed above) where the receiver is an Eloquent model or Builder.
  2. Resolve the model class from the receiver type (e.g. User from User::with(...) or from the Builder's generic parameter).
  3. Scan the model for public methods whose return type extends one of the relationship base classes (HasOne, HasMany, BelongsTo, BelongsToMany, HasOneThrough, HasManyThrough, MorphOne, MorphMany, MorphTo, MorphToMany, MorphedByMany).
  4. Offer those method names as completions.
  5. When the string already contains a dot (e.g. 'mother.si|'), resolve the chain segment-by-segment: mother() returns BelongsTo<Person>, so look up Person's relationship methods for the next segment.
  6. Inside array literals (['posts', 'comments.|']), trigger on each element independently. Also handle the => closure form where the key is the relation string.

Target methods (non-exhaustive):

Method Where Notes
with() Builder, Model Eager loading
without() Builder Remove eager load
load() Model Lazy eager load
loadMissing() Model Load if not loaded
loadCount() Model Lazy count load
loadMorph() Model Morph relation load
has() Builder Existence query
orHas() Builder OR existence
doesntHave() Builder Non-existence
orDoesntHave() Builder OR non-existence
whereHas() Builder Constrained existence
orWhereHas() Builder OR constrained
whereDoesntHave() Builder Constrained non-existence
withCount() Builder Count sub-select
withSum() Builder Aggregate
withAvg() Builder Aggregate
withMin() Builder Aggregate
withMax() Builder Aggregate
withExists() Builder Existence flag
Column name string completion

Several Eloquent and Query Builder methods accept column name strings. PHPantom already synthesizes where{PropertyName}() dynamic methods from model properties, but the same property names should also be offered as string completions inside methods that take column arguments:

// Column name as string:
User::where('middle_name', 'like', '%foo%');
User::whereIn('status', ['active', 'pending']);
User::orderBy('created_at');
User::select(['id', 'name', 'email']);
User::pluck('email');

// Also in update/insert arrays (keys are column names):
$user->update(['middle_name' => 'Jane']);

Implementation:

  1. Detect when the cursor is inside a string argument to a method on a Builder or Model where the parameter represents a column name. The parameter name or position in the known method signatures identifies column-position arguments (e.g. the first argument to where(), whereIn(), orderBy(), groupBy(), select(), pluck(), value(), increment(), decrement(), etc.).
  2. Resolve the model class from the Builder's generic parameter.
  3. Collect all known property names from the model: $casts, $fillable, $guarded, $hidden, $visible, $attributes defaults, @property annotations, accessor methods, and timestamp columns.
  4. Offer those names as string completions.

Target methods (non-exhaustive):

Method Column parameter position
where() 1st argument
orWhere() 1st argument
whereIn() 1st argument
whereNotIn() 1st argument
whereBetween() 1st argument
whereNull() 1st argument
whereNotNull() 1st argument
orderBy() 1st argument
orderByDesc() 1st argument
groupBy() all arguments
having() 1st argument
select() all arguments
addSelect() all arguments
pluck() 1st argument
value() 1st argument
increment() 1st argument
decrement() 1st argument
latest() 1st argument
oldest() 1st argument
model-property<T> type resolution

Larastan defines a model-property<Model> pseudo-type that represents the union of column/attribute names on a given Eloquent model as string literals. PHPantom currently does not recognize this type, which produces false "unknown class" diagnostics when it appears in docblocks (see #33).

/** @return array{process: array<model-property<Process>, mixed>} */
public function broadcastWith(): array { return []; }

This should be handled as part of L11 since the column name knowledge is the same data:

  1. Minimum viable: Recognize model-property<T> as a string subtype so it never triggers an "unknown class" diagnostic.
  2. Full resolution: When the generic argument resolves to an Eloquent model, expand model-property<Process> to the concrete union of known property names (from $casts, $fillable, @property, accessors, timestamps, etc.). This enables type checking at call sites and connects directly to the column name list that L11's string completion already builds.

Relationship with L1 (Facade completion): This feature is independent and can be implemented before or after L1. The column name source (model property list) is the same data that LaravelModelProvider already collects for virtual property synthesis and where{PropertyName}() dynamic methods.

L12. HasUuids / HasUlids trait — $id typed as string

Impact: Low-Medium · Effort: Low

Models that use Illuminate\Database\Eloquent\Concerns\HasUuids or HasUlids have their primary key ($id by default) typed as string instead of int. Currently PHPantom does not inspect these traits, so $model->id resolves to int (from the default Model stub) instead of string.

Larastan's bug-2188.php tests this: assertType('string', $uuidModel->id).

Where to change: In LaravelModelProvider::provide, after synthesizing other virtual properties, check whether the model's used_traits (recursively, including parent traits) contains HasUuids or HasUlids. If so, synthesize a virtual id property typed as string (or override the existing one). The trait also overrides getKeyType() to return 'string' and getIncrementing() to return false, but for virtual property purposes just the id type is the main gap.

Alternatively, if the stubs for these traits include @property tags or a typed $id override, the PHPDoc provider may handle it automatically once the traits are loaded.

L13. Factory count-conditional return types

Impact: Medium · Effort: Medium-High

User::factory()->create() returns User, but User::factory(3)->create() and User::factory()->count(3)->create() return Collection<int, User>. Larastan resolves this via conditional return type extensions that inspect whether count() or the $count constructor argument was called with a non-null value.

Currently PHPantom always resolves create() and make() to the single model type. This is correct for the common case but wrong when count() or times() has been called, or when the factory constructor receives an integer argument.

Larastan's model-factories.php has 64 assertions covering these patterns, including count(null) resetting back to single-model return.

Where to change: This requires tracking call-chain state (whether count()/times() was called) through the resolution pipeline, which is non-trivial. Possible approaches:

  1. Conservative: Always return User|Collection<int, User> union when count state is ambiguous. Simple but noisy.
  2. Chain-aware: Track whether count()/times() appears in the chain before create()/make(). If yes, return Collection<int, User>. If no, return User. Requires the resolver to inspect preceding calls in the chain.
  3. Argument-aware: Also check User::factory($count) for a literal integer argument. Most complex but most accurate.

Approach 2 is likely the best balance. The factory virtual member provider already knows the model type; it would need to emit different return types for create()/make() based on whether the chain includes a count-setting call.