Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Eloquent morph map aliases.** A `Relation::morphMap(['post' => Post::class, …])` or `Relation::enforceMorphMap([…])` call in a service provider is now recovered, so the short aliases it registers behave like real symbols wherever they appear as string literals. Hover names the model an alias maps to and the file that registers it, go-to-definition offers both the registration and the model, and find-references links every usage back to the registration. The alias positions Eloquent actually resolves through the map are recognized: the registration's own keys, `Relation::getMorphedModel()`, `Model::getActualClassNameForMorph()`, and the `$types` argument of the `whereHasMorph()` family (including the `'*'` wildcard and class-name spellings, which are left alone). Laravel's list shorthand `Relation::morphMap([Post::class, …])` is understood too, keyed by each model's table name the way the framework derives it. When the project calls `enforceMorphMap()` (or `requireMorphMap()`) the map is the exhaustive set of morph types, so an alias that is not registered is flagged; without it the set stays open and nothing is reported.
- **Workspace-wide diagnostics.** PHPantom now surfaces problems across the whole project, not just the files you have open. After startup and the full background index finish, diagnostics run in the background over every file and stream into the editor's problems panel as they're found, so issues in files you haven't opened yet are already visible when you navigate to them. Configured tools (PHPStan, PHPCS, Mago) also run once over the whole project afterwards, when the project has its own configuration file for that tool. Both passes are deliberately deferred until after startup so they never slow down the time it takes for the editor to become usable. Disable with `[diagnostics] workspace = false` (native pass) or `workspace-external = false` (external tools) in `.phpantom.toml`.
- **Higher-order collection proxies.** `$users->map->email` is Laravel shorthand for `$users->map(fn ($u) => $u->email)`, and PHPantom now types it that way: the item type's members complete and hover through the proxy, and each one resolves to whatever the proxied collection method returns for it — `map` collects the member, `filter` and `each` keep the collection, `first` gives you one nullable item, `contains` a `bool`, `sum` a number. The result is an ordinary collection, so the chain continues from it. The proxy remembers which collection it came from, so a method that returns `static` stays on an Eloquent or application-defined collection, while mapping to a value that is not a model falls back to the base collection exactly as Eloquent does at runtime. Contributed by @shuvroroy (#314).
- **Model factories know how many models they build.** `User::factory()->create()` gives you a `User`, but the moment the chain sets a count it gives you a collection of them, and PHPantom now reads that off the call site. `count(3)`, `times(3)`, and an integer argument to `factory(3)` all switch `create()`, `createQuietly()`, and `make()` over to the collection the model actually builds — a custom Eloquent collection included — so the members that complete, hover, and resolve are the collection's, and iterating it or calling `first()` gets you back to the model. A `count(null)` clears the count again, and calls that pass a count for something else, like `hasPosts(3)`, leave the return type alone. A factory that writes its own `create()` keeps whatever it declares. Contributed by @shuvroroy (#315).
- **Enum validation rules type their field.** A `validated()` array shape used to give up on an enum rule and call the field `mixed`, because the rule is an object (`new Enum(Role::class)`, `Rule::enum(Role::class)`) rather than a rule name. The field now takes the enum's backing type (`string` for a string-backed enum, `int` for an int-backed one), which is what the validated array actually holds, since it carries the raw input rather than the enum case. The enum is read the same way whichever spelling it has, including a fluent chain like `Rule::enum(Role::class)->only([…])`, and its class name is resolved against the imports of the file that declares the rules, so a `FormRequest`'s own `use` statements are what count. A pure enum has no raw scalar form, so those fields stay `mixed` rather than being guessed at. Contributed by @shuvroroy (#307).

### Changed
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ unlikely to move the needle for most users.
| L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Low |
| L6 | Factory `has*`/`for*` relationship methods | Low-Medium | Medium |
| L7 | `$pivot` property on BelongsToMany | Medium | Medium-High |
| L13 | [Factory count-conditional return types](todo/laravel.md#l13-factory-count-conditional-return-types) | Medium | Medium-High |
| L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | Medium-High |
| L45 | [`*_count` properties are offered on every relationship](todo/laravel.md#l45-_count-properties-are-offered-on-every-relationship) | Low-Medium | Medium-High |
| L10 | `View::withX()` / `RedirectResponse::withX()` dynamic methods | Low | Low |
Expand Down
38 changes: 0 additions & 38 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,44 +399,6 @@ 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.

#### L41. Morph aliases in `*_type` column comparisons

**Impact: Low-Medium · Effort: Medium**
Expand Down
31 changes: 30 additions & 1 deletion examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Models\BlogPost;
use App\Models\Review;
use App\Models\ReviewCollection;
use Database\Factories\BlogAuthorFactory;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Carbon\CarbonImmutable;
Expand Down Expand Up @@ -170,8 +171,8 @@ public function factories(): void
BlogAuthor::factory()->hasProfile(); // HasOne profile → factory

// The chain stays on the factory, so create() still returns the model.
// hasPosts(3) counts *related* models, not the ones being built.
BlogAuthor::factory()->hasPosts(3)->create(); // → BlogAuthor
BlogAuthor::factory()->count(2)->hasPosts(3)->create(); // → BlogAuthor

// for{Relationship}() — for the inverse (BelongsTo) side.
BlogPost::factory()->forAuthor(['name' => 'Ada']); // BelongsTo author → factory
Expand All @@ -183,6 +184,34 @@ public function factories(): void
}


// ── Factory count state ─────────────────────────────────────────────────
// create()/make() build one model, or a Collection of them once the
// chain sets a count — through count(), times(), or an integer argument
// to factory(). count(null) clears it again.

public function factoryCounts(): void
{
// No count → a single model.
BlogAuthor::factory()->create()->displayName; // → BlogAuthor
BlogAuthor::factory(['name' => 'Ada'])->make(); // array is state → BlogAuthor

// Count set → a collection of them. BlogAuthor declares
// #[CollectedBy(AuthorCollection::class)], so that is what comes back.
BlogAuthor::factory(3)->create()->first(); // → BlogAuthor|null
BlogAuthor::factory()->count(2)->create()->emails(); // → array<string>
BlogAuthor::factory()->times(2)->make()->byName(); // → AuthorCollection
BlogAuthorFactory::times(2)->create()->active(); // → AuthorCollection

// The count carries past intervening calls, and count(null) clears it.
BlogAuthor::factory()->count(2)->hasPosts(3)->create()->first(); // → BlogAuthor|null
BlogAuthor::factory(3)->count(null)->create()->displayName; // → BlogAuthor

foreach (BlogAuthor::factory(3)->create() as $author) {
$author->displayName; // → BlogAuthor
}
}


// ── Custom Eloquent Collections ─────────────────────────────────────────

public function customCollection(): void
Expand Down
34 changes: 34 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,40 @@ class_uses_recursive(\App\Models\BlogPost::class),
\App\Models\BlogPost::factory()->trashed() instanceof \Illuminate\Database\Eloquent\Factories\Factory
);

// ─── Factory count-conditional return types ──────────────────────────────────

// create()/make() hand back a single model until the chain sets a count, at
// which point they hand back a collection — the one the model itself builds,
// so BlogAuthor's #[CollectedBy(AuthorCollection::class)] applies here too.
check(
'BlogAuthor::factory()->make() builds one model',
\App\Models\BlogAuthor::factory()->make() instanceof \App\Models\BlogAuthor
);
check(
'an array argument to factory() is state, not a count',
\App\Models\BlogAuthor::factory(['name' => 'Ada'])->make() instanceof \App\Models\BlogAuthor
);
check(
'BlogAuthor::factory(3)->make() builds the model collection',
\App\Models\BlogAuthor::factory(3)->make() instanceof \App\Models\AuthorCollection
);
check(
'BlogAuthor::factory()->count(2)->make() builds two models',
\App\Models\BlogAuthor::factory()->count(2)->make()->count() === 2
);
check(
'BlogAuthorFactory::times(2)->make() builds the model collection',
\Database\Factories\BlogAuthorFactory::times(2)->make() instanceof \App\Models\AuthorCollection
);
check(
'count(null) clears a count set through factory($count)',
\App\Models\BlogAuthor::factory(3)->count(null)->make() instanceof \App\Models\BlogAuthor
);
check(
'hasPosts() counts related models, not the ones being built',
\App\Models\BlogAuthor::factory()->hasPosts(3)->make() instanceof \App\Models\BlogAuthor
);

// ─── Carbon macro closure scope binding ──────────────────────────────────────

// Carbon binds macro closures with the target class as scope, so `self::`
Expand Down
17 changes: 17 additions & 0 deletions src/type_engine/call_resolution/return_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,23 @@ impl Backend {
return classes;
}

// Laravel factory count state: `create()`/`make()` build
// a single model, or a collection of them when the chain
// set a count (`factory(3)`, `count(3)`, `times(3)`).
if let Some((classes, hint)) =
crate::virtual_members::laravel::resolve_factory_count_return(
base,
method_name,
&lhs_resolved,
ctx,
)
{
if let Some(ref mut hint_out) = return_type_hint_out {
**hint_out = Some(hint);
}
return classes;
}

// Laravel validated input: `validated()`, `validate([…])`
// and `safe()->only([…])` return the array shape the
// validation rules in scope describe. An array shape has
Expand Down
37 changes: 27 additions & 10 deletions src/virtual_members/laravel/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ fn is_eloquent_factory(class_name: &str) -> bool {
/// `Illuminate\Database\Eloquent\Factories\Factory`.
///
/// Returns `true` if the class itself is `Factory` or any ancestor is.
fn extends_eloquent_factory(
pub(crate) fn extends_eloquent_factory(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> bool {
Expand All @@ -135,6 +135,30 @@ fn has_factory_extends_generic(class: &ClassInfo) -> bool {
})
}

/// The model type a factory class builds.
///
/// Prefers the explicit `@extends Factory<App\Models\User>` annotation
/// that Laravel's own factory stub carries, and falls back to the naming
/// convention (e.g. `Database\Factories\UserFactory` → `App\Models\User`)
/// for factories written without it. The convention branch only answers
/// when the model class actually exists, so a `Factory` subclass that
/// happens to be named `SomethingFactory` without a matching model does
/// not invent one.
pub(crate) fn factory_model_type(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Option<PhpType> {
if let Some(model) = class.extends_generics.iter().find_map(|(name, args)| {
let short = name.rsplit('\\').next().unwrap_or(name);
(short == "Factory").then(|| args.last()).flatten()
}) {
return Some(model.clone());
}

let model_fqn = factory_to_model_fqn(&class.name)?;
class_loader(&model_fqn).map(|_| PhpType::named(atom(model_fqn.as_ref())))
}

/// Build virtual `create()` and `make()` methods for a factory class
/// that does not have `@extends Factory<Model>`.
///
Expand All @@ -144,18 +168,11 @@ fn build_factory_model_methods(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Vec<MethodInfo> {
let model_fqn = match factory_to_model_fqn(&class.name) {
Some(fqn) => fqn,
let model_type = match factory_model_type(class, class_loader) {
Some(ty) => ty,
None => return Vec::new(),
};

// Verify the model class actually exists.
if class_loader(&model_fqn).is_none() {
return Vec::new();
}

let model_type = PhpType::named(atom(model_fqn.as_ref()));

vec![
MethodInfo::virtual_method_typed("create", Some(&model_type)),
MethodInfo::virtual_method_typed("make", Some(&model_type)),
Expand Down
Loading
Loading