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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw.
- **Artisan command names and signature strings.** Command names declared by a command class's `$signature`, `$name`, or `#[AsCommand]` attribute are now recovered from project and vendor command classes, so referencing one as a string completes, navigates to the declaring class, hovers with its arguments and options, and flags unknown names. This covers `Artisan::call()`, `Artisan::queue()`, `Schedule::command()`, and `$this->call()` / `$this->callSilently()` inside a command. The `$signature` grammar (`{user}`, `{user?}`, `{--queue=}`, arrays, defaults, shortcuts, and ` : ` descriptions) is parsed so that, inside a command, `$this->argument('user')` and `$this->option('queue')` complete and hover against that command's own signature and unknown parameter names are flagged, and the parameter array of `Artisan::call('cmd', [...])` completes the target command's argument and `--option` keys. Contributed by @shuvroroy (#274).
- **Request input keys complete from validation rules.** The keys of a Laravel validation rules array are the complete set of inputs a request may carry, so PHPantom now offers them as string completion wherever a request accessor names a field: `$request->input('key')`, the typed and presence accessors, the multi-key ones such as `only()` and `except()`, `safe()->only([...])`, and array access `$request['key']`. The rules come from the `rules()` method of the `FormRequest` type-hinted in the enclosing method (following `array_merge()` and the parent chain), or from a `validate()` / `Validator::make()` call earlier in the same method. Each suggestion shows its rule (`required|string|max:255`), and go-to-definition on the key jumps to the line that declares it. Wildcard rules like `items.*.id` complete their root segment, and a computed rule key simply contributes nothing rather than a wrong suggestion. Contributed by @shuvroroy (#292).
- **`validated()` is typed from the validation rules.** `$request->validated()` is declared to return `array` and no tool takes it further, but the rules array already says which keys the result holds and what each one is. PHPantom now translates it into an array shape, so `$data['title']` is a `string`, `$data['views']` is an `int`, `'nullable'` adds `null`, a field that is neither required nor nullable becomes an optional key, `'items.*.id'` produces `list<array{id: int}>`, and an `'image'` rule gives you a real `UploadedFile` to chain from. The keys complete on array access and the whole shape shows in hover. This covers `$request->validate([...])` return values, `$validator->validated()` where the rules are visible, `validated('key')` for a single member, and `safe()->only([...])` / `except([...])`, which narrow the same shape. When a rule key is computed the shape is abandoned for plain `array` rather than claiming a key set it cannot vouch for. Contributed by @shuvroroy (#294).
- **Laravel model factory relationship methods.** Eloquent factories now offer the dynamic `has{Relationship}()` and `for{Relationship}()` methods that Laravel resolves through `Factory::__call()`, one per relationship on the associated model, plus `trashed()` when the model uses `SoftDeletes`. They complete, hover, and resolve, and because each returns the factory the fluent chain continues, so `Post::factory()->hasComments(3)->create()` still resolves to the model. The model is derived from the factory naming convention, so no `@extends Factory<Model>` generic is required. Contributed by @shuvroroy (#260).
- **Eloquent `$pivot` attribute on many-to-many related models.** Models that are the target of a `belongsToMany`/`morphToMany` relationship now expose a `$pivot` property, so accessing the intermediate row (e.g. `$user->roles->first()->pivot`) completes, hovers, and resolves. The pivot type is taken from the relationship's `TPivotModel` generic (`BelongsToMany<Permission, $this, PermissionRole>`), falling back to a `->using(CustomPivot::class)` call in the relationship body and then to the base `\Illuminate\Database\Eloquent\Relations\Pivot`; a declared `pivot` property still takes precedence. Only models actually reached through such a relationship gain the attribute, and the `->withPivot('col', …)` columns and custom pivot class are also shown when hovering the relationship. Contributed by @shuvroroy (#266).
- **Laravel `Macroable::mixin()` registrations are recognized.** A `Str::mixin(new StrMixin())` or `Collection::mixin(CollectionMixin::class)` call in a service provider now contributes one macro per public/protected method of the mixin class, taking the signature of the closure that method returns, so those methods autocomplete, hover, resolve, and type-check on the target class just like a `Target::macro(...)` registration. Mixins registered through a facade also attach to the facade's concrete container-bound class, and go-to-definition on a mixed-in method jumps to the mixin method's own declaration. Editing the mixin class (for example adding a helper method) refreshes the recognized macros. Contributed by @shuvroroy (#256).
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ unlikely to move the needle for most users.
| L15 | [Completion for Laravel string keys](todo/laravel.md) | High | Medium |
| L5 | [`abort_if`/`abort_unless` type narrowing](todo/laravel.md#l5-abort_ifabort_unless-type-narrowing) | High | Medium |
| L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium |
| L38 | [Typed `validated()` array shapes from rules](todo/laravel.md#l38-typed-validated-array-shapes-from-rules) | Medium-High | Medium-High |
| L26 | [Gate ability and policy strings](todo/laravel.md#l26-gate-ability-and-policy-strings) | Medium-High | Medium-High |
| L16 | [Hover for Laravel string keys](todo/laravel.md) | Medium | Low-Medium |
| L23 | [Route parameter name completion](todo/laravel.md#l23-route-parameter-name-completion) | Medium | Low-Medium |
Expand Down
32 changes: 0 additions & 32 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,38 +847,6 @@ binding `bind(Gateway::class, StripeGateway::class)` does **not**
retype `app(Gateway::class)` to the concrete — the contract is the
interface.

#### L38. Typed `validated()` array shapes from rules

**Impact: Medium-High · Effort: Medium-High**

No mainstream tool types `$request->validated()` beyond `array` — this
is a leapfrog opportunity, not catch-up. The rules array is a static
type contract; translate it into an array shape:

- `'name' => 'required|string|max:255'` → `name: string`
- `'age' => 'nullable|integer'` → `age: int|null`
- `'active' => 'boolean'` → `active: bool`
- `'role' => [new Enum(Role::class)]` / enum-cast columns → `role: string`
(the raw validated value; the enum object only exists after `enum()`)
- `'items' => 'array'`, `'items.*.id' => 'integer'` →
`items: list<array{id: int}>`
- fields without `required` (and without `nullable`) are optional keys
(`name?:`), since absent input never reaches the validated array

Apply the shape to `$request->validated()` (no-argument form),
`$request->validate([...])` return values, and `$validator->validated()`
where the rules are visible. `validated('key')` returns the shape's
member type. `safe()` returns a `ValidatedInput` whose `only()`/
`except()` results narrow the same shape. Fall back to plain `array`
the moment any rule key or rule string is non-literal — a partial
shape that claims completeness would produce false unknown-key
diagnostics. Array-shape machinery (shapes, optional keys, `list<>`)
already exists in the type engine, and
`virtual_members/laravel/validation_rules.rs` already recovers the
literal `(key, rule)` pairs and locates the rules array in scope for a
cursor; the work is the rules-to-shape translation and wiring it as a
conditional return.

#### L39. Unused view and translation key detection

**Impact: Low · Effort: Medium**
Expand Down
45 changes: 45 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,51 @@ public function inlineValidateKeys(Request $request): void
}


// ── Typed validated() array shapes from rules ───────────────────────

public function validatedArrayShape(StoreBakeryRequest $request): void
{
// validated() is declared `array`, but the rules array says exactly
// which keys it holds and what each one is, so it resolves to:
// array{
// name: string,
// apricot?: bool,
// dough_temp?: ?int|float,
// notes?: list<array{body: string}>,
// owner: array{email: string},
// }
$data = $request->validated();

$data['name']; // → string
$data['apricot']; // → bool ('apricot' is optional)
$data['notes']; // → list<array{body: string}>
$data['owner']['email']; // → string

// A key argument returns that member's type rather than the array.
$request->validated('name'); // → string

// safe() narrows the same shape.
$subset = $request->safe()->only(['name', 'apricot']);
$subset['name']; // → string, and 'notes' is gone
}


public function validatedShapeFromInlineRules(Request $request): void
{
// The rules passed to validate() type its return value directly, so
// no FormRequest class is needed.
$data = $request->validate([
'headline' => 'required|string',
'rank' => 'required|integer',
'featured' => 'boolean',
]);

$data['headline']; // → string
$data['rank']; // → int
$data['featured']; // → bool (optional key)
}


// ── @mixin of an Eloquent model exposes its virtual members ─────────

public function mixinModel(): void
Expand Down
43 changes: 43 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,49 @@ class_uses_recursive(\App\Models\BlogPost::class),
&& method_exists(\Illuminate\Support\ValidatedInput::class, 'except')
);

// ─── Validated arrays only carry keys the rules named ───────────────────────

// Demo::validatedArrayShape() reads StoreBakeryRequest's rules as an array
// shape. Two claims it makes are worth pinning to the real validator: that
// the result carries only keys the rules named, and — the reason `apricot`
// and `dough_temp` are *optional* keys rather than nullable ones — that a
// field which is merely allowed is absent when it was not sent.
//
// Built directly rather than through the facade, which needs a booted
// container.
$translator = new \Illuminate\Translation\Translator(
new \Illuminate\Translation\ArrayLoader(),
'en'
);
$validated = (new \Illuminate\Validation\Validator(
$translator,
[
'name' => 'Sourdough',
'owner' => ['email' => 'baker@example.com'],
'unlisted' => 'ignored',
],
(new \App\Http\Requests\StoreBakeryRequest())->rules()
))->validated();
check(
'validated() drops input the rules do not name',
! array_key_exists('unlisted', $validated)
);
check(
'validated() keeps the fields that were supplied',
($validated['name'] ?? null) === 'Sourdough'
);
check(
'an unsent optional field is absent from validated()',
! array_key_exists('apricot', $validated)
);
// `dough_temp` is `nullable|numeric`. `nullable` permits a null value; it
// does not make the key appear, which is why the shape marks it optional
// (`dough_temp?: ?int|float`) rather than merely nullable.
check(
'an unsent nullable field is absent, not present-and-null',
! array_key_exists('dough_temp', $validated)
);

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
Expand Down
2 changes: 2 additions & 0 deletions src/analyse/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ pub async fn run(options: AnalyseOptions) -> i32 {
crate::type_engine::call_resolution::with_callable_target_cache();
let _body_infer_guard = backend.activate_body_return_inferrer();
let _auth_user_guard = backend.activate_auth_user_resolver();
let _validation_rules_guard =
backend.activate_validation_rules_resolver();

// ── Forward-walked diagnostic scope cache ───
// Walk every function/method body once with the
Expand Down
1 change: 1 addition & 0 deletions src/completion/handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ impl Backend {
let _chain_guard = crate::type_engine::resolver::with_chain_resolution_cache();
let _body_infer_guard = self.activate_body_return_inferrer();
let _auth_user_guard = self.activate_auth_user_resolver();
let _validation_rules_guard = self.activate_validation_rules_resolver();
let _cache_guard = crate::virtual_members::with_active_resolved_class_cache(
&self.resolved_class_cache,
);
Expand Down
1 change: 1 addition & 0 deletions src/diagnostics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ impl Backend {
let _callable_guard = crate::type_engine::call_resolution::with_callable_target_cache();
let _body_infer_guard = self.activate_body_return_inferrer();
let _auth_user_guard = self.activate_auth_user_resolver();
let _validation_rules_guard = self.activate_validation_rules_resolver();

// ── Phase 2: forward-walked diagnostic scope cache ──────
// Walk every function/method body in the file once with the
Expand Down
1 change: 1 addition & 0 deletions src/hover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ impl Backend {
pub fn handle_hover(&self, uri: &str, content: &str, position: Position) -> Option<Hover> {
let _body_infer_guard = self.activate_body_return_inferrer();
let _auth_user_guard = self.activate_auth_user_resolver();
let _validation_rules_guard = self.activate_validation_rules_resolver();
let offset = crate::text_position::position_to_offset(content, position);

// Try the exact cursor offset first.
Expand Down
4 changes: 3 additions & 1 deletion src/type_engine/call_resolution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,6 @@ mod target_cache;
mod template_subs;

pub(crate) use return_types::MethodReturnCtx;
pub(crate) use target_cache::{try_infer_body_return_type, with_callable_target_cache};
pub(crate) use target_cache::{
VALIDATION_RULES_RESOLVER, try_infer_body_return_type, with_callable_target_cache,
};
50 changes: 50 additions & 0 deletions src/type_engine/call_resolution/return_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ fn resolve_auth_user_at_call(
}
}

/// The array shape a Laravel `validated()` / `validate()` /
/// `safe()->only()` call returns, given the rules in scope at the call site.
///
/// Returns `None` for every other call, leaving the declared return type
/// alone.
fn resolve_validated_shape_at_call(
base: &SubjectExpr,
method_name: &str,
text_args: &str,
owners: &[ResolvedType],
ctx: &ResolutionCtx<'_>,
) -> Option<PhpType> {
use crate::virtual_members::laravel::validated_shape::{self, ShapeCall};

let call = validated_shape::shape_bearing_method(method_name)?;
let receiver = owners.iter().find_map(|rt| rt.class_info.as_ref())?;
// Tracing the `safe()` hop resolves another expression, so only do it for
// the narrowing calls that need it.
let safe_source = matches!(call, ShapeCall::Only | ShapeCall::Except)
.then(|| validated_shape::safe_source_class(base, ctx))
.flatten();

validated_shape::resolve_shape_at_call(
receiver,
call,
&split_text_args(text_args),
safe_source.as_deref(),
ctx.content,
ctx.cursor_offset,
ctx.class_loader,
)
}

/// Recover the guard name from a `user()` call site.
///
/// The guard name may be an explicit argument to `user()` itself
Expand Down Expand Up @@ -292,6 +325,23 @@ impl Backend {
return classes;
}

// Laravel validated input: `validated()`, `validate([…])`
// and `safe()->only([…])` return the array shape the
// validation rules in scope describe. An array shape has
// no class, so it travels as the type hint alone.
if let Some(shape) = resolve_validated_shape_at_call(
base,
method_name,
text_args,
&lhs_resolved,
ctx,
) {
if let Some(ref mut hint_out) = return_type_hint_out {
**hint_out = Some(shape);
}
return Vec::new();
}

// Capture the raw return type hint while we iterate
// the owner classes below. We grab it from the first
// owner that has a matching method — before the return
Expand Down
Loading
Loading