Skip to content

Commit f0aaf09

Browse files
committed
Add support for Laravel Eloquent scopes
1 parent bb03e6a commit f0aaf09

6 files changed

Lines changed: 612 additions & 20 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@ PHPantom focuses on completion and go-to-definition and aims to do them really w
1616
| Go-to-definition ||||||
1717
| Go-to-implementation | 🚧 |||||
1818
| `@mixin` completion || 💰 ||| 🚧 |
19-
| `@phpstan-assert` narrowing ||| 🚧 || 🚧 |
19+
| `@phpstan` annotations ||| 🚧 || 🚧 |
2020
| Conditional return types ||||| 🚧 |
21+
| Laravel Eloquent ||||||
2122
| Array shape inference ||||| 🚧 |
2223
| Object shape completion ||||| 🚧 |
23-
| `@phpstan-type` aliases ||| 🚧 |||
24-
| Laravel relationships ||||||
2524
| Generator body types | 🚧 || 🚧 |||
2625
| Hover ||||||
2726
| Signature help ||||||
@@ -44,6 +43,7 @@ PHPantom focuses on completion and go-to-definition and aims to do them really w
4443
- **Generic collection foreach.** Iterating `Collection<User>`, `Generator<int, Item>`, or a class with `@implements IteratorAggregate<int, User>` resolves the loop variable to the element type. Keys too.
4544
- **Generics.** `@template` with type substitution through inheritance chains and at call sites.
4645
- **Conditional return types.** PHPStan-style `@return ($param is class-string<T> ? T : mixed)` resolves to the concrete branch at each call site.
46+
- **Laravel Eloquent.** Relationship methods produce virtual properties with the correct generic type (`$user->posts` resolves to `Collection<Post>`). Scope methods are synthesized from `scope*` prefixed methods and available as both static and instance calls. No application booting, no database access, no ide-helper dependency required.
4747
- **Everything else you'd expect.** Named arguments completion, destructuring with named keys, chained method calls in assignments, `@deprecated` detection.
4848

4949
## Project Awareness

docs/CHANGELOG.md

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

1212
- **Virtual member provider abstraction.** Introduced the `VirtualMemberProvider` trait and `VirtualMembers` struct in a new `virtual_members` module. This provides a priority-ordered pipeline for synthesizing members from `@method`/`@property` tags, `@mixin` classes, and framework-specific patterns (e.g. Laravel relationships, scopes, Builder forwarding). All completion and go-to-definition call sites now use the new `resolve_class_fully` entry point, which applies base inheritance resolution followed by virtual member providers. No providers are registered yet, so behavior is unchanged. This is the foundation for upcoming Laravel support.
1313
- **Laravel relationship properties.** Classes extending `Illuminate\Database\Eloquent\Model` now get virtual properties synthesized from relationship methods. A method returning `HasMany<Post, $this>` produces a `$posts` property typed as `\Illuminate\Database\Eloquent\Collection<Post>`, and `HasOne<Profile, $this>` produces a `$profile` property typed as `Profile`. Supports `HasOne`, `HasMany`, `BelongsTo`, `BelongsToMany`, `MorphOne`, `MorphMany`, `MorphTo`, `MorphToMany`, and `HasManyThrough`. Generic type parameters are extracted from Larastan-style `@return` annotations. The synthesized properties sit at the highest virtual member priority, beating `@property` tags from ide-helper and `@mixin` members. Works with `$this->`, instance variables, relationship methods defined in traits, indirect Model subclasses (through a BaseModel), fully-qualified return types, and cross-file PSR-4 resolution. Chaining through relationship properties (e.g. `$user->profile->getBio()`) resolves to the related model's members.
14+
- **Laravel scope methods.** Methods starting with `scope` on Eloquent model classes (e.g. `scopeActive`, `scopeOfType`) now produce virtual methods with the prefix stripped and the first letter lowercased (`active`, `ofType`). The first `$query` parameter is removed since Laravel injects it automatically. Scope methods are available as both static and instance methods, so `User::active()` and `$user->active()` both resolve. If the scope method returns `void` or has no return type, the synthesized method defaults to `\Illuminate\Database\Eloquent\Builder<static>`. Custom return types and extra parameters beyond `$query` are preserved.
1415
- **Union completion sorting.** When a variable has a union type (e.g. `Dog|Cat` from a match or ternary), members shared by all types in the union (the intersection) now sort above members that only exist on a subset of types. Branch-only members display their originating class name as a label detail suffix, giving an at-a-glance visual hint in the completion popup. No completions are removed. Single-type completions are unaffected.
1516
- **Context-aware class name filtering.** Class name completions are now filtered by syntactic context. `extends` in a class declaration only offers non-final classes, `extends` in an interface only offers interfaces, `implements` offers interfaces only, `use` inside a class body offers traits only, and `instanceof` excludes traits. Constants and functions are also suppressed in these positions. Multi-line declarations and comma-separated lists are handled. Built-in (stub) classes are filtered correctly even before they are fully parsed, using a lightweight source scanner that detects the declaration keyword from the raw PHP source already in memory. Classmap entries whose source is not yet loaded pass through the filter since their kind is unknown.
1617
- **First-class callable syntax.** PHP 8.1's `strlen(...)`, `$obj->method(...)`, and `ClassName::method(...)` syntax now resolves correctly. The variable holding the callable is typed as `Closure` (for `$fn->bindTo()` etc.), and invoking it with `$fn()` resolves to the underlying function or method's return type. Works with function references, instance methods, static methods, `$this->method(...)`, `self::method(...)`, chained calls on the result, and cross-file resolution.

docs/todo-laravel.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -329,14 +329,6 @@ end-to-end.
329329

330330
## Implementation sequence
331331

332-
### Step 4: LaravelModelProvider — scopes
333-
334-
- Scan methods for `scope*` prefix.
335-
- Synthesize virtual methods (name transformation, param stripping).
336-
- Mark as both static and instance.
337-
- Tests: verify `User::active()` and `User::query()->active()` resolve,
338-
verify parameter list excludes `$query`.
339-
340332
### Step 5: LaravelModelProvider — Builder-as-static
341333

342334
- Load Eloquent Builder, extract public instance methods.

example.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -813,10 +813,14 @@ public function switchInsideCase(string $driver): void
813813
}
814814

815815

816-
// ── Laravel Eloquent Relationship Properties ────────────────────────────────
816+
// ── Laravel Eloquent Virtual Members ────────────────────────────────────────
817817
// Methods returning Eloquent relationship types (HasMany, HasOne, BelongsTo, etc.)
818818
// automatically produce virtual properties. Accessing $author->posts resolves to a
819819
// Collection<BlogPost>, while $author->profile resolves directly to AuthorProfile.
820+
//
821+
// Methods starting with "scope" (e.g. scopeActive) produce virtual methods with
822+
// the prefix stripped and first letter lowercased (e.g. active). The $query
823+
// parameter is removed. Scopes are available as both static and instance methods.
820824

821825
class BlogAuthor extends \Illuminate\Database\Eloquent\Model
822826
{
@@ -838,13 +842,33 @@ public function tags(): mixed
838842
return $this->belongsToMany(BlogTag::class);
839843
}
840844

845+
public function scopeActive(\Illuminate\Database\Eloquent\Builder $query): void
846+
{
847+
$query->where('active', true);
848+
}
849+
850+
public function scopeOfGenre(\Illuminate\Database\Eloquent\Builder $query, string $genre): void
851+
{
852+
$query->where('genre', $genre);
853+
}
854+
841855
public function demo(): void
842856
{
843857
$author = new BlogAuthor();
858+
859+
// Relationship virtual properties
844860
$author->posts; // virtual property → Collection<BlogPost>
845861
$author->profile; // virtual property → AuthorProfile
846862
$author->profile->getBio(); // chains to AuthorProfile methods
847863
$author->tags; // virtual property → Collection<BlogTag>
864+
865+
// Scope methods — instance access
866+
$author->active(); // virtual method from scopeActive
867+
$author->ofGenre('fiction'); // virtual method from scopeOfGenre ($query stripped)
868+
869+
// Scope methods — static access
870+
BlogAuthor::active(); // also available as static
871+
BlogAuthor::ofGenre('fiction'); // $genre parameter preserved, $query stripped
848872
}
849873
}
850874

0 commit comments

Comments
 (0)