Skip to content

Commit f6d6fa0

Browse files
committed
Laravel factory support
1 parent 1bdccb2 commit f6d6fa0

9 files changed

Lines changed: 1301 additions & 41 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Factory support.** `User::factory()->create()` and `->make()` now resolve to the model class. When a model uses the `HasFactory` trait with an explicit `@use HasFactory<UserFactory>` annotation, the generics system handles resolution. When the annotation is absent, the naming convention is used as a fallback: `App\Models\User` maps to `Database\Factories\UserFactory`, and subdirectories are preserved (`App\Models\Admin\SuperUser` maps to `Database\Factories\Admin\SuperUserFactory`). Factory chain methods that return `static` (e.g. `count()`, `state()`) continue the chain on the factory, while `create()` and `make()` return the model. The convention also works in reverse: a factory class extending `Factory` without `@extends Factory<Model>` resolves `TModel` from its own class name. Both directions are implemented as fallbacks that defer to explicit generics when present.
1213
- **`newCollection()` override detection.** Eloquent models that override the `newCollection()` method now resolve to the custom collection class declared in the method's return type. This is the third detection mechanism alongside `#[CollectedBy]` and `@use HasCollection<X>`. Priority order: attribute, trait, method override. Works with short names resolved via `use` imports and fully-qualified return types. The standard `Collection` return type is correctly ignored. Custom collection methods appear after `->get()`, on relationship properties, and in builder chains.
1314
- **Body-inferred relationship properties.** Eloquent relationship methods that lack `@return` annotations now produce virtual properties by scanning the method body for patterns like `$this->hasMany(Post::class)`. The relationship type is inferred from the method name (`hasMany` to `HasMany`, `belongsTo` to `BelongsTo`, etc.) and the related model class is extracted from the first `::class` argument. Supports all 10 relationship types including `HasOneThrough`. Fully-qualified class names, extra foreign key arguments, and chained builder calls (e.g. `->latest()`) are handled. When both a `@return` annotation and a body pattern are present, the annotation takes priority. Projects that don't use Larastan no longer need to add annotations for basic relationship completion.
1415
- **Laravel accessor and mutator virtual properties.** Eloquent models with legacy accessors (`getFullNameAttribute()`) or modern Laravel 9+ accessors (methods returning `Illuminate\Database\Eloquent\Casts\Attribute`) now produce virtual properties. The property name is derived by converting the method name to snake_case: `getFullNameAttribute` produces `$full_name`, and `avatarUrl()` produces `$avatar_url`. Legacy accessors use the method's declared return type; modern accessors use `mixed`. Works alongside relationship properties, scope methods, and builder forwarding. `getAttribute()` (a real Eloquent method) is correctly excluded.

docs/todo-laravel.md

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ virtual member provider design, see `ARCHITECTURE.md`.
88

99
---
1010

11-
## Known gaps (documented in tests)
11+
## Missing features
1212

1313
### 1. Variable assignment from builder-forwarded static method in GTD
1414

@@ -22,32 +22,18 @@ to the declaring class in a Builder loaded through the chain. This
2222
works for completion (which only needs the type) but not for GTD (which
2323
needs the source location).
2424

25-
---
26-
27-
## Missing features
28-
29-
### 2. Factory support
30-
31-
`User::factory()->create()` is ubiquitous in Laravel test code. The
32-
`factory()` static method returns a `HasFactory` trait method that
33-
produces a factory instance. Resolving the chain requires:
34-
35-
1. Detecting the `HasFactory` trait on the model.
36-
2. Resolving `factory()` to the model's corresponding Factory class
37-
(convention: `App\Models\User``Database\Factories\UserFactory`).
38-
3. Resolving `create()` / `make()` on the factory to return the model.
39-
40-
This is medium complexity because it involves a naming convention
41-
(model name → factory name) and cross-file resolution.
42-
43-
### 3. Closure parameter inference in collection pipelines
25+
### 2. Closure parameter inference in collection pipelines
4426

4527
`$users->map(fn($u) => $u->...)` does not infer `$u` as the
4628
collection's element type. This is a general generics/callable
4729
inference problem, not Laravel-specific, but Laravel collection
4830
pipelines are the most common place users encounter it.
31+
Other cases:
32+
- MyModel::whereIn()->chunk(self::CHUNK_SIZE, function (Collection $orders) {})
33+
- MyModel::whereHas('order', function (Builder $q) {})
34+
- MyModel::with(['translations' => function (Relation $query) {}]) // translations is the name of the relation on MyModel, Relation will become the return type of that relation
4935

50-
### 4. Query scope chaining on Builder instances
36+
### 3. Query scope chaining on Builder instances
5137

5238
Inside a scope method body, `$query->verified()` (calling another
5339
scope) does not offer scope method completions. Scope methods are
@@ -61,6 +47,11 @@ scope methods as instance methods on the resolved Builder. This
6147
requires extending the virtual member system to also apply to
6248
Builder instances, not just Model classes.
6349

50+
### 4. Scopes on queries
51+
52+
Copes are missing from complation of a query after other query
53+
opertions for example Brand::where('id', $id)->isActive(); // Brand has a method called scopeIsActive()
54+
6455
---
6556

6657
## Out of scope (and why)
@@ -87,4 +78,4 @@ Builder instances, not just Model classes.
8778
are best-effort.
8879
- **Facades fall back to `@method`.** Facades whose `getFacadeAccessor()`
8980
returns a string alias cannot be resolved. `@method` tags on facade
90-
classes provide completion without template intelligence.
81+
classes provide completion without template intelligence.

example.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,8 @@ function handleIntersection(User&Loggable $entity): void {
364364
// ── Trait Generic Substitution ──────────────────────────────────────────────
365365

366366
Product::factory()->create(); // @use HasFactory<UserFactory> → UserFactory
367-
Product::factory()->count(5);
367+
Product::factory()->count(5)->make(); // count() returns static, make() returns Product
368+
Product::factory()->state([])->create(); // state() returns static, create() returns Product
368369

369370
$idx = new UserIndex(); // @use Indexable<int, User>
370371
$idx->get()->getEmail(); // TValue → User
@@ -2407,6 +2408,7 @@ class UserFactory
24072408
{
24082409
public function create(): User { return new User('', ''); }
24092410
public function count(int $n): static { return $this; }
2411+
public function state(array $state): static { return $this; }
24102412
public function make(): User { return new User('', ''); }
24112413
}
24122414

src/completion/handler.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,6 @@ impl Backend {
661661
};
662662
Self::resolve_target_classes(&target.subject, target.access_kind, &rctx)
663663
};
664-
665664
if candidates.is_empty() {
666665
return vec![];
667666
}

src/inheritance.rs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ use crate::types::{
2525
ParamCondition, PropertyInfo, TraitAlias, TraitPrecedence, Visibility,
2626
};
2727
use crate::util::short_name;
28+
use crate::virtual_members::laravel::{
29+
extends_eloquent_model, factory_to_model_fqn, model_to_factory_fqn,
30+
};
2831

2932
impl Backend {
3033
/// Resolve a class together with all inherited members from its parent
@@ -98,7 +101,29 @@ impl Backend {
98101
// Look through current's `extends_generics` for an entry
99102
// whose class name matches this parent, and zip its type
100103
// arguments with the parent's `template_params`.
101-
let level_subs = build_substitution_map(&current, &parent, &active_subs);
104+
let mut level_subs = build_substitution_map(&current, &parent, &active_subs);
105+
106+
// ── Convention-based Factory fallback ────────────────────
107+
// When a factory class extends `Factory` without
108+
// `@extends Factory<Model>`, derive the model class from
109+
// the naming convention (e.g. `Database\Factories\UserFactory`
110+
// → `App\Models\User`) and substitute `TModel` automatically.
111+
if level_subs.is_empty()
112+
&& !parent.template_params.is_empty()
113+
&& is_factory_class(parent_name)
114+
{
115+
let factory_fqn = match &current.file_namespace {
116+
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, current.name),
117+
_ => current.name.clone(),
118+
};
119+
if let Some(model_fqn) = factory_to_model_fqn(&factory_fqn)
120+
&& class_loader(&model_fqn).is_some()
121+
{
122+
for param in &parent.template_params {
123+
level_subs.insert(param.clone(), model_fqn.clone());
124+
}
125+
}
126+
}
102127

103128
// Merge traits used by the parent class as well, so that
104129
// grandparent-level trait members are visible.
@@ -252,7 +277,30 @@ impl Backend {
252277
// Build a substitution map for this trait if the using class
253278
// declared `@use TraitName<Type1, Type2>` and the trait has
254279
// `@template` parameters.
255-
let trait_subs = build_trait_substitution_map(trait_name, &trait_info, use_generics);
280+
let mut trait_subs =
281+
build_trait_substitution_map(trait_name, &trait_info, use_generics);
282+
283+
// ── Convention-based HasFactory fallback ─────────────────
284+
// When a model uses `HasFactory` without `@use HasFactory<X>`,
285+
// derive the factory class from the naming convention
286+
// (e.g. `App\Models\User` → `Database\Factories\UserFactory`)
287+
// and substitute `TFactory` automatically.
288+
if trait_subs.is_empty()
289+
&& !trait_info.template_params.is_empty()
290+
&& is_has_factory_trait(trait_name)
291+
&& extends_eloquent_model(merged, class_loader)
292+
{
293+
let model_fqn = match &merged.file_namespace {
294+
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, merged.name),
295+
_ => merged.name.clone(),
296+
};
297+
let factory_fqn = model_to_factory_fqn(&model_fqn);
298+
if class_loader(&factory_fqn).is_some() {
299+
for param in &trait_info.template_params {
300+
trait_subs.insert(param.clone(), factory_fqn.clone());
301+
}
302+
}
303+
}
256304

257305
// Recursively merge traits used by this trait (trait composition).
258306
// The sub-trait's own `@use` generics (from the trait's docblock)
@@ -454,6 +502,22 @@ impl Backend {
454502

455503
// ─── Generic Type Substitution ──────────────────────────────────────────────
456504

505+
/// Check whether a trait name is the Laravel `HasFactory` trait.
506+
///
507+
/// Matches the FQN `Illuminate\Database\Eloquent\Factories\HasFactory`
508+
/// as well as the short name `HasFactory` (common in same-file tests).
509+
fn is_has_factory_trait(trait_name: &str) -> bool {
510+
let stripped = trait_name.strip_prefix('\\').unwrap_or(trait_name);
511+
stripped == "Illuminate\\Database\\Eloquent\\Factories\\HasFactory" || stripped == "HasFactory"
512+
}
513+
514+
/// Check whether a parent class name is the Laravel
515+
/// `Illuminate\Database\Eloquent\Factories\Factory` base class.
516+
fn is_factory_class(class_name: &str) -> bool {
517+
let stripped = class_name.strip_prefix('\\').unwrap_or(class_name);
518+
stripped == "Illuminate\\Database\\Eloquent\\Factories\\Factory" || stripped == "Factory"
519+
}
520+
457521
/// Build a substitution map for a trait based on `@use` generics and the
458522
/// trait's `@template` parameters.
459523
///

src/resolution.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ impl Backend {
166166
let file_namespace = self.parse_namespace(content);
167167
Self::resolve_parent_class_names(&mut classes, &file_use_map, &file_namespace);
168168

169+
// Set the per-class file_namespace so that classes loaded via
170+
// PSR-4 / classmap carry their namespace. This mirrors the
171+
// same assignment done in `update_ast_inner` for files opened
172+
// through `did_open` / `did_change`.
173+
for cls in &mut classes {
174+
if cls.file_namespace.is_none() {
175+
cls.file_namespace = file_namespace.clone();
176+
}
177+
}
178+
169179
if let Ok(mut map) = self.ast_map.lock() {
170180
map.insert(uri.to_owned(), classes.clone());
171181
}

0 commit comments

Comments
 (0)