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

- **Relation callbacks keep their full context.** Completion, navigation, and diagnostics retain generic model alternatives through custom relationships and eager-loading arrays. Application-defined callback signatures take precedence, and relation query methods work regardless of letter case. Contributed by @shuvroroy.

- **Relation callbacks follow their argument types.** Completion and diagnostics keep related model types when constraints use relation-name variables, unions, or relation objects. Relation shortcuts and direct eager-loading callbacks now retain their concrete builder or relation too. Contributed by @shuvroroy.

- **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
86 changes: 83 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,12 +473,83 @@ 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
});
}

public function eloquentRelationArguments(bool $includeBaker): void
{
$name = 'headBaker';
$bakery = new Bakery();
Bakery::whereHas($name, fn ($q) => $q->active()); // → BakerBuilder<Baker>
Bakery::whereHas($bakery->headBaker(), fn ($q) => $q->active()); // → BakerBuilder<Baker>
Bakery::orWhereRelation($name, fn ($q) => $q->active());
Bakery::whereDoesntHaveRelation($bakery->headBaker(), fn ($q) => $q->active());
Bakery::orWhereDoesntHaveRelation(column: fn ($q) => $q->active(), relation: $name);

// Each possible name contributes its builder and eager relation.
$relation = $includeBaker ? 'headBaker' : 'baguettes';
Bakery::withWhereHas($relation, fn ($q) => $q->where('id', '>', 0));

// Direct eager callbacks receive a relation and retain its related model.
Bakery::query()->with(callback: function (Relation $q) {
$q->where('active', true)->getModel()->getName(); // → string (Baker::getName())
}, relations: $name); // → HasOne<Baker, Bakery>

Review::whereHasMorph((new Review())->reviewable(), Loaf::class, function ($q, $type) {
$q->stale(); // → LoafBuilder<Loaf>
echo $type; // → string
});
}

public function eloquentRelationContexts(bool $bakerQuery): void
{
// Custom relations preserve their ancestor's related-model binding.
Bakery::whereHas('leadBaker', fn ($q) => $q->active()); // → BakerBuilder<Baker>
Bakery::WHEREHAS('headBaker', fn ($q) => $q->active()); // PHP methods ignore case.
Bakery::with(['leadBaker' => function (Relation $q) {
$q->getModel()->getName(); // → Baker::getName(); $q is BakerRelation<Bakery, Baker>.
}]);
BlogPost::query()->with(['author' => ['posts' => function ($q) {
$q->getModel()->getTitle(); // → BlogPost::getTitle(); the terminal eager relation is HasMany.
}]]);

if ($bakerQuery) {
$query = Bakery::query();
$relation = 'headBaker';
} else {
$query = BlogAuthor::query();
$relation = 'posts';
}
$query->whereHas($relation, fn ($q) => $q->where('id', '>', 0)); // → BakerBuilder<Baker>|Builder<BlogPost>
}

// ── Laravel Config & Env Navigation ─────────────────────────────────────

Expand Down
15 changes: 15 additions & 0 deletions examples/laravel/app/Models/BakerRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;

/**
* @template TParent of Model
* @template TRelated of Model
* @extends HasOne<TRelated, TParent>
*/
class BakerRelation extends HasOne
{
}
6 changes: 6 additions & 0 deletions examples/laravel/app/Models/Bakery.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public function baguettes(): mixed { return $this->hasMany(Loaf::class); }
/** @return HasOne<Baker, $this> */
public function headBaker(): mixed { return $this->hasOne(Baker::class); }

/** @return BakerRelation<$this, Baker> */
public function leadBaker(): BakerRelation
{
return new BakerRelation((new Baker())->newQuery(), $this, 'bakers.bakery_id', 'id');
}

/** @return BelongsToMany<BakeryRecipe, $this> */
public function masterRecipe(): mixed { return $this->belongsToMany(BakeryRecipe::class)->using(RecipeIngredient::class)->withPivot('quantity', 'unit'); }

Expand Down
Loading
Loading