Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Eloquent query examples and inference coverage.** The Laravel playground demonstrates custom builders surviving query chains and callbacks following dotted relationships. An audit against the PHPStan Laravel extensions now guards these behaviours with editor and runtime assertions; the remaining inference gaps are tracked as focused follow-up work. Contributed by @shuvroroy.
- **Extract interface is offered only to editors that can create files.** The action writes the new interface to a file of its own, which an editor has to say it accepts (the `create` resource operation) before a server may send it. Editors that do not are no longer shown an action they cannot apply.
- **Updated the bundled mago toolchain to 1.47.5.** The parser, docblock parser, formatter, and supporting crates are refreshed to the latest upstream release. Contributed by @nguyentranchung.

### Fixed

- **Model instance queries keep custom builders.** Starting a query with `newQuery()`, `newModelQuery()`, or `newQueryWithoutScopes()` now retains the model’s custom builder and its model type through subsequent calls. Contributed by @shuvroroy.
- **Hover, completion, go-to-definition, signature help, and inlay hints parse the document once per request.** The type engine reads the syntax tree from several places while resolving an expression, and only diagnostics and code actions were sharing one parse between them; every other request re-parsed the whole file once per resolution step, which on a large file made a hover noticeably slower than the diagnostics for the same line.
- **A Blade template deleted or renamed on disk no longer keeps its lowered PHP in memory.** The template's generated PHP and source map were only released when the editor closed the file, so a template removed by a rename or a branch switch stayed resident for the rest of the session and kept being visited by every Blade refresh pass.
- **A method's unnamed `@param` tags are now matched by position, and its `@param` descriptions now show up in hover.** Both already worked for a standalone function's docblock; a method's own merge was a separate, older implementation that never grew the positional fallback (common in phpstorm-stubs-style docs, e.g. `@param callable(TValue, TKey): bool` with no `$callback`) and never copied the description across at all.
Expand Down
5 changes: 4 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,13 @@ unlikely to move the needle for most users.
| L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High |
| L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Medium |
| L53 | [Collection key types from the column for `keyBy` / `groupBy` / `pluck`](todo/laravel.md#l53-collection-key-types-from-the-column-for-keyby-groupby-pluck) | Medium | Medium |
| L57 | [Bind named relation callback arguments before inference](todo/laravel.md#l57-bind-named-relation-callback-arguments-before-inference) | Medium | Medium |
| L32 | [Config-backed named-resource strings](todo/laravel.md#l32-config-backed-named-resource-strings) (log channels, cache stores, guards, connections, rate limiters) | Medium | Medium |
| L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium |
| L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium-High |
| L54 | [Audit custom-builder and relation-closure inference against the PHPStan extensions](todo/laravel.md#l54-audit-custom-builder-and-relation-closure-inference-against-the-phpstan-extensions) | Medium | Medium-High |
| L56 | [Preserve custom builders in relation callbacks](todo/laravel.md#l56-preserve-custom-builders-in-relation-callbacks) | Medium | Medium-High |
| L58 | [Infer morph constraint callbacks from candidate models](todo/laravel.md#l58-infer-morph-constraint-callbacks-from-candidate-models) | Medium | Medium-High |
| L59 | [Infer both callback receivers for withWhereHas](todo/laravel.md#l59-infer-both-callback-receivers-for-withwherehas) | Medium | Medium-High |
| L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium |
| L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Medium |
| L3 | `$dates` array (deprecated) | Low-Medium | Medium |
Expand Down
120 changes: 94 additions & 26 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,35 +247,103 @@ the collection return-type patches for the argument forms. The property
lookup already exists for `model-property<Model>`; the work is threading
a resolved key type through the generic substitution.

#### L54. Audit custom-builder and relation-closure inference against the PHPStan extensions
#### L56. Preserve custom builders in relation callbacks

**Impact: Medium · Complexity: Medium-High**

Two areas where the PHPStan Laravel extensions have moved past what we
mirror, and where we have machinery that has not been checked against
them:

- **A custom builder surviving the chain.** We read
`newEloquentBuilder()` (`virtual_members/laravel/model_extraction.rs`)
and inject the builder, but it is not established that
`Team::query()->where(…)->orderBy(…)` stays on `TeamBuilder` rather than
degrading to `Builder<Team>` at the first inherited call, nor that
static calls on the model and instance calls on the builder agree about
what comes back.
- **Relation-constraint closure parameters.** We type closures for the
`whereHas` family (`type_engine/variable/closure_resolution.rs`,
`forward_walk/callable_inference.rs`). Unverified: dotted relation paths
(`whereHas('stocks.warehouse', …)` should type the closure for
`Warehouse`'s builder, resolving each segment against the model the
previous one named), the `*Morph` variants' union of candidate builders
plus their `$type` parameter, `withWhereHas` receiving both a builder
and the relation, and closures in non-leading argument positions
(`has('stocks', '>=', 1, 'and', fn ($q) => …)`).

**Where to change:** Write the assertion cases first — the existing
`tests/integration/completion_laravel.rs` conventions cover both areas —
and file what actually fails. Splitting this into concrete items once the
gaps are known is preferable to a broad rewrite of either subsystem.
Two sides of the relation override discard custom-builder context:

- `Team::query()->whereHas('stocks.warehouse', …)` fails to identify the
receiver's model when `Team` uses `TeamBuilder`. The callback in the
assertion fixture becomes `Builder<string>` instead of
`Builder<Warehouse>`.
- `Stock::whereHas('team', …)` resolves the related model but always builds
`Builder<Team>`, losing `TeamBuilder` and its custom methods. An explicit
bare `Builder` parameter has the same problem.

**Reproducer:** The custom-builder callback assertions in `relations()`
in `tests/phpstan_nsrt/laravel-builder-relations.php`. Expected types follow
[Larastan's relation callback assertions](https://github.com/larastan/larastan/blob/c328727e6103c1147d1c64cc96b3aedfda26bc20/tests/Type/data/relationship-query-callbacks.php).

**Where to change:** `find_model_from_receivers` and
`try_relation_query_override` in
`type_engine/variable/closure_resolution.rs`, using the receiver's resolved
generic context and the existing custom-builder metadata/selection. Keep
this in the shared callable inference path so completion, hover, and
diagnostics agree. Refining a bare `Builder` hint to its custom subclass
must preserve the inferred generic arguments too.

#### L57. Bind named relation callback arguments before inference

**Impact: Medium · Complexity: Medium**

`Team::has(relation: 'stocks.warehouse', callback: …)` types the callback
correctly, but putting `callback:` first loses the relation and produces
`Builder<string>` in the assertion fixture. Positional callbacks in the
fifth argument already work; the missing step is binding named arguments,
not assuming the first supplied expression names the relation.

**Reproducer:** The reordered `has(callback: …, relation: …)` assertion
in `relations()` in `tests/phpstan_nsrt/laravel-builder-relations.php`.

**Where to change:** Relation overrides in
`type_engine/variable/forward_walk/callable_inference.rs` currently receive
the first literal argument from `extract_first_arg_string_fw`. Use the
shared argument binder to locate both the relation and the actual callback
parameter, consistently in the cursor and diagnostics walks. Cover static
and instance calls, arrow functions, omitted defaults, and closures in an
unrelated argument so the override does not attach to the wrong closure.

#### L58. Infer morph constraint callbacks from candidate models

**Impact: Medium · Complexity: Medium-High**

The `whereHasMorph` family does not participate in relation-query
inference. A literal model class or an array of candidates yields
`Builder<string>` in the assertion fixture, losing both the related
models and their custom builders. The second `$type` callback parameter
already resolves to `string` from Laravel's declaration and must stay
typed when the first parameter is refined.

**Reproducer:** The `morphs()` assertions in
`tests/phpstan_nsrt/laravel-builder-relations.php` cover all six
callback-taking existence methods, single and multiple candidates,
reordered named arguments, wildcard and empty-list fallbacks, and a
declared `MorphTo<Team>` target. The desired builder types remain in the
`SKIP` assertions; the `$type` assertions run normally.

**Where to change:** Extend shared callable inference using bound
`relation`, `types`, and `callback` arguments. Build a union of candidate
builders, preserving custom builders. For unresolved/wildcard candidates,
use the relation's declared target, falling back to `Model` when that is
all the declaration knows. Also cover class-string variables and mixed
known/unknown candidate arrays, as
[Larastan's callback extension](https://github.com/larastan/larastan/blob/c328727e6103c1147d1c64cc96b3aedfda26bc20/src/ClosureTypes/RelationshipQueryCallbackExtension.php)
does. Do not boot the application or query the database for wildcard
discovery. Check the `whereMorphRelation` shortcuts when sharing the
callback logic.

#### L59. Infer both callback receivers for withWhereHas

**Impact: Medium · Complexity: Medium-High**

`withWhereHas('stocks', …)` calls the constraint with `Builder<Stock>` for
the existence query and `HasMany<Stock, Team>` for eager loading. We infer
only the builder. Dotted paths likewise lose the final relation type, and
the `stocks:id` column-selection form fails relation lookup entirely,
falling back to unbound `Builder<mixed>|Relation<mixed, mixed, mixed>`.

**Reproducer:** The `eagerRelations()` assertions in
`tests/phpstan_nsrt/laravel-builder-relations.php`. The Laravel runtime
assertions also verify both invocations without executing eager-load SQL.

**Where to change:** Extend the shared relation-chain resolver to retain
the terminal instantiated relation and its declaring model, then use the
builder/relation union for this callback. Strip the column suffix where
Laravel accepts it, preserve custom builders, and bind named arguments.
The callback's ordinary fluent methods must preserve that union. Cover
explicit `Builder|Relation` hints and the `withWhereRelation` counterpart;
ordinary `whereHas` must continue to receive just the related builder.

#### L45. `*_count` properties are offered on every relationship

Expand Down
18 changes: 15 additions & 3 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ public function eloquentQuery(): void
Loaf::query()->stale()->get(); // → Collection<Loaf>
Baker::query()->active()->firstOrFail()->getName(); // → Baker

// Inherited methods and forwarded query methods keep the custom builder.
Loaf::query()->where('crust', 'sourdough')->orderBy('id')->stale(); // → LoafBuilder
Loaf::where('crust', 'sourdough')->orderBy('id')->stale(); // → LoafBuilder
Baker::query()->whereIn('id', [1])->lockForUpdate()->active(); // → BakerBuilder<Baker>
(new Loaf())->newQuery()->orderBy('id')->stale(); // → LoafBuilder
(new Baker())->newQueryWithoutScopes()->active(); // → BakerBuilder<Baker>
(new Baker())->newModelQuery()->firstOrFail()->getName(); // → Baker

// Paginators carry the model element type through foreach
foreach (BlogAuthor::where('active', 1)->paginate() as $author) {
$author->profile->getBio(); // → BlogAuthor
Expand Down Expand Up @@ -464,10 +472,14 @@ public function eloquentClosure(): void
$query->where('published', true); // resolves to Builder<BlogPost>
});

// Dot-notation relation chain
BlogPost::whereHas('author', function ($q) {
$q->where('active', true); // resolves to Builder<BlogAuthor>
// Each dotted segment is resolved on the preceding related model.
BlogPost::whereHas('author.posts', function ($q) {
$q->where('published', true); // resolves to Builder<BlogPost>
});

// has() takes its callback in the fifth argument; arrow functions
// receive the final related model's builder too.
BlogAuthor::has('posts.author', '>=', 1, 'and', fn ($q) => $q->active()); // → Builder<BlogAuthor>
}


Expand Down
72 changes: 72 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,78 @@ function assertMethodReturnType(string $class, string $method, string $expected)
$result->getModel() instanceof \App\Models\Baker
);

check(
'Inherited where/orderBy calls preserve the LoafBuilder instance',
($query = \App\Models\Loaf::query())->where('crust', 'sourdough')->orderBy('id')->stale() === $query
);
check(
'Static where/orderBy forwarding returns LoafBuilder',
\App\Models\Loaf::where('crust', 'sourdough')->orderBy('id')->stale() instanceof \App\Models\LoafBuilder
);
check(
'Query-builder forwarding preserves BakerBuilder and its model',
($query = \App\Models\Baker::query())->whereIn('id', [1])->lockForUpdate()->active() === $query
&& $query->getModel() instanceof \App\Models\Baker
);

foreach (['newQuery', 'newModelQuery', 'newQueryWithoutScopes'] as $factory) {
$loafQuery = (new \App\Models\Loaf())->$factory();
$bakerQuery = (new \App\Models\Baker())->$factory();
check(
"Model::$factory preserves custom builders and their models",
$loafQuery instanceof \App\Models\LoafBuilder
&& $loafQuery->stale()->getModel() instanceof \App\Models\Loaf
&& $bakerQuery instanceof \App\Models\BakerBuilder
&& $bakerQuery->active()->getModel() instanceof \App\Models\Baker
);
}

$callbackModels = [];
\App\Models\BlogPost::whereHas('author.posts', function ($query) use (&$callbackModels) {
$callbackModels[] = get_class($query->getModel());
$query->where('published', true);
});
check(
'Dotted whereHas callback queries the last related model',
$callbackModels === [\App\Models\BlogPost::class]
);

$callbackModels = [];
\App\Models\BlogAuthor::has('posts.author', '>=', 1, 'and', function ($query) use (&$callbackModels) {
$callbackModels[] = get_class($query->active()->getModel());
});
check(
'The fifth has argument receives the final related model builder and scopes',
$callbackModels === [\App\Models\BlogAuthor::class]
);
check(
'An arrow callback in the fifth has argument preserves the outer query',
($query = \App\Models\BlogAuthor::query())->has('posts.author', '>=', 1, 'and', fn ($related) => $related->active()) === $query
);

// Check both callback invocations without executing the eager-load SQL.
$callbackClasses = [];
$query = \App\Models\BlogAuthor::withWhereHas('posts', function ($related) use (&$callbackClasses) {
$callbackClasses[] = get_class($related);
});
$query->getEagerLoads()['posts']((new \App\Models\BlogAuthor())->posts());
check(
'withWhereHas supplies a builder for existence and a relation for eager loading',
$callbackClasses === [\Illuminate\Database\Eloquent\Builder::class, \Illuminate\Database\Eloquent\Relations\HasMany::class]
);

$morphCallbacks = [];
\App\Models\Review::whereHasMorph('reviewable', [\App\Models\BlogPost::class, \App\Models\Loaf::class], function ($related, $type) use (&$morphCallbacks) {
$morphCallbacks[] = [get_class($related), get_class($related->getModel()), $type];
});
check(
'Morph callbacks receive each candidate builder, its model, and the class-string type',
$morphCallbacks === [
[\Illuminate\Database\Eloquent\Builder::class, \App\Models\BlogPost::class, \App\Models\BlogPost::class],
[\App\Models\LoafBuilder::class, \App\Models\Loaf::class, \App\Models\Loaf::class],
]
);

// Model::fresh() on instance (non-existing model returns null)
$result = $bakery->fresh();
check(
Expand Down
Loading
Loading