Skip to content

Commit b3f541e

Browse files
committed
*_count relationship count property
1 parent 877fd64 commit b3f541e

7 files changed

Lines changed: 1061 additions & 179 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343
- **Generator yield type inference inside generator bodies.** When a function or method declares `@return Generator<TKey, TValue, TSend, TReturn>`, variables inside the generator body are now typed from the annotation. Variables that appear as the operand of a `yield` statement are inferred as TValue (the 2nd generic parameter), and variables assigned from a yield expression (`$var = yield $expr`) are inferred as TSend (the 3rd generic parameter). Works in class methods, top-level functions, key-value yields (`yield $k => $v`), cross-file resolution via PSR-4, and all four Generator type parameter arities. Explicit assignments still take priority over yield inference.
4444
- **Custom Eloquent collections.** Models that declare a custom collection via `#[CollectedBy(CustomCollection::class)]` or `/** @use HasCollection<CustomCollection> */ use HasCollection;` now resolve to the custom collection class instead of the standard `Illuminate\Database\Eloquent\Collection`. Custom collection methods appear in completions after `Model::where(...)->get()->`, after `Model::get()->`, and on relationship properties that return a collection of the model. The attribute takes priority over the trait when both are present. Works with short names, fully-qualified names, cross-file PSR-4 resolution, and same-file definitions. Models without a custom collection continue to use the standard Collection.
4545
- **Eloquent `$visible` array extraction.** The `$visible` property on Eloquent models is now recognised as a source of column names, matching the existing handling of `$fillable`, `$guarded`, and `$hidden`. Columns listed in `$visible` that are not already covered by `$casts` or `$attributes` produce `mixed`-typed virtual properties.
46+
- **Relationship count properties.** Eloquent models now get `*_count` virtual properties for each relationship method. A `posts()` method returning `HasMany<Post>` produces a `$posts_count` property typed as `int`, matching the runtime behaviour of `withCount`/`loadCount`. CamelCase method names are converted to snake_case (`headBaker()` produces `$head_baker_count`). Works with all relationship types, body-inferred relationships, `$this->` access, and cross-file PSR-4 resolution. Existing properties from `$casts`, `$attributes`, or `@property` tags take priority.
4647

4748
### Changed
4849

docs/todo-laravel.md

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ today and what is still missing.
5757
| Legacy accessors (`getXAttribute()`) | Method's return type | |
5858
| Modern accessors (returns `Attribute`) | First generic arg of `Attribute<TGet>`, or `mixed` when unparameterised | |
5959
| Relationship methods | Generic params or body inference | |
60+
| Relationship `*_count` properties | `int` | `{snake_name}_count` for each relationship method |
6061

6162
### Gaps (ranked by impact ÷ effort)
6263

@@ -73,33 +74,7 @@ benefit) and an **Effort** estimate (implementation complexity):
7374

7475
---
7576

76-
#### 1. `*_count` relationship count properties
77-
78-
| | |
79-
|---|---|
80-
| **Impact** | ★★★★ — `withCount`/`loadCount` is one of the most common Eloquent patterns; `$model->posts_count` appears in nearly every non-trivial app. |
81-
| **Effort** | ★★ — After synthesizing relationship properties, iterate relationships again and push `{snake_name}_count` typed as `int`. |
82-
83-
Accessing `$user->posts_count` is a very common Laravel pattern
84-
(`withCount`, `loadCount`, or eager-loaded counts). We don't
85-
synthesize these today.
86-
87-
```php
88-
$user->posts_count; // int, but we know nothing about it
89-
```
90-
91-
Larastan handles this **declaratively** — no call-site tracking
92-
required. When a property name ends with `_count`, it strips the
93-
suffix, checks whether the remainder (converted to camelCase) is a
94-
relationship method, and if so types the property as `int`.
95-
96-
**Where to change:** In `LaravelModelProvider::provide`, after
97-
synthesizing relationship properties, iterate the relationship methods
98-
again and push a `{snake_name}_count` property typed as `int` for
99-
each one. The property should have lower priority than explicit
100-
`@property` tags.
101-
102-
#### 2. `#[Scope]` attribute (Laravel 11+)
77+
#### 1. `#[Scope]` attribute (Laravel 11+)
10378

10479
| | |
10580
|---|---|
@@ -129,7 +104,7 @@ methods with the `#[Scope]` attribute the same as `scopeX` methods
129104
(strip the first `$query` parameter, expose as both static and
130105
instance virtual methods).
131106

132-
#### 3. `$dates` array (deprecated)
107+
#### 2. `$dates` array (deprecated)
133108

134109
| | |
135110
|---|---|
@@ -148,7 +123,7 @@ Merge these into `casts_definitions` at a lower priority than explicit
148123
`$casts` entries, or add a separate field on `ClassInfo` and handle
149124
priority in the provider.
150125

151-
#### 4. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
126+
#### 3. Custom Eloquent builders (`HasBuilder` / `#[UseEloquentBuilder]`)
152127

153128
| | |
154129
|---|---|
@@ -186,7 +161,7 @@ declares a custom builder via `@use HasBuilder<X>` in `use_generics`
186161
or a `newEloquentBuilder()` method with a non-default return type.
187162
If found, load and resolve that builder class instead.
188163

189-
#### 5. `abort_if`/`abort_unless` type narrowing
164+
#### 4. `abort_if`/`abort_unless` type narrowing
190165

191166
| | |
192167
|---|---|
@@ -230,7 +205,7 @@ to subsequent code:
230205
This is similar to the existing guard clause narrowing but triggered
231206
by specific function names rather than `if` + early return.
232207

233-
#### 6. `collect()` and other helper functions lose generic type info
208+
#### 5. `collect()` and other helper functions lose generic type info
234209

235210
| | |
236211
|---|---|
@@ -278,7 +253,7 @@ before passing it to `type_hint_to_classes`. See the general TODO
278253
item (§ PHP Language Feature Gaps, "Function-level `@template`
279254
generic resolution") for the full implementation plan.
280255

281-
#### 7. Factory `has*`/`for*` relationship methods
256+
#### 6. Factory `has*`/`for*` relationship methods
282257

283258
| | |
284259
|---|---|
@@ -316,7 +291,7 @@ The `has*` variant should accept optional `int $count` and
316291
`array|callable $state` parameters; `for*` should accept
317292
`array|callable $state`.
318293

319-
#### 8. `$pivot` property on BelongsToMany related models
294+
#### 7. `$pivot` property on BelongsToMany related models
320295

321296
| | |
322297
|---|---|
@@ -362,7 +337,7 @@ the `BelongsToMany` relationship stubs. If the user's stub set
362337
includes these annotations, it already works through our PHPDoc
363338
provider.
364339

365-
#### 9. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
340+
#### 8. `withSum()` / `withAvg()` / `withMin()` / `withMax()` aggregate properties
366341

367342
| | |
368343
|---|---|
@@ -378,7 +353,7 @@ aggregate function (`withSum`/`withAvg` → `float`,
378353

379354
The `@property` workaround applies here too.
380355

381-
#### 10. Higher-order collection proxies
356+
#### 9. Higher-order collection proxies
382357

383358
| | |
384359
|---|---|
@@ -401,7 +376,7 @@ and `HigherOrderCollectionProxyExtension`, which resolve the proxy's
401376
template types and delegate property/method lookups to the collection's
402377
value type.
403378

404-
#### 11. `SoftDeletes` trait methods on Builder
379+
#### 10. `SoftDeletes` trait methods on Builder
405380

406381
| | |
407382
|---|---|
@@ -429,7 +404,7 @@ type — e.g. `Builder<static>` instead of `Builder<User>`. This is
429404
a minor gap but not worth a dedicated fix until custom builder
430405
support (gap §7) is implemented.
431406

432-
#### 12. `View::withX()` and `RedirectResponse::withX()` dynamic methods
407+
#### 11. `View::withX()` and `RedirectResponse::withX()` dynamic methods
433408

434409
| | |
435410
|---|---|
@@ -461,7 +436,7 @@ hard-coding the two known classes. A simpler approach: add
461436
`@method` tags to bundled stubs for the most common dynamic `with*`
462437
methods, or document this as a known limitation.
463438

464-
#### 13. `$appends` array
439+
#### 12. `$appends` array
465440

466441
| | |
467442
|---|---|

example.php

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,28 +1155,32 @@ public function demo(): void
11551155
{
11561156
$bakery = new Bakery();
11571157

1158-
$bakery->apricot; // $casts 'boolean' → bool
1159-
$bakery->baguettes; // relationship HasMany → Collection<Loaf>
1160-
$bakery->croissant; // $attributes default → string
1161-
$bakery->dough_temp; // $casts 'float' → float
1162-
$bakery->egg_count; // $attributes default → int
1163-
$bakery->flour; // $fillable (no cast/attr) → mixed
1164-
$bakery->gluten_free; // $attributes default → bool
1165-
$bakery->headBaker; // relationship HasOne → Baker
1166-
$bakery->icing; // $casts custom class → ?Frosting
1167-
$bakery->jam_flavor; // $casts enum → JamFlavor
1168-
$bakery->kitchen_id; // $guarded (no cast/attr) → mixed
1169-
$bakery->loaf_name; // legacy accessor → string
1170-
$bakery->masterRecipe; // relationship BelongsToMany → Collection<BakeryRecipe>
1171-
$bakery->notes; // $casts 'array' → array
1172-
$bakery->oven_code; // $hidden (no cast/attr) → mixed
1173-
$bakery->proved_at; // $casts 'datetime' → \Carbon\Carbon
1174-
$bakery->quality; // casts() method 'float' → float
1175-
$bakery->rye_blend; // $visible (no cast/attr) → mixed
1176-
$bakery->sprinkle; // modern accessor Attribute → string
1177-
$bakery->topping('choc'); // scope method → Builder
1178-
$bakery->unbaked(); // scope method → Builder
1179-
$bakery->vendor; // body-inferred morphTo → Model
1158+
$bakery->apricot; // $casts 'boolean' → bool
1159+
$bakery->baguettes; // relationship HasMany → Collection<Loaf>
1160+
$bakery->baguettes_count; // relationship count → int
1161+
$bakery->croissant; // $attributes default → string
1162+
$bakery->dough_temp; // $casts 'float' → float
1163+
$bakery->egg_count; // $attributes default → int
1164+
$bakery->flour; // $fillable (no cast/attr) → mixed
1165+
$bakery->gluten_free; // $attributes default → bool
1166+
$bakery->headBaker; // relationship HasOne → Baker
1167+
$bakery->head_baker_count; // relationship count → int
1168+
$bakery->icing; // $casts custom class → ?Frosting
1169+
$bakery->jam_flavor; // $casts enum → JamFlavor
1170+
$bakery->kitchen_id; // $guarded (no cast/attr) → mixed
1171+
$bakery->loaf_name; // legacy accessor → string
1172+
$bakery->masterRecipe; // relationship BelongsToMany → Collection<BakeryRecipe>
1173+
$bakery->master_recipe_count; // relationship count → int
1174+
$bakery->notes; // $casts 'array' → array
1175+
$bakery->oven_code; // $hidden (no cast/attr) → mixed
1176+
$bakery->proved_at; // $casts 'datetime' → \Carbon\Carbon
1177+
$bakery->quality; // casts() method 'float' → float
1178+
$bakery->rye_blend; // $visible (no cast/attr) → mixed
1179+
$bakery->sprinkle; // modern accessor Attribute → string
1180+
$bakery->topping('choc'); // scope method → Builder
1181+
$bakery->unbaked(); // scope method → Builder
1182+
$bakery->vendor; // body-inferred morphTo → Model
1183+
$bakery->vendor_count; // relationship count → int
11801184
// MUST NOT appear: secret_ingredient (private $attributes field)
11811185
}
11821186
}

0 commit comments

Comments
 (0)