Skip to content

Commit 289af94

Browse files
committed
Add bug backlog for analyzer and Eloquent issues, docblock property
where-methods
1 parent 732c34e commit 289af94

7 files changed

Lines changed: 386 additions & 12 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7979
- **Single generic argument on collections bound to the wrong template parameter.** Writing `Collection<SectionTranslation>` on a class with `@template TKey of array-key` and `@template TValue` assigned the argument to `TKey` (by position), leaving `TValue` unsubstituted. When fewer generic arguments are provided than template parameters and the leading parameters have key-like bounds (`array-key`, `int`, `string`), the arguments are now right-aligned so they bind to the value parameters. Method-level template parameters whose bound parameter was not passed at the call site and defaults to `null` now resolve to `null` instead of remaining as raw template names. Together these fixes mean `Collection<SectionTranslation>::first()` correctly resolves to `SectionTranslation|null`.
8080
- **Nullable return types losing `|null` after template substitution.** When a method declared `@return TValue|null` and `TValue` was substituted with a concrete class through `@extends`, the `|null` component was silently dropped. Hover showed `AdminUser` instead of `AdminUser|null` for calls like `AdminUser::first()`. The docblock type extraction pipeline now preserves nullable unions so that `|null` survives through substitution, hover display, and variable type resolution.
8181
- **Loop-body assignments not visible inside the same loop iteration.** When a variable was initialized as `null` and reassigned later in a loop body, code earlier in the loop (reached on subsequent iterations) only saw `null`. The variable resolution walker now pre-scans the entire loop body for assignments before the positional walk, so the union of all assignments is available at every point inside the loop. Combined with `!== null` narrowing, variables like `$lastPaidEnd` correctly resolve to the assigned class type. Applies to `foreach`, `while`, `for`, and `do-while` loops.
82+
- **`@mixin` referencing a template parameter now resolves.** When a class declares `@template T` and `@mixin T`, the mixin name is substituted with the concrete type from the generic arguments. For example, a wrapper class `Subclient<EventsApi>` with `@mixin TWraps` now pulls in `EventsApi`'s methods instead of silently ignoring the mixin because no class named `TWraps` exists.
83+
- **Eloquent `where{Property}()` methods from docblock-only `@property` tags.** Models that declare columns only via `@property` annotations (without `$casts`, `$fillable`, etc.) now correctly get `where{PropertyName}()` methods on `Builder<Model>`.
8284

8385
## [0.6.0] - 2026-03-26
8486

docs/todo.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------ |
26+
| B3 | [Variable reassignment inside `try` block not tracked by analyzer](todo/bugs.md#b3-variable-reassignment-inside-try-block-not-tracked-by-analyzer) | Low | Low |
27+
| B4 | [Relationship property access and BelongsTo return type not resolved](todo/bugs.md#b4-relationship-property-access-and-belongsto-return-type-not-resolved-by-analyzer) | Medium | Medium |
28+
| B5 | [`$this->items` on custom Collection subclass not typed](todo/bugs.md#b5-thisitems-on-custom-collection-subclass-not-typed) | Low | Medium |
29+
| B6 | [Scope methods not found on Builder in analyzer chains](todo/bugs.md#b6-scope-methods-not-found-on-builder-in-analyzer-chains) | High | Medium |
30+
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |
31+
| B8 | [Variadic parameter element type lost in `foreach`](todo/bugs.md#b8-variadic-parameter-element-type-lost-in-foreach) | Low | Low |
32+
| B9 | [Eloquent relationship property lookup is case-sensitive](todo/bugs.md#b9-eloquent-relationship-property-lookup-is-case-sensitive) | Low | Low |
2633
| H10 | [`return.unusedType` — remove unused type from return union](todo/phpstan-actions.md#h10-returnunusedtype--remove-unused-type-from-return-union) | Medium | Medium |
2734
| H6 | `return.type` — update return type to match actual returns | Medium | Medium |
2835
| | **Release 0.7.0** | | |

docs/todo/bugs.md

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,236 @@
1-
# PHPantom — Bug Fixes
1+
# PHPantom — Bug Fixes
2+
3+
## B3: Variable reassignment inside `try` block not tracked by analyzer
4+
5+
When a variable is assigned one type before a `try` block and then
6+
reassigned to a different type inside the `try`, the analyzer uses the
7+
original type instead of the reassigned type for subsequent accesses
8+
within the same block.
9+
10+
Real-world example — `MollieGateway.php`:
11+
12+
```php
13+
$customer = $request->getCustomerOrFail(); // type: Luxplus Customer
14+
// ...
15+
try {
16+
$customer = $client->getOrCreateCustoemr($customer); // reassigned to MollieCustomer
17+
$molliePayment = $customer->createPayment($paymentData); // ← analyzer uses original type
18+
}
19+
```
20+
21+
Line 452 reassigns `$customer` from `Luxplus\...\Customer` to
22+
`Mollie\Api\Resources\Customer` (aliased as `MollieCustomer`). Line 453
23+
calls `->createPayment()` which exists on `MollieCustomer` but not on
24+
the Luxplus `Customer` model. The analyzer resolves `$customer` as the
25+
original type, producing an `unknown_member` diagnostic for
26+
`createPayment` and a cascading `unresolved_member_access` for
27+
`$molliePayment->getCheckoutUrl()`.
28+
29+
**Impact:** 2 diagnostics in the shared project (`MollieGateway:453`,
30+
`MollieGateway:462`).
31+
32+
## B4: Relationship property access and BelongsTo return type not resolved by analyzer
33+
34+
Eloquent relationship methods accessed as properties (without `()`) are
35+
resolved correctly in the completion engine via `LaravelModelProvider`,
36+
which synthesizes virtual properties (e.g. `translations()` returning
37+
`HasMany<T>` produces `$translations` typed as `Collection<T>`). However,
38+
the analyzer's member-access checker does not find these synthesized
39+
properties in some cross-file scenarios, reporting
40+
`unresolved_member_access`.
41+
42+
Additionally, calling a relationship method WITH `()` (e.g.
43+
`$translation->category()`) returns a `BelongsTo` type that the LSP can
44+
resolve, but then member lookup on that `BelongsTo` fails to find
45+
methods like `associate()`. The `covariant $this` syntax in generic args
46+
(e.g. `BelongsTo<NotificationCategory, covariant $this>`) may interfere
47+
with type parsing.
48+
49+
A separate sub-issue: `FlowService:477` accesses `$order->orderProducts`
50+
(camelCase) while the model declares the property and relationship method
51+
as `orderproducts` (all lowercase). Laravel normalises via `Str::snake()`
52+
at runtime, but the LSP does a case-sensitive property lookup.
53+
54+
Affected diagnostics:
55+
56+
- `NotificationCategory:52``$this->translations` property not resolved
57+
(HasMany relationship, no `@property` annotation)
58+
- `NotificationObject:114``$this->imageFile` property not resolved
59+
(HasOne relationship, no `@property` annotation)
60+
- `NotificationCategoryService:37``$translation->category()->associate()`
61+
`BelongsTo` return type resolves but `associate()` not found on it
62+
63+
`FlowService:477` (`$order->orderProducts`) is a case-sensitivity issue
64+
filed separately as B9.
65+
66+
`FlowService:517` is a compound failure: `$reorder->order->orderproducts`
67+
is a relationship property (this bug), then `->reduce()` returns `mixed`
68+
instead of `Decimal` (generic return type inference gap), then `->add()`
69+
fails on the unresolved type.
70+
71+
**Impact:** 4 diagnostics in the shared project
72+
(`NotificationCategory:52`, `NotificationObject:114`,
73+
`NotificationCategoryService:37`, `FlowService:517`).
74+
75+
## B5: `$this->items` on custom Collection subclass not typed
76+
77+
When a class extends `Collection<int, T>` via `@extends`, accessing
78+
`$this->items` should yield `array<int, T>`. Currently, `$this->items`
79+
resolves as `array` (the base `Collection`'s declared property type)
80+
without applying the generic substitution. This means iterating
81+
`$this->items` in a `foreach` or passing it to `array_any()` loses the
82+
element type.
83+
84+
Real-world example — `PurchaseFileProductCollection.php`:
85+
86+
```php
87+
/**
88+
* @extends Collection<int, PurchaseFileProduct>
89+
*/
90+
final class PurchaseFileProductCollection extends Collection
91+
{
92+
public function hasIssues(): bool
93+
{
94+
return array_any($this->items, fn($item) => $item->order_amount > 0);
95+
// ^^^^^ unresolved
96+
}
97+
}
98+
```
99+
100+
`$this->items` should be `array<int, PurchaseFileProduct>`, so `$item`
101+
in the closure should be `PurchaseFileProduct`. Instead, `$item` is
102+
unresolved because the generic substitution is not applied to inherited
103+
properties when accessed via `$this->`.
104+
105+
**Impact:** 2 diagnostics in the shared project
106+
(`PurchaseFileProductCollection:25` — two property accesses on `$item`).
107+
108+
## B6: Scope methods not found on Builder in analyzer chains
109+
110+
PHPantom's completion engine correctly injects scope methods onto
111+
`Builder<ConcreteModel>` via `try_inject_builder_scopes` in
112+
`resolve_named_type`. However, the analyzer's `check_member_on_resolved_classes`
113+
uses `resolve_class_fully_cached` which is keyed by bare FQN without
114+
generic args. A prior cache entry for `Builder` (without model-specific
115+
scopes) is returned, and the scope method is reported as not found.
116+
117+
The analyzer does check `base_classes` first (before the cache) to avoid
118+
this, but in method chains like
119+
`ArticleCategoryTranslation::whereHas(...)->whereLanguage(...)`, the
120+
intermediate `Builder<ArticleCategoryTranslation>` type produced by
121+
`whereHas()` may not carry the scope-injected methods in `base_classes`.
122+
123+
Affected diagnostics (5 direct + 2 cascading):
124+
125+
Direct `unknown_member` — scope method exists on model but not found on
126+
Builder:
127+
- `ArticleRepository:69``whereLanguage` (scope on
128+
`ArticleCategoryTranslation`)
129+
- `ProductRepository:271``whereIsLuxury` (scope on `Product`)
130+
- `ProductRepository:272``whereIsDerma` (scope on `Product`)
131+
- `ProductRepository:273``whereIsProHairCare` (scope on `Product`)
132+
- `ProductRepository:369``whereIsLuxury` (scope on `Product`)
133+
134+
Cascading `unresolved_member_access`:
135+
- `EventRepository:23``pluck` after broken
136+
`whereIsBlackFriday()->whereIsVisible()` chain
137+
138+
Note: `EventRepository:22` reports `whereIsVisible` not found on Builder.
139+
Product has `scopeIsVisibleIn` (takes a `Country` parameter) but no
140+
`scopeWhereIsVisible` and no `is_visible` column. This may be a genuine
141+
code bug in the project rather than an LSP issue.
142+
143+
**Impact:** 5–6 direct `unknown_member` diagnostics plus 1–2 cascading.
144+
145+
## B7: PHPDoc `@param` generic array type not merged with native `array` hint
146+
147+
When a method has a native type hint `array` and a PHPDoc `@param` with
148+
a generic type like `list<Request>`, PHPantom doesn't merge the PHPDoc
149+
element type with the native `array` for narrowing purposes. After an
150+
`is_array()` guard, the variable narrows to `array` but loses the `Request`
151+
element type from the docblock.
152+
153+
Real-world example — `MobilePayConnection.php`:
154+
155+
```php
156+
/**
157+
* @param null|list<Request>|Request $request
158+
*/
159+
protected function connect(string $uri, null|array|Request $request, ...): MobilePayResponse
160+
{
161+
if (is_array($request)) {
162+
foreach ($request as $item) {
163+
$serializedObjects[] = $item->jsonSerialize();
164+
// ^^^^^ unresolved
165+
}
166+
}
167+
}
168+
```
169+
170+
After `is_array($request)`, `$request` narrows from `null|array|Request`
171+
to `array`. The `@param` says the array case is `list<Request>`, so
172+
`$item` should be `Request`. But the LSP doesn't unify the narrowed
173+
native `array` with the docblock's `list<Request>`.
174+
175+
**Impact:** 1 diagnostic in the shared project
176+
(`MobilePayConnection:76`).
177+
178+
## B8: Variadic parameter element type lost in `foreach`
179+
180+
When a method declares a variadic parameter with a union type like
181+
`HtmlString|int|string ...$placeholders`, iterating with
182+
`foreach ($placeholders as $value)` should give `$value` the element
183+
type `HtmlString|int|string`. Instead, the LSP resolves `$value` as
184+
untyped (hover returns nothing).
185+
186+
Real-world example — `ShortTexts.php`:
187+
188+
```php
189+
public static function get(int $id, Country $lang, HtmlString|int|string ...$placeholders): HtmlString|string
190+
{
191+
// ...
192+
foreach ($placeholders as $value) {
193+
$isHTMLValue = $value instanceof HtmlString;
194+
if ($isHTML) {
195+
$replace[] = $isHTMLValue ? $value->toHtml() : htmlentities((string)$value);
196+
// ^^^^^^ unresolved
197+
}
198+
}
199+
}
200+
```
201+
202+
The variadic `...$placeholders` is internally `array<int, HtmlString|int|string>`,
203+
but the LSP doesn't propagate the element type into the `foreach` loop
204+
variable. This is a prerequisite for the `instanceof` narrowing (which
205+
would further narrow `$value` to `HtmlString` in the truthy ternary
206+
branch), but the primary failure is the missing element type.
207+
208+
**Impact:** 1 diagnostic in the shared project (`ShortTexts:79`).
209+
210+
## B9: Eloquent relationship property lookup is case-sensitive
211+
212+
Laravel normalises property names via `Str::snake()` at runtime, so
213+
`$order->orderProducts` and `$order->orderproducts` both resolve to the
214+
same relationship. PHPantom's property lookup is case-sensitive, so when
215+
code uses `orderProducts` (camelCase) but the model declares the
216+
relationship method and `@property` as `orderproducts` (all lowercase),
217+
the property is not found.
218+
219+
Real-world example — `FlowService.php`:
220+
221+
```php
222+
// FlowService line 477:
223+
$items = $order->orderProducts->map(...);
224+
// ^^^^^^^^^^^^^^ camelCase — not found
225+
226+
// Order model declares:
227+
public function orderproducts(): HasMany { ... }
228+
// and @property uses 'orderproducts' (lowercase)
229+
```
230+
231+
The fix should apply `Str::snake()`-equivalent normalisation (or
232+
case-insensitive matching) when looking up relationship-derived virtual
233+
properties on Eloquent models.
234+
235+
**Impact:** 1 direct diagnostic (`FlowService:477`) plus 1 cascading
236+
(`FlowService:517` — compound with `Collection::reduce()` type loss).

src/virtual_members/laravel/where_property.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
//! - `$attributes` defaults
1616
//! - `$fillable`/`$guarded`/`$hidden`/`$appends` column names
1717
//! - Timestamp columns (`created_at`, `updated_at` unless disabled)
18-
//! - `@property` annotations (already on the raw `ClassInfo`)
18+
//! - `@property` / `@property-read` / `@property-write` docblock tags (parsed from raw docblock)
1919
//! - Declared (non-static, non-private) properties on the class itself
2020
//!
2121
//! Each column `foo_bar` produces a method `whereFooBar($value)` that
2222
//! accepts one parameter and returns `Builder<ConcreteModel>`.
2323
2424
use std::collections::HashSet;
2525

26+
use crate::docblock;
2627
use crate::php_type::PhpType;
2728
use crate::types::{ClassInfo, MethodInfo, ParameterInfo};
2829

@@ -88,16 +89,23 @@ fn collect_column_names(class: &ClassInfo) -> Vec<String> {
8889
}
8990

9091
// ── Properties already on the class ─────────────────────────────
91-
// This catches @property annotations (parsed into `properties` by
92-
// the PHPDoc parser) and any explicitly declared properties.
93-
// We skip properties that look like relationship properties (they
94-
// are objects, not columns) by checking for uppercase first char,
95-
// but we include everything — the worst case is an extra `where`
96-
// method that doesn't hurt.
92+
// This catches any explicitly declared properties and virtual
93+
// properties that were already added to the class.
9794
for prop in class.properties.iter() {
9895
push(&prop.name);
9996
}
10097

98+
// ── @property tags from the class docblock ──────────────────────
99+
// Virtual @property tags are not on `class.properties` yet (they
100+
// are added by PHPDocProvider during full resolution, which runs
101+
// after this function). Extract them directly from the raw
102+
// docblock text.
103+
if let Some(ref doc_text) = class.class_docblock {
104+
for (name, _type_str) in docblock::extract_property_tags(doc_text) {
105+
push(&name);
106+
}
107+
}
108+
101109
columns
102110
}
103111

src/virtual_members/laravel/where_property_tests.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,43 @@ fn lowercase_method_names_helper() {
326326
assert_eq!(names.len(), 2);
327327
}
328328

329+
#[test]
330+
fn synthesizes_from_docblock_property_tags() {
331+
let mut user = make_model("App\\Models\\User");
332+
user.laravel_mut().timestamps = Some(false);
333+
// Simulate a model with @property tags in the class docblock but
334+
// nothing in the properties vec (as happens before full resolution).
335+
user.class_docblock =
336+
Some("/**\n * @property int $brand_id\n * @property string $email\n */".to_string());
337+
338+
let methods = build_where_property_methods_for_class(&user, &HashSet::new());
339+
340+
let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect();
341+
assert!(
342+
names.contains(&"whereBrandId"),
343+
"Expected whereBrandId from @property docblock tag, got: {names:?}"
344+
);
345+
assert!(
346+
names.contains(&"whereEmail"),
347+
"Expected whereEmail from @property docblock tag, got: {names:?}"
348+
);
349+
}
350+
351+
#[test]
352+
fn docblock_property_deduplicates_with_existing_properties() {
353+
let mut user = make_model("App\\Models\\User");
354+
user.laravel_mut().timestamps = Some(false);
355+
// Same property in both properties vec and docblock — only one where method.
356+
user.properties
357+
.push(PropertyInfo::virtual_property("brand_id", Some("int")));
358+
user.class_docblock = Some("/** @property int $brand_id */".to_string());
359+
360+
let methods = build_where_property_methods_for_class(&user, &HashSet::new());
361+
362+
let count = methods.iter().filter(|m| m.name == "whereBrandId").count();
363+
assert_eq!(count, 1, "Should have exactly one whereBrandId method");
364+
}
365+
329366
#[test]
330367
fn all_sources_combined() {
331368
let mut user = make_model("App\\Models\\User");

0 commit comments

Comments
 (0)