Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **Macro hover shows origin and inferred return types.** Hovering on a macro method call now displays a "macro" indicator instead of the generic "virtual" label, distinguishing `::macro()` registrations from `@method`/`@mixin` synthesized members. When the closure has no explicit return type hint, the return type is inferred from the closure body and shown with an "(inferred)" annotation. Bare `$this` / `self` / `static` returns preserve their keyword form, and method chains like `$this->transform(...)` use the last method's declared return type directly — preserving `$this`, `static`, and generic parameters that the general resolver would flatten to a bare class name. Regular (non-macro) methods with inferred return types also show the "(inferred)" annotation on hover. A new `preserve_static` flag on the resolution context controls whether self-references resolve to their keyword form or the concrete class name. Contributed by @calebdw.
- **Return type mismatch diagnostics (`type_mismatch_return`).** Functions and methods with a declared return type are now checked against their `return` statements. Incompatible return values are flagged as errors. Void functions returning a value and bare `return;` in non-void functions are also flagged. Generators (functions using `yield`) are skipped. Uses the same conservative `is_type_compatible` policy as argument type checking to avoid false positives. Contributed by @calebdw.
- **Property type assignment diagnostics (`type_mismatch_property`).** Assignments to typed properties (`$this->prop = expr` and `self::$prop = expr`) are checked against the declared property type. Incompatible values are flagged as errors. Only plain `=` assignments are checked; compound operators (`+=`, `.=`, etc.) are skipped. Untyped and `mixed` properties are not flagged. Contributed by @calebdw.
- **Conditional return types keep an intersection with the matched class.** A `@return ($x is class-string<T> ? T&SomeInterface : SomeInterface)` annotation now resolves the matched branch to the concrete class intersected with the interface, instead of collapsing it to the bare class. Mock factories such as Mockery's `mock(Foo::class)` and Laravel's `$this->mock(Foo::class)` therefore resolve to `Foo&MockInterface`, so their members complete and assigning the result to a `Foo`-typed property or returning it from a `Foo&MockInterface` method no longer reports a spurious type mismatch.
- **PSR-4 mismatch diagnostics and rename-based moves.** Files now warn when the declared namespace or primary class name does not match the PSR-4 path or filename, with quick fixes to correct them. Renaming a class from its declaration now opens the full FQCN so you can move it between namespaces in one step, and renaming a namespace can rewrite multiple segments at once while moving PSR-4 directories and updating references across the project. Contributed by @calebdw.
- **Case-sensitive autoloading diagnostic.** A class reference whose casing differs from the class's actual declaration is now flagged, with a quick fix to correct it. This catches the bug where code loads on a case-insensitive filesystem (macOS, Windows) but fails with a class-not-found error on Linux, because PSR-4 maps the name to a file path and path lookups are case-sensitive there. It covers `use` imports and inline references to autoloaded classes; built-in classes and same-file references, which never reach the autoloader, are left alone.
- **Completion candidates ranked by dependency provenance.** Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (`require` / `require-dev`), then transitive vendor dependencies last. The provenance is inferred from `composer.json` and `installed.json` during indexing. Contributed by @calebdw.
Expand Down Expand Up @@ -51,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Laravel's `now()` and `today()` resolve to the concrete Carbon class.** Both helpers are declared to return the `CarbonInterface` interface, but they hand back Laravel's concrete `Illuminate\Support\Carbon` (which extends `\DateTime`). They now resolve to that concrete class, so a chained call like `now()->addHours(1)` returned from a method declared `: DateTime` no longer reports a false return-type mismatch, and member access on the result resolves precisely. This mirrors the model the Laravel and Carbon ecosystem is written against.
- **A conditional `@return` type is evaluated at the call site even when it is declared on an interface.** A method whose PHPStan conditional return type narrows a literal-array argument (as in Spatie LaravelData's `Data::collect([...])`, which yields `array<static>` for an array) now resolves to that narrowed branch instead of the method's broad declared union. This holds when the conditional is declared on an interface while the concrete method comes from a trait, and the narrowed branch now supersedes the union rather than being discarded (which previously also dropped the union's `array` member). Member access, hover, and the return- and property-type mismatch diagnostics all see the precise type, eliminating false "incompatible with declared type `array<Foo>`" reports.
- **`new ReflectionClass($class)` resolves instances to the reflected type.** When the argument is a `class-string<T>`, `newInstance()` and `newInstanceArgs()` now resolve to the object type `T` (nullable for `newInstanceArgs`) instead of the class-string, so returning `$reflection->newInstanceArgs(...)` from a method declared to return that object type no longer reports a false return-type mismatch. More generally, a docblock type now refines a native union that mixes `object` with a scalar (such as the `object|string` hint many reflection stubs carry), where it was previously discarded.
- **A property assigned from a function of itself no longer crashes analysis.** A self-referencing assignment such as `$this->items = array_unique(array_merge($this->items, $more))`, where the property is read on its own right-hand side, previously sent type resolution into unbounded recursion that overflowed the stack and aborted the whole `analyze` run. The property now resolves to its declared type instead.
- **Method-call chains no longer report a spurious unresolved type on one branch but not an adjacent identical one.** A variable assigned from a call whose return type is inferred from the callee's body (for example an Eloquent query builder returned by an un-annotated `query()` method) kept its type across the whole method, so every `$query->whereBetween(...)->pluck(...)` chain resolves consistently. Previously the receiver's type could be dropped for one branch while an identically shaped adjacent branch resolved cleanly, producing an intermittent "type could not be resolved" warning.
- **A variable destructured from an untyped array can be narrowed by a later assertion.** When list-destructuring pulls variables out of a value whose type is unknown (`[$type, $variable] = $declarations[0]` where `$declarations` is a bare `array`), a following `assertInstanceOf(Wanted::class, $type)` now narrows `$type` to the asserted class, so member access on it resolves instead of reporting the type as unresolvable. A plain assignment from the same untyped value already worked; only the destructuring form left the variables unnarrowable.
Expand Down
1 change: 1 addition & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ within the same impact tier.

| # | Item | Impact | Effort |
| --- | --------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
| L21 | [Tighten the supertype-where-subtype comparison escape hatch (blocked on resolver precision)](todo/laravel.md#l21-tighten-the-supertype-where-subtype-comparison-escape-hatch-blocked-on-resolver-precision) | Medium | High |
| L18 | [`Storage::disk()` return type — resolve from config, don't guess](todo/laravel.md#l18-storagedisk-return-type-is-genuinely-polymorphic--resolve-from-config-dont-guess) | Medium-High | Low-Medium |
| L19 | [Redis client (phpredis/predis) — resolve from config, don't guess](todo/laravel.md#l19-redis-connection-client-is-chosen-by-config-phpredis-vs-predis--resolve-from-config-dont-guess) | Medium | Low-Medium |
| X4 | [Full background indexing](todo/indexing.md#x4-full-background-indexing) (workspace symbols, fast find-references) | Medium | High |
Expand Down
49 changes: 49 additions & 0 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,52 @@ recoverable signature).
Larastan reference: it boots the app and reflects the runtime `$macros`
static, so it gets `mixin()` for free; we recover the common literal shape
from source instead.

#### L21. Tighten the supertype-where-subtype comparison escape hatch (blocked on resolver precision)

**Impact: Medium (a whole class of genuine mismatches goes unreported)
· Effort: High (blocked on two precision gaps below)**

The argument/return compatibility check has a deliberate "reverse
direction MAYBE" escape hatch: when the resolved value is a *broader*
supertype and the declared type is a *narrower* subtype (a downcast),
we accept it rather than flag a mismatch. This is intentionally
over-generous. It hides real errors such as returning a base type
where a specific subclass is declared.

We would like to drop this escape hatch to catch those genuine
mismatches. We cannot yet, because doing so surfaces ~250 diagnostics
across real Laravel codebases. These are *not* classic false
positives: the code is correct against the model Larastan presents,
and Larastan hacks around Carbon and Eloquent rather than modelling
them precisely. Because the entire ecosystem (and therefore
application code) is written against that looser model, tightening the
check drowns real projects in mismatches that only PHPantom would
report. PHPStan/Larastan report zero on the same lines.

Two resolver-precision gaps produce the bulk of them; both must be
closed before the escape hatch can go:

1. **Eloquent custom collection classes.** A relation or query typed
as the base `Illuminate\Database\Eloquent\Collection<int, Model>`
where the method declares a custom `ModelCollection` subclass. We
do not yet resolve a model's `$collectionClass` / `newCollection()`
override, so the base type leaks and looks like a downcast. This is
the largest chunk and is not Carbon-specific.

2. **Carbon parent/child typing.** A value typed `Carbon\Carbon` where
`Illuminate\Support\Carbon` is declared (for example a datetime-cast
property chained through a fluent method). The runtime value is the
concrete Laravel subclass, but our cast/property typing lands on the
parent. Related to the `now()`/`today()` handling already in place,
which maps those helpers to `Illuminate\Support\Carbon`.

Note the `now()`/`today()` mapping itself is part of the same looser
model: it is not strictly sound (the helpers' declared return type is
the interface) but mirrors Larastan so real code does not drown in
mismatches. Any tightening here has to preserve that.

Where to look: the reverse-direction hierarchy block in the argument
type compatibility layer, and the two resolution paths named above.
Reproducible in real projects (a production Laravel codebase surfaces
all ~250 when the escape hatch is removed).
14 changes: 7 additions & 7 deletions docs/todo/lsp-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,12 @@ Consider implementing after X4 (full background indexing) ships, or
accept the same scan-based latency that Find References currently has.

**References:**
- php-lsp: `references/php-lsp/src/navigation/call_hierarchy.rs` — a
- php-lsp: `src/navigation/call_hierarchy.rs` in its own repo — a
working Rust implementation (prepare/incoming/outgoing, cross-file)
built on the same "wrap Find References + walk the body" shape
described above. Their wire-protocol tests
(`references/php-lsp/tests/`, call-hierarchy cases in the Symfony
suite) show the expected item/range semantics editors rely on.
described above. Their wire-protocol tests (`tests/`, call-hierarchy
cases in the Symfony suite) show the expected item/range semantics
editors rely on.
- Phpactor: call hierarchy via its references index.

## F7. Evaluatable expression support (DAP integration)
Expand Down Expand Up @@ -560,9 +560,9 @@ F18's namespace computation).
**References:**
- Phpactor: `MoveClass` refactoring in the class-mover package.
- php-lsp: `handle_will_rename_files` in
`references/php-lsp/src/backend/handlers/workspace.rs` (updates
`use` imports workspace-wide on file rename) and their
`willCreateFiles` PSR-4 stub insertion.
`src/backend/handlers/workspace.rs` in its own repo (updates `use`
imports workspace-wide on file rename) and their `willCreateFiles`
PSR-4 stub insertion.

## F18. Fix namespace/class name from PSR-4

Expand Down
6 changes: 3 additions & 3 deletions docs/todo/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ isn't needed.

**References:**
- Psalm: `ClassLikeStorageCacheProvider` in
`references/psalm/src/Psalm/Internal/Provider/ClassLikeStorageCacheProvider.php`
`Psalm\Internal\Provider\ClassLikeStorageCacheProvider`
- Psalm: `FileStorageCacheProvider` for the content-hash invalidation
pattern
- php-lsp: persists `FileIndex` entries under `~/.cache/php-lsp/`
Expand All @@ -563,7 +563,7 @@ isn't needed.
content hashing. Confirms the content-hash-as-authority choice
above; never trust mtime for correctness, at most as a cheap
pre-filter to skip hashing unchanged files.
- php-lsp: `references/php-lsp/docs/salsa-migration.md` (Phases K1-K4)
- php-lsp: `docs/salsa-migration.md` in its own repo (Phases K1-K4)
documents their cache-size cap, reset-on-overflow, and
LRU-by-mtime eviction plans — useful pitfall list if P20 ships.

Expand Down Expand Up @@ -609,7 +609,7 @@ diagnostic layer.

**References:**
- Psalm: `FileDiffer` and `FileStatementsDiffer` in
`references/psalm/src/Psalm/Internal/Diff/`
`Psalm\Internal\Diff`
- Psalm: `Analyzer::shiftFileOffsets()` for the offset-shifting logic

---
Expand Down
16 changes: 6 additions & 10 deletions docs/todo/type-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,8 @@ Key design decisions to adopt:
(assertions + types → narrowed types). Each is independently
testable.

See `references/psalm/src/Psalm/Internal/Algebra.php`,
`references/psalm/src/Psalm/Internal/Clause.php`, and
`references/psalm/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php`.
See Psalm's `Psalm\Internal\Algebra`, `Psalm\Internal\Clause`, and
`Psalm\Internal\Analyzer\Statements\Expression\AssertionFinder`.

**Depends on:** The structured type representation (`PhpType`) has
landed, which makes reconciliation much simpler than working with
Expand Down Expand Up @@ -563,8 +562,7 @@ This eliminates the exponential re-resolution that causes performance
issues on deeply chained expressions (P20 class of problems).

**References:**
- Psalm: `NodeTypeProvider` interface in
`references/psalm/src/Psalm/NodeTypeProvider.php`
- Psalm: `NodeTypeProvider` interface (`Psalm\NodeTypeProvider`)
- Mago: per-node type caching via `spl_object_id` equivalent

---
Expand Down Expand Up @@ -611,8 +609,7 @@ When selecting the final type for a template param:

**References:**
- Psalm: `TemplateBound` with `appearance_depth` and
`getMostSpecificTypeFromBounds` in
`references/psalm/src/Psalm/Internal/Type/TemplateResult.php`
`getMostSpecificTypeFromBounds` in `Psalm\Internal\Type\TemplateResult`

---

Expand Down Expand Up @@ -658,7 +655,7 @@ intentional).

**References:**
- Psalm: `Context::$vars_in_scope` and `Context::$vars_possibly_in_scope`
in `references/psalm/src/Psalm/Context.php`
(`Psalm\Context`)

---

Expand All @@ -682,8 +679,7 @@ that normal code never hits it, but low enough to prevent pathological
blowup.

**References:**
- Psalm: literal limit in `Type::combineUnionTypes()` at
`references/psalm/src/Psalm/Type.php`
- Psalm: literal limit in `Type::combineUnionTypes()` (`Psalm\Type`)



Expand Down
33 changes: 33 additions & 0 deletions examples/demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -4267,9 +4267,38 @@ public function mockPath(): string
}
}

// ── ReflectionClass<T> instantiation ────────────────────────────────────────
// `new ReflectionClass($classString)` binds the reflected type from a
// `class-string<T>`. `newInstance()` returns `T` and `newInstanceArgs()`
// returns `T|null`, even though the constructor's native hint is the broad
// `object|string`.

class ReflectionInstantiationDemo
{
/**
* @param class-string<ReflectedWidget> $class
*/
public function build(string $class): ReflectedWidget
{
// Fully-qualified `\ReflectionClass` so the unused-import demo above
// keeps its `use ReflectionClass;` dimmed.
$reflection = new \ReflectionClass($class);

// newInstance() → ReflectedWidget (not class-string<ReflectedWidget>)
return $reflection->newInstance();
}
}

// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃

// ── ReflectionClass<T> instantiation scaffolding ────────────────────────────

class ReflectedWidget
{
public function label(): string { return 'widget'; }
}

// ── @phpstan-require-extends scaffolding ────────────────────────────────────

class Mock
Expand Down Expand Up @@ -6648,6 +6677,10 @@ function runDemoAssertions(): void
assert($cfd instanceof Model, 'ClassFilteringDemo must extend Model');
assert($cfd instanceof Renderable, 'ClassFilteringDemo must implement Renderable');

// ── ReflectionClass<T> instantiation ────────────────────────────────
$widget = (new ReflectionInstantiationDemo())->build(ReflectedWidget::class);
assert($widget instanceof ReflectedWidget, 'ReflectionClass::newInstance() must be ReflectedWidget');

// ── Inline new chaining ─────────────────────────────────────────────
$fromNew = (new Canvas())->getBrush();
assert($fromNew instanceof Brush, '(new Canvas())->getBrush() must be Brush');
Expand Down
13 changes: 13 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -418,4 +418,17 @@ public function globalRedisOverImport(): void
// The bare `Redis` short name still resolves to the imported facade.
Redis::connection(); // Facades\Redis static method
}

public function dateHelpers(): \DateTime
{
// now()/today() are declared to return CarbonInterface, but they
// instantiate the concrete Illuminate\Support\Carbon (a \DateTime),
// so member access resolves and a chained fluent call stays concrete.
now()->addHours(1); // → Illuminate\Support\Carbon
today()->startOfDay(); // → Illuminate\Support\Carbon

// Returning the chain from a :DateTime method is not a mismatch,
// because Illuminate\Support\Carbon extends \DateTime.
return now()->addHours(1);
}
}
Loading
Loading