Skip to content

Commit 12606d9

Browse files
committed
Add support for Eloquent $appends as virtual properties
1 parent 01af2ac commit 12606d9

12 files changed

Lines changed: 177 additions & 61 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4040
- **Remove always-true `assert()` code action.** When PHPStan reports `function.alreadyNarrowedType` for a call to `assert()` that will always evaluate to true, a quickfix offers to delete the no-op statement. Only `assert()` calls are matched — other functions sharing the same identifier (e.g. `is_string()` inside conditions) are excluded because removal would change control flow. The diagnostic is eagerly cleared once `assert(` no longer appears on the line.
4141
- **Fix void return mismatch code actions.** When PHPStan reports `return.void` (a void function returns an expression), a quickfix strips the expression to produce a bare `return;`. When PHPStan reports `return.empty` (a non-void function has a bare `return;`), a quickfix changes the native return type to `void` and removes any `@return` docblock tag. The two actions chain naturally: fixing `return.void` may trigger `return.empty`, which then fixes the signature.
4242
- **Remove unreachable statement code action.** When PHPStan reports `deadCode.unreachable`, a quickfix deletes the dead statement. The statement-removal helper is shared infrastructure that a future native dead-code diagnostic (D6) can reuse.
43+
- **Eloquent `$appends` array.** Entries in a model's `$appends` property now produce virtual properties, matching the existing treatment of `$fillable`, `$guarded`, `$hidden`, and `$visible`.
4344

4445
### Changed
4546

docs/todo.md

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,22 @@ within the same impact tier.
2121

2222
## Sprint 4 — Refactoring toolkit & type inference
2323

24-
| # | Item | Impact | Effort |
25-
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------ |
26-
| L11 | [`$appends` array](todo/laravel.md#l11-appends-array) | Low | Low |
27-
| L13 | [`where{PropertyName}()` dynamic methods on Builder](todo/laravel.md#l13-wherepropertyname-dynamic-methods-on-builder) | High | Medium |
28-
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
29-
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
30-
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
31-
| | **Release 0.7.0** | | |
24+
| # | Item | Impact | Effort |
25+
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
26+
| L13 | [`where{PropertyName}()` dynamic methods on Builder](todo/laravel.md#l13-wherepropertyname-dynamic-methods-on-builder) | High | Medium |
27+
| H17 | [`missingType.iterableValue` — add `@return` with inferred element type](todo/phpstan-actions.md#h17-missingtype-iterablevalue-return-type--add-return-with-iterable-type) | Medium | High |
28+
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
29+
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
30+
| | **Release 0.7.0** | | |
3231

3332
## Sprint 5 — Polish for office adoption
3433

35-
| # | Item | Impact | Effort |
36-
| --- | ---------------------------------------------------------------------------------------------------- | ------ | ------ |
37-
| | Clear [refactoring gate](todo/refactor.md) |||
38-
| D8 | [Undefined variable diagnostic](todo/diagnostics.md#d8-undefined-variable-diagnostic) | High | Medium |
39-
| D11 | [Invalid class-like kind in context](todo/diagnostics.md#d11-invalid-class-like-kind-in-context) | Medium | Low |
40-
| | **Release 0.8.0** | | |
34+
| # | Item | Impact | Effort |
35+
| --- | ------------------------------------------------------------------------------------------------ | ------ | ------ |
36+
| | Clear [refactoring gate](todo/refactor.md) |||
37+
| D8 | [Undefined variable diagnostic](todo/diagnostics.md#d8-undefined-variable-diagnostic) | High | Medium |
38+
| D11 | [Invalid class-like kind in context](todo/diagnostics.md#d11-invalid-class-like-kind-in-context) | Medium | Low |
39+
| | **Release 0.8.0** | | |
4140

4241
> **Note:** F1 (Workspace symbol search), F2 (Document symbols), A8
4342
> (Implement interface methods), A9 (Auto import), D1 (Unknown class
@@ -94,7 +93,7 @@ unlikely to move the needle for most users.
9493
| | **[Type Inference](todo/type-inference.md)** | | |
9594
| T19 | [Structured type representation](todo/type-inference.md#t19-structured-type-representation) (replace string-based types with `PhpType` enum) | High | Very High |
9695
| T20 | [Type narrowing reconciliation engine](todo/type-inference.md#t20-type-narrowing-reconciliation-engine) (sure/sureNot tracking, AND/OR algebra) | Medium-High | High |
97-
| T21 | [Bidirectional template inference](todo/type-inference.md#t21-bidirectional-template-inference-upperlower-bounds) (upper/lower bounds, variance) | Medium | Medium-High |
96+
| T21 | [Bidirectional template inference](todo/type-inference.md#t21-bidirectional-template-inference-upperlower-bounds) (upper/lower bounds, variance) | Medium | Medium-High |
9897
| T6 | `Closure::bind()` / `Closure::fromCallable()` return type preservation | Low-Medium | Low-Medium |
9998
| T12 | [Intersection types flattened to unions by `type_strings_joined`](todo/type-inference.md#t12-intersection-types-flattened-to-unions-by-type_strings_joined) | Low-Medium | Low (after M4) |
10099
| T13 | [Closure variables lose callable signature detail](todo/type-inference.md#t13-closure-variables-lose-callable-signature-detail) | Low-Medium | Medium |
@@ -104,16 +103,15 @@ unlikely to move the needle for most users.
104103
| T10 | [Ternary expression as RHS of list destructuring](todo/type-inference.md#t10-ternary-expression-as-rhs-of-list-destructuring) | Low | Low-Medium |
105104
| T11 | [Nested list destructuring](todo/type-inference.md#t11-nested-list-destructuring) | Low | Low-Medium |
106105
| | **[Diagnostics](todo/diagnostics.md)** | | |
107-
108106
| D5 | Diagnostic suppression intelligence | Medium | Medium |
109-
| D12 | [Mago linter integration](todo/diagnostics.md#d12-mago-linter-integration-optional-diagnostics) (optional, ~159 AST-only lint rules) | Medium | Medium |
107+
| D12 | [Mago linter integration](todo/diagnostics.md#d12-mago-linter-integration-optional-diagnostics) (optional, ~159 AST-only lint rules) | Medium | Medium |
110108
| D6 | [Unreachable code diagnostic](todo/diagnostics.md#d6-unreachable-code-diagnostic) | Low-Medium | Low |
111109
| D10 | PHPMD diagnostic proxy | Low | Medium |
112110
| | **[Bug Fixes](todo/bugs.md)** | | |
113111
| | **[Code Actions](todo/actions.md)** | | |
114112
| A8 | [Update docblock to match signature](todo/actions.md#a8-update-docblock-to-match-signature) | Medium | Medium |
115113
| A16 | [Snippet placeholder for extracted method name](todo/actions.md#a16-snippet-placeholder-for-extracted-method-name) (lets the user type over the generated name immediately) | Medium | Low-Medium |
116-
| A25 | [`strpos``str_contains`](todo/actions.md#a25-strpos--str_contains-php-80) (PHP 8.0+) | Medium | Low |
114+
| A25 | [`strpos``str_contains`](todo/actions.md#a25-strpos--str_contains-php-80) (PHP 8.0+) | Medium | Low |
117115
| A28 | [Explicit nullable parameter type](todo/actions.md#a28-explicit-nullable-parameter-type-php-84-deprecation) (PHP 8.4 deprecation) | Medium | Low |
118116
| A10 | [Generate interface from class](todo/actions.md#a10-generate-interface-from-class) | Low-Medium | Medium |
119117
| A29 | [Simplify boolean return](todo/actions.md#a29-simplify-boolean-return) (`if (cond) return true; return false;``return cond;`) | Low-Medium | Medium |
@@ -133,7 +131,7 @@ unlikely to move the needle for most users.
133131
| | **[CLI Fix Rules](todo/fix-cli.md)** | | |
134132
| FX1 | [`deprecated` — replace deprecated symbol usage](todo/fix-cli.md#fx1-deprecated--replace-deprecated-symbol-usage) | Medium | Medium |
135133
| FX2 | [`unused_variable` — remove unused variables](todo/fix-cli.md#fx2-unused_variable--remove-unused-variables) | Medium | Medium |
136-
| FX3 | [`phpstan.return.unusedType` — remove unused type from return union](todo/fix-cli.md#fx3-phpstanreturnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
134+
| FX3 | [`phpstan.return.unusedType` — remove unused type from return union](todo/fix-cli.md#fx3-phpstanreturnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
137135
| FX4 | [`phpstan.missingType.iterableValue` — add `@return` with iterable type](todo/fix-cli.md#fx4-phpstanmissingtypeiterablevalue--add-return-with-iterable-type) | Medium | Medium |
138136
| FX5 | [`phpstan.property.unused` / `phpstan.method.unused` — remove unused member](todo/fix-cli.md#fx5-phpstanpropertyunused--phpstanmethodunused--remove-unused-member) | Low | Low |
139137
| FX6 | [`phpstan.generics.callSiteVarianceRedundant` — remove redundant variance](todo/fix-cli.md#fx6-phpstangenericscallsitevarianceredundant--remove-redundant-variance) | Low | Low |
@@ -155,7 +153,6 @@ unlikely to move the needle for most users.
155153
| L8 | `withSum`/`withAvg`/`withMin`/`withMax` aggregate properties | Low-Medium | Medium-High |
156154
| L9 | Higher-order collection proxies | Low-Medium | Medium-High |
157155
| L10 | `View::withX()` / `RedirectResponse::withX()` dynamic methods | Low | Low |
158-
| L11 | `$appends` array | Low | Low |
159156
| | **[External Stubs](todo/external-stubs.md)** | | |
160157
| E2 | Project-level stubs as type resolution source | Medium | Medium |
161158
| E3 | IDE-provided and `.phpantom.toml` stub paths | Low-Medium | Low |

docs/todo/laravel.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -468,17 +468,5 @@ hard-coding the two known classes. A simpler approach: add
468468
`@method` tags to bundled stubs for the most common dynamic `with*`
469469
methods, or document this as a known limitation.
470470

471-
#### L11. `$appends` array
472471

473-
**Impact: Low · Effort: Low**
474-
475-
The accessor method is the real source of truth; `$appends` only
476-
helps when the accessor is defined in an unloaded parent class.
477-
Similar to `$fillable`/`$hidden` extraction.
478-
479-
The `$appends` property lists accessor names that should always be
480-
included in `toArray()` / `toJson()`. These reference existing
481-
accessors, so in most cases the accessor method itself already produces
482-
the virtual property. Parsing `$appends` would only help when the
483-
accessor is defined in an unloaded parent class.
484472

example.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1616,7 +1616,7 @@ public function demo(): void
16161616

16171617

16181618
// ── Eloquent Virtual Properties ─────────────────────────────────────────────
1619-
// Alphabetical — every property a through v should appear in order.
1619+
// Alphabetical — every property a through w should appear in order.
16201620
// Trigger completion on `$bakery->` and scan the list.
16211621

16221622
class EloquentPropertyDemo
@@ -1653,6 +1653,7 @@ public function demo(): void
16531653
$bakery->unbaked(); // scope method → Builder
16541654
$bakery->vendor; // body-inferred morphTo → Model
16551655
$bakery->vendor_count; // relationship count → int
1656+
$bakery->warmth; // $appends (no cast/attr) → mixed
16561657
// MUST NOT appear: secret_ingredient (private $attributes field)
16571658
}
16581659
}
@@ -4675,6 +4676,8 @@ class Bakery extends \Illuminate\Database\Eloquent\Model
46754676

46764677
protected $visible = ['rye_blend'];
46774678

4679+
protected $appends = ['warmth'];
4680+
46784681
protected $casts = [
46794682
'apricot' => 'boolean',
46804683
'dough_temp' => 'float',

src/completion/variable/resolution.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,15 +1009,6 @@ fn resolve_variable_in_members<'b>(
10091009
vec![]
10101010
}
10111011

1012-
// Walk statements collecting variable assignment types.
1013-
//
1014-
// The `conditional` flag indicates whether we are inside a conditional
1015-
// block (if/else, try/catch, loop). When `conditional` is `false`,
1016-
// a new assignment **replaces** all previous candidates (the variable
1017-
// is being unconditionally reassigned). When `conditional` is `true`,
1018-
// a new assignment **adds** to the list (the variable *might* be this
1019-
// type).
1020-
10211012
/// Substitute method/function-level template parameter names with their
10221013
/// upper bounds from `@template T of Bound` annotations.
10231014
///

src/definition/member/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,9 @@ impl Backend {
348348

349349
// ── Eloquent array entry fallback ───────────────────────
350350
// Virtual properties from $casts, $attributes, $fillable,
351-
// $guarded, $hidden, and $visible don't have a method or property
352-
// declaration. Jump to the string literal entry inside the
353-
// array property instead.
351+
// $guarded, $hidden, $visible, and $appends don't have a
352+
// method or property declaration. Jump to the string literal
353+
// entry inside the array property instead.
354354
if extends_eloquent_model(lookup_class, &class_loader)
355355
&& let Some((class_uri, class_content)) =
356356
self.find_class_file_content(&declaring_fqn, uri, content)

src/definition/member/shape_keys.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,10 @@ impl Backend {
161161
/// Find a string literal entry inside an Eloquent array property.
162162
///
163163
/// Searches for `'member_name'` or `"member_name"` inside `$casts`,
164-
/// `$attributes`, `$fillable`, `$guarded`, `$hidden`, and `$visible`
165-
/// property declarations within the given class range. Returns the
166-
/// position of the string literal so go-to-definition can jump to it.
164+
/// `$attributes`, `$fillable`, `$guarded`, `$hidden`, `$visible`,
165+
/// and `$appends` property declarations within the given class range.
166+
/// Returns the position of the string literal so go-to-definition
167+
/// can jump to it.
167168
pub(in crate::definition) fn find_eloquent_array_entry(
168169
content: &str,
169170
member_name: &str,
@@ -178,6 +179,7 @@ impl Backend {
178179
"$guarded",
179180
"$hidden",
180181
"$visible",
182+
"$appends",
181183
];
182184

183185
// Track whether we're inside one of the target property arrays.

0 commit comments

Comments
 (0)