Skip to content

Commit d7d77db

Browse files
committed
#[Scope] attribute (Laravel 11+)
1 parent b3f541e commit d7d77db

14 files changed

Lines changed: 1153 additions & 79 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ Provider priority order (highest first):
321321

322322
Two providers are currently registered in `default_providers()`:
323323

324-
- **`LaravelModelProvider`** (`virtual_members/laravel.rs`): synthesizes virtual members for classes extending `Illuminate\Database\Eloquent\Model`. Produces relationship properties (methods returning `HasMany`, `HasOne`, `BelongsTo`, etc. generate a virtual property typed from the relationship's generic parameters), scope methods (`scopeActive` becomes `active()` as both static and instance), Builder-as-static forwarding (`User::where()->get()` resolves end-to-end), accessors (legacy `getXAttribute()` and modern `Attribute` casts), and cast properties (`$casts` array or `casts()` method entries are mapped to PHP types like `datetime` to `\Carbon\Carbon`, `boolean` to `bool`, custom cast classes to their `get()` return type). Highest priority among virtual member providers. Scope methods are also injected onto `Builder<Model>` instances via a post-generic-substitution hook in `type_hint_to_classes_depth` (see "Scope Methods on Builder Instances" below).
324+
- **`LaravelModelProvider`** (`virtual_members/laravel.rs`): synthesizes virtual members for classes extending `Illuminate\Database\Eloquent\Model`. Produces relationship properties (methods returning `HasMany`, `HasOne`, `BelongsTo`, etc. generate a virtual property typed from the relationship's generic parameters), scope methods (both the `scopeActive` naming convention and the `#[Scope]` attribute from Laravel 11+ are supported; either style becomes `active()` as both static and instance), Builder-as-static forwarding (`User::where()->get()` resolves end-to-end), accessors (legacy `getXAttribute()` and modern `Attribute` casts), and cast properties (`$casts` array or `casts()` method entries are mapped to PHP types like `datetime` to `\Carbon\Carbon`, `boolean` to `bool`, custom cast classes to their `get()` return type). Highest priority among virtual member providers. Scope methods are also injected onto `Builder<Model>` instances via a post-generic-substitution hook in `type_hint_to_classes_depth` (see "Scope Methods on Builder Instances" below).
325325
- **`PHPDocProvider`** (`virtual_members/phpdoc.rs`): parses `@method`, `@property`, `@property-read`, `@property-write`, and `@mixin` tags from the class-level docblock stored in `ClassInfo.class_docblock`. Explicit `@method` / `@property` tags are not parsed eagerly during AST extraction; instead, the raw docblock string is preserved and parsed lazily when `provide` is called. For `@mixin` tags, the provider loads the referenced classes and merges their public members. Within the provider, explicit tags take precedence over mixin members. Recurses into mixin-of-mixin chains up to `MAX_MIXIN_DEPTH`.
326326

327327
### Precedence Rules
@@ -334,15 +334,15 @@ Two providers are currently registered in `default_providers()`:
334334

335335
### Scope Methods on Builder Instances
336336

337-
Scope methods are synthesized on the Model class by `LaravelModelProvider`, but they also need to be available on `Builder<Model>` instances (e.g. `Brand::where('id', $id)->isActive()`). This cannot be handled by a virtual member provider alone because providers run before generic argument substitution, so the provider would not know the concrete model type.
337+
Scope methods (both `scopeX` convention and `#[Scope]` attribute) are synthesized on the Model class by `LaravelModelProvider`, but they also need to be available on `Builder<Model>` instances (e.g. `Brand::where('id', $id)->isActive()`). This cannot be handled by a virtual member provider alone because providers run before generic argument substitution, so the provider would not know the concrete model type.
338338

339339
Instead, scope injection happens in three places:
340340

341341
1. **Post-generic-substitution hook** (`completion/resolver.rs`, inside `type_hint_to_classes_depth`): after `resolve_class_fully` + `apply_generic_args` produces a `Builder<User>` class, the resolver detects that the result is an Eloquent Builder with a concrete model generic argument. It calls `build_scope_methods_for_builder(model_name, class_loader)` which loads the model, fully resolves it, extracts scope methods, and returns them as instance methods with `static` in return types substituted to the concrete model name. These are merged onto the Builder's method list, giving `$q->active()` after `$q = User::where(...)`.
342342

343-
2. **Scope body Builder enrichment** (`completion/variable_resolution.rs`, `enrich_builder_type_in_scope`): inside a scope method body, the `$query` parameter is typically typed as `Builder` without generic arguments. The enrichment function detects when the enclosing method is a scope (name starts with `scope`, length > 5) on a class that extends Eloquent Model, and the parameter type is `Builder` without generics. It rewrites the type to `Builder<EnclosingModel>`, which then flows through the generic-args path and triggers the post-substitution hook above. This makes `$query->otherScope()` resolve inside scope bodies.
343+
2. **Scope body Builder enrichment** (`completion/variable_resolution.rs`, `enrich_builder_type_in_scope`): inside a scope method body, the `$query` parameter is typically typed as `Builder` without generic arguments. The enrichment function detects when the enclosing method is a scope (either the `scopeX` naming convention or the `#[Scope]` attribute) on a class that extends Eloquent Model, and the parameter type is `Builder` without generics. It rewrites the type to `Builder<EnclosingModel>`, which then flows through the generic-args path and triggers the post-substitution hook above. This makes `$query->otherScope()` resolve inside scope bodies.
344344

345-
3. **Go-to-definition fallback** (`definition/member.rs`, `find_scope_on_builder_model`): when go-to-definition resolves a member on a Builder class and the normal lookup chain (own members, traits, parents, mixins, builder forwarding) fails, this fallback checks whether the member is a scope method injected from the model. It confirms the resolved candidate (with injected scopes) has the method, extracts the model name from the scope method's return type generic argument, loads the model, and finds the `scopeXxx` declaration through the model's inheritance chain. This bridges the gap between completion (which works on the merged ClassInfo) and GTD (which traces back to the declaring source file).
345+
3. **Go-to-definition fallback** (`definition/member.rs`, `find_scope_on_builder_model`): when go-to-definition resolves a member on a Builder class and the normal lookup chain (own members, traits, parents, mixins, builder forwarding) fails, this fallback checks whether the member is a scope method injected from the model. It confirms the resolved candidate (with injected scopes) has the method, extracts the model name from the scope method's return type generic argument, loads the model, and finds the declaration through the model's inheritance chain. For `scopeX`-style scopes it looks for `scopeXxx`; for `#[Scope]`-attributed methods it falls back to the original method name. This bridges the gap between completion (which works on the merged ClassInfo) and GTD (which traces back to the declaring source file).
346346

347347
### Interface Inheritance in Traits/Used Interfaces
348348

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- **Eloquent `$attributes` default properties.** Entries in a model's `$attributes` property array now produce typed virtual properties as a fallback. Types are inferred from the literal default values: strings, booleans, integers, floats, `null`, and arrays. Columns that already have a `$casts` entry are skipped, so casts always take priority. Works with `$this->`, instance variables, cross-file PSR-4 resolution, double-quoted keys, and negative numeric literals.
2323
- **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.
2424
- **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.
25-
- **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.
25+
- **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. The `#[Scope]` attribute (Laravel 11+) is also supported as an alternative to the `scopeX` naming convention: methods decorated with `#[\Illuminate\Database\Eloquent\Attributes\Scope]` use their own name directly as the public-facing scope name. Both styles work on model classes, on Builder instances, inside scope method bodies, in traits, and with go-to-definition.
2626
- **Scope methods on Builder instances.** Eloquent scope methods now resolve on Builder instances, not just on the Model class itself. `Brand::where('id', $id)->isActive()` and `$query->active()->ofGenre('fiction')->get()` now offer scope completions and go-to-definition end-to-end. When the Builder's `TModel` template parameter is a concrete model (e.g. `Builder<User>`), the model's scope methods are injected as instance methods on the Builder. Go-to-definition on a scope called through a Builder (e.g. `$q->isActive()`) jumps to the `scopeIsActive` method on the model class. Scope return types are mapped so that chaining continues on the Builder. Inside scope method bodies, `$query->otherScope()` also resolves for both completion and go-to-definition: the `Builder` parameter type is automatically enriched with the enclosing model's generic argument. Works with scopes defined in traits, inherited from parent models, across files via PSR-4, and through multi-step builder chains.
2727
- **Laravel Builder-as-static forwarding.** Static method calls on Eloquent model classes are forwarded to the Eloquent Builder. `User::where('email', $e)->orderBy('name')->get()` now resolves end-to-end. The provider loads `\Illuminate\Database\Eloquent\Builder`, fully resolves it (including `@mixin` members from `Query\Builder`), and presents its public instance methods as static virtual methods on the model. Return types are mapped so that `static`/`$this`/`self` on Builder resolve to `Builder<ConcreteModel>` (the chain continues on the builder), and template parameters like `TModel` resolve to the concrete model class. Methods like `get()` return `Collection<User>`, `first()` returns `User|null`, and chain methods like `where()` and `orderBy()` return `Builder<User>` for continued chaining. Works with indirect model subclasses, coexists with scope methods and relationship properties, and includes Query Builder methods forwarded via `@mixin`.
2828
- **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.

docs/todo-laravel.md

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -74,37 +74,7 @@ benefit) and an **Effort** estimate (implementation complexity):
7474

7575
---
7676

77-
#### 1. `#[Scope]` attribute (Laravel 11+)
78-
79-
| | |
80-
|---|---|
81-
| **Impact** | ★★★ — Adoption is growing as the modern alternative to `scopeX`. Already the documented approach in Laravel 11+. |
82-
| **Effort** | ★★ — Extract `#[Scope]` attributes in the parser; treat them the same as `scopeX` methods in the provider. |
83-
84-
Laravel 11 introduced the `#[Scope]` attribute as an alternative to
85-
the `scopeX` naming convention. Methods decorated with `#[Scope]`
86-
are available on the builder without needing the `scope` prefix:
87-
88-
```php
89-
class User extends Model {
90-
#[Scope]
91-
protected function active(Builder $query): void { ... }
92-
}
93-
94-
User::active()->get(); // works at runtime via #[Scope]
95-
```
96-
97-
Larastan checks for this attribute in `BuilderHelper::searchOnEloquentBuilder()`.
98-
We currently only detect scopes via the `scopeX` naming convention in
99-
`is_scope_method`.
100-
101-
**Where to change:** In the parser, extract `#[Scope]` attributes
102-
from method declarations. In `LaravelModelProvider::provide`, treat
103-
methods with the `#[Scope]` attribute the same as `scopeX` methods
104-
(strip the first `$query` parameter, expose as both static and
105-
instance virtual methods).
106-
107-
#### 2. `$dates` array (deprecated)
77+
#### 1. `$dates` array (deprecated)
10878

10979
| | |
11080
|---|---|
@@ -123,7 +93,7 @@ Merge these into `casts_definitions` at a lower priority than explicit
12393
`$casts` entries, or add a separate field on `ClassInfo` and handle
12494
priority in the provider.
12595

126-
#### 3. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
96+
#### 2. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
12797

12898
| | |
12999
|---|---|
@@ -161,7 +131,7 @@ declares a custom builder via `@use HasBuilder<X>` in `use_generics`
161131
or a `newEloquentBuilder()` method with a non-default return type.
162132
If found, load and resolve that builder class instead.
163133

164-
#### 4. `abort_if`/`abort_unless` type narrowing
134+
#### 3. `abort_if`/`abort_unless` type narrowing
165135

166136
| | |
167137
|---|---|
@@ -205,7 +175,7 @@ to subsequent code:
205175
This is similar to the existing guard clause narrowing but triggered
206176
by specific function names rather than `if` + early return.
207177

208-
#### 5. `collect()` and other helper functions lose generic type info
178+
#### 4. `collect()` and other helper functions lose generic type info
209179

210180
| | |
211181
|---|---|
@@ -253,7 +223,7 @@ before passing it to `type_hint_to_classes`. See the general TODO
253223
item (§ PHP Language Feature Gaps, "Function-level `@template`
254224
generic resolution") for the full implementation plan.
255225

256-
#### 6. Factory `has*`/`for*` relationship methods
226+
#### 5. Factory `has*`/`for*` relationship methods
257227

258228
| | |
259229
|---|---|
@@ -291,7 +261,7 @@ The `has*` variant should accept optional `int $count` and
291261
`array|callable $state` parameters; `for*` should accept
292262
`array|callable $state`.
293263

294-
#### 7. `$pivot` property on BelongsToMany related models
264+
#### 6. `$pivot` property on BelongsToMany related models
295265

296266
| | |
297267
|---|---|
@@ -337,7 +307,7 @@ the `BelongsToMany` relationship stubs. If the user's stub set
337307
includes these annotations, it already works through our PHPDoc
338308
provider.
339309

340-
#### 8. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
310+
#### 7. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
341311

342312
| | |
343313
|---|---|
@@ -353,7 +323,7 @@ aggregate function (`withSum`/`withAvg` → `float`,
353323

354324
The `@property` workaround applies here too.
355325

356-
#### 9. Higher-order collection proxies
326+
#### 8. Higher-order collection proxies
357327

358328
| | |
359329
|---|---|
@@ -376,7 +346,7 @@ and `HigherOrderCollectionProxyExtension`, which resolve the proxy's
376346
template types and delegate property/method lookups to the collection's
377347
value type.
378348

379-
#### 10. `SoftDeletes` trait methods on Builder
349+
#### 9. `SoftDeletes` trait methods on Builder
380350

381351
| | |
382352
|---|---|
@@ -404,7 +374,7 @@ type — e.g. `Builder<static>` instead of `Builder<User>`. This is
404374
a minor gap but not worth a dedicated fix until custom builder
405375
support (gap §7) is implemented.
406376

407-
#### 11. `View::withX()` and `RedirectResponse::withX()` dynamic methods
377+
#### 10. `View::withX()` and `RedirectResponse::withX()` dynamic methods
408378

409379
| | |
410380
|---|---|
@@ -436,7 +406,7 @@ hard-coding the two known classes. A simpler approach: add
436406
`@method` tags to bundled stubs for the most common dynamic `with*`
437407
methods, or document this as a known limitation.
438408

439-
#### 12. `$appends` array
409+
#### 11. `$appends` array
440410

441411
| | |
442412
|---|---|

example.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,7 @@ public function demo(): void
11621162
$bakery->dough_temp; // $casts 'float' → float
11631163
$bakery->egg_count; // $attributes default → int
11641164
$bakery->flour; // $fillable (no cast/attr) → mixed
1165+
$bakery->fresh(); // #[Scope] method → Builder
11651166
$bakery->gluten_free; // $attributes default → bool
11661167
$bakery->headBaker; // relationship HasOne → Baker
11671168
$bakery->head_baker_count; // relationship count → int
@@ -1207,8 +1208,9 @@ public function demo(): void
12071208
BlogAuthor::active();
12081209
BlogAuthor::ofGenre('fiction');
12091210

1210-
// Scopes on Builder instances
1211+
// Scopes on Builder instances (convention and #[Scope] attribute)
12111212
BlogAuthor::where('active', 1)->active()->ofGenre('sci-fi')->get();
1213+
Bakery::where('open', true)->fresh()->get();
12121214
$query = BlogAuthor::where('genre', 'fiction');
12131215
$query->active();
12141216
$query->orderBy('name')->get();
@@ -2781,6 +2783,12 @@ public function scopeUnbaked(\Illuminate\Database\Eloquent\Builder $query): void
27812783
$query->where('baked', false);
27822784
}
27832785

2786+
#[\Illuminate\Database\Eloquent\Attributes\Scope]
2787+
protected function fresh(\Illuminate\Database\Eloquent\Builder $query): void
2788+
{
2789+
$query->where('fresh', true);
2790+
}
2791+
27842792
public function getLoafNameAttribute(): string { return ''; }
27852793

27862794
/** @return \Illuminate\Database\Eloquent\Casts\Attribute<string> */
@@ -2992,6 +3000,7 @@ class HasManyThrough {}
29923000

29933001
namespace Illuminate\Database\Eloquent\Attributes {
29943002
class CollectedBy {}
3003+
class Scope {}
29953004
}
29963005

29973006
namespace Illuminate\Database\Eloquent\Casts {

0 commit comments

Comments
 (0)