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
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,20 @@ 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

- **Eager relation constraints keep both callback types.** `withWhereHas()` and `withWhereRelation()` now retain the related builder and relation through fluent calls, including dotted paths and explicit union hints. Contributed by @shuvroroy.

- **Morph relation callbacks infer their candidate models.** Completion and diagnostics retain custom builders for polymorphic constraints, including unions and class-string variables, while unknown candidates use the relation’s declared model. Contributed by @shuvroroy.

- **Named relation callbacks keep their model types.** Completion and diagnostics now resolve relationship constraints when named arguments are reordered or optional arguments are omitted. Contributed by @shuvroroy.

- **Relation callbacks retain custom builders.** Constraints on related models now offer their custom builder methods, including through custom-builder query chains and bare `Builder` parameter hints. Contributed by @shuvroroy.
- **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
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ unlikely to move the needle for most users.
| 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 |
| 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
30 changes: 0 additions & 30 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,36 +247,6 @@ 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

**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.

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

**Impact: Low-Medium · Complexity: High**
Expand Down
40 changes: 37 additions & 3 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Database\Factories\BlogAuthorFactory;
use Database\Factories\EditorialFactory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
Expand Down Expand Up @@ -180,6 +181,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,9 +473,34 @@ 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>

// Named arguments may put the callback before the relation.
BlogAuthor::has(callback: fn ($q) => $q->active(), relation: 'posts.author'); // → Builder<BlogAuthor>

// The related model chooses the builder, including with a bare hint.
Bakery::whereHas('baguettes', function (Builder $q) {
$q->stale(); // → LoafBuilder<Loaf>
});
Bakery::query()->whereHas('headBaker', fn ($q) => $q->active()); // → BakerBuilder<Baker>

// The same constraint runs on a builder and the eager-loaded relation.
Bakery::withWhereHas(callback: function (Builder|Relation $q) {
$q->where('weight_grams', '>', 500); // → LoafBuilder<Loaf>|HasMany<Loaf, Bakery>
}, relation: 'baguettes');
BlogPost::withWhereRelation('author.posts', fn ($q) => $q->where('published', true)); // → Builder<BlogPost>|HasMany<BlogPost, BlogAuthor>

// Morph candidates choose the callback builder and keep the type string.
Review::whereHasMorph('reviewable', Loaf::class, function (Builder $q, string $type) {
$q->stale(); // → LoafBuilder<Loaf>
echo $type; // → string
});
}

Expand Down
134 changes: 134 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,140 @@ 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]
);

$relatedBuilders = [];
\App\Models\Bakery::whereHas('baguettes', function (\Illuminate\Database\Eloquent\Builder $query) use (&$relatedBuilders) {
$relatedBuilders[] = get_class($query->stale());
});
\App\Models\Bakery::query()->whereHas('headBaker', function ($query) use (&$relatedBuilders) {
$relatedBuilders[] = get_class($query->active());
});
check(
'Relation constraints select each related model custom builder',
$relatedBuilders === [\App\Models\LoafBuilder::class, \App\Models\BakerBuilder::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
);

$namedCallbackModels = [];
\App\Models\BlogAuthor::query()->has(callback: function ($query) use (&$namedCallbackModels) {
$namedCallbackModels[] = get_class($query->active()->getModel());
}, relation: 'posts.author');
check('Reordered named relation arguments preserve the callback model', $namedCallbackModels === [\App\Models\BlogAuthor::class]);

// 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]
);

foreach (['withWhereHas', 'withWhereRelation'] as $method) {
$seen = [];
$constraint = function (\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query) use (&$seen) {
$seen[] = [get_class($query), get_class($query->where('weight_grams', '>', 500)), get_class($query->getModel())];
};
$query = \App\Models\Bakery::$method('baguettes', $constraint);
$query->getEagerLoads()['baguettes']((new \App\Models\Bakery())->baguettes());
check("$method preserves the custom builder and relation through fluent calls", $seen === [
[\App\Models\LoafBuilder::class, \App\Models\LoafBuilder::class, \App\Models\Loaf::class],
[\Illuminate\Database\Eloquent\Relations\HasMany::class, \Illuminate\Database\Eloquent\Relations\HasMany::class, \App\Models\Loaf::class],
]);
}
$seen = [];
$query = \App\Models\BlogPost::withWhereRelation('author.posts', function ($query) use (&$seen) {
$seen[] = [get_class($query), get_class($query->getModel())];
});
$terminal = (new \App\Models\BlogAuthor())->posts();
$query->getEagerLoads()['author.posts']($terminal);
check('Dotted eager callbacks use the terminal relation and declaring model', $seen === [
[\Illuminate\Database\Eloquent\Builder::class, \App\Models\BlogPost::class],
[\Illuminate\Database\Eloquent\Relations\HasMany::class, \App\Models\BlogPost::class],
] && $terminal->getParent() instanceof \App\Models\BlogAuthor);
$seen = [];
$query = \App\Models\Bakery::withWhereHas(callback: function ($query) use (&$seen) {
$seen[] = get_class($query);
}, relation: 'baguettes:id,weight_grams');
check('withWhereHas strips column selections for its existence query', $seen === [\App\Models\LoafBuilder::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],
]
);

$morphCandidate = \App\Models\Loaf::class;
foreach (['hasMorph', 'doesntHaveMorph', 'whereHasMorph', 'orWhereHasMorph', 'whereDoesntHaveMorph', 'orWhereDoesntHaveMorph'] as $method) {
$seen = [];
\App\Models\Review::$method(callback: function ($query, $type) use (&$seen) {
$seen[] = [get_class($query->stale()), $type];
}, types: $morphCandidate, relation: 'reviewable');
check("$method binds named candidates and preserves the custom builder", $seen === [[\App\Models\LoafBuilder::class, $morphCandidate]]);
}
foreach (['whereMorphRelation', 'orWhereMorphRelation', 'whereMorphDoesntHaveRelation', 'orWhereMorphDoesntHaveRelation'] as $method) {
$seen = [];
\App\Models\Review::$method(column: function ($query) use (&$seen) {
$seen[] = get_class($query->stale()->getModel());
}, types: $morphCandidate, relation: 'reviewable');
check("$method passes the candidate builder to its column closure", $seen === [$morphCandidate]);
}

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