# 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:

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

But the `App` facade emits:

```php
@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:

```php
/**
 * @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 object` →
  `object`) 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)

---

#### 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`.

```php
/** @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`.

```php
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.

```php
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.

```php
/** @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`/`withAvg` → `float`,
`withMin`/`withMax` → `mixed`).

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.

```php
$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)`.

```php
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:

```php
// 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:

```php
// 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](https://github.com/AJenbo/phpantom_lsp/issues/33)).

```php
/** @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.

