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) |
| 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. |
- 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
@methodtags. Laravel's facades ship with comprehensive@method statictags (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 Tthat would letApp::make(Foo::class)resolve toFoo. 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.
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 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.
The original L1 plan proposed resolving facades by parsing
getFacadeAccessor() to find the concrete class and forwarding its
methods. This has fundamental problems:
- Half the facades use string aliases.
Authreturns'auth',Cachereturns'cache',DBreturns'db', etc. These require booting the container to resolve, which is out of scope. - The
@seeannotations are informal. Facades list multiple@seeclasses (e.g.Authreferences bothAuthManagerandSessionGuard), and the relationship between them isn't formal (one may be a@mixinof the other). We'd be reverse-engineering an undocumented script's output logic. - Only PHPantom would benefit. Any resolution technique we build is specific to our tool. Other IDEs and LSPs face the same problem.
- Manager pattern complicates things. Many facades delegate to a
"manager" class (
AuthManager,CacheManager) which in turn delegates to a "driver" (SessionGuard,FileStore). The@methodtags are already a flattened merge of both levels. We'd need to replicate that merging logic.
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:
- When a method has
@templateparameters, emit them in the@methodtag using<T>syntax after the method name. - 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. - For facades using string aliases: the
@methodtags 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
@templateparameters on@methodtags (the<T>syntax after the method name). Ourextract_method_tagsindocblock/virtual_members.rscurrently ignores this syntax. - Apply template substitution at call sites for
@methodtags, 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()— theGenericTypeNodebranch strips generic arguments entirely (Collection<int, User>→Collection). Needs to preserve args in the output string.handleUnknownIdentifierType()— when encountering a template parameter likeT, resolves it to its bound (T of object→object) ormixed. Needs to instead emitTas-is and add a<T of object>clause to the@methodtag.ConditionalTypeForParameterNodehandling — unions the if/else branches (($id is int ? static : Collection)→static|Collection). Needs to preserve the conditional syntax verbatim.- The
class-stringcase inIdentifierTypeNodehandling — mapsclass-stringtostring, losing the generic arg that carries the template binding. Needs to preserveclass-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@methodtags? 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
@methodwith templates via@templatetags on the class docblock, but does it support inline<T>on the@methodtag 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).
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:
- Detects facade classes (extends
Illuminate\Support\Facades\Facade). - Parses
getFacadeAccessor()for a::classreturn value. - Loads the concrete service class and forwards its instance methods as static virtual methods, preserving full signatures.
- Runs at higher priority than
PHPDocProviderto shadow the flattened@methodtags.
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.
The LaravelModelProvider synthesizes virtual properties from several
sources on Eloquent models. The table below summarises what we handle
today and what is still missing.
| 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 |
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.
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 UserBuilderLarastan 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.
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 hereLarastan 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$xto non-null afterabort_unless($x instanceof Foo, ...)→ narrow$xtoFooafterthrow_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.
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 runtimeThe 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, orint $count, array|callable $state.for{Rel}()— two overloads: no args, orarray|callable $state.trashed()— only synthesized when the model usesSoftDeletes. No parameters, returnsstatic.
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.
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 usThere are several layers of complexity here:
-
Basic
$pivotproperty. Related models accessed through aBelongsToManyorMorphToManyrelationship should have a$pivotproperty 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. -
withPivot()columns. ThewithPivot('col1', 'col2')call declares which extra columns are available on the pivot object. Tracking these requires parsing the relationship method body for chainedwithPivotcalls — similar in difficulty to thewithCountcall-site problem (gap 5). -
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.
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/withAvg → float,
withMin/withMax → mixed).
The @property workaround applies here too.
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.
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 dynamicThe 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.
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.
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:
- 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.
- Resolve the model class from the receiver type (e.g.
UserfromUser::with(...)or from the Builder's generic parameter). - 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). - Offer those method names as completions.
- When the string already contains a dot (e.g.
'mother.si|'), resolve the chain segment-by-segment:mother()returnsBelongsTo<Person>, so look upPerson's relationship methods for the next segment. - 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 |
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:
- 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.). - Resolve the model class from the Builder's generic parameter.
- Collect all known property names from the model:
$casts,$fillable,$guarded,$hidden,$visible,$attributesdefaults,@propertyannotations, accessor methods, and timestamp columns. - 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 |
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:
- Minimum viable: Recognize
model-property<T>as astringsubtype so it never triggers an "unknown class" diagnostic. - 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.
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.
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:
- Conservative: Always return
User|Collection<int, User>union when count state is ambiguous. Simple but noisy. - Chain-aware: Track whether
count()/times()appears in the chain beforecreate()/make(). If yes, returnCollection<int, User>. If no, returnUser. Requires the resolver to inspect preceding calls in the chain. - 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.