From 53dadf20e6629df20e79b69b8685918f74c779d7 Mon Sep 17 00:00:00 2001 From: Pieter Willekens <3403851+pataar@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:58:20 +0100 Subject: [PATCH] Resolve self parameters in traits against the required class A parameter typed `self` inside a trait resolved to the trait alone, so members of the class named by @phpstan-require-extends or -implements were reported as unknown on it. PHP flattens the trait into each class using it, so the parameter holds one of those classes, just like $this. It now gets the same bounds $this does. Laravel model traits hit this in every model event closure: static::saving(static function (self $model) { ... }). --- docs/CHANGELOG.md | 1 + .../variable/forward_walk/param_seeding.rs | 23 +++++++++- .../diagnostics_unknown_members.rs | 46 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d1165bcd1..8bcfccf51 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -249,6 +249,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Type inference +- **`self` parameters in traits.** A parameter typed `self` inside a trait now sees the class its `@phpstan-require-extends` or `-implements` names, as `$this` already did, so a model trait's `static::saving(static function (self $model) { $model->getTable(); })` no longer reports `getTable()` as unknown. - **Constants passed to calls.** Namespaced and imported constants keep their values and types when used as arguments. Contributed by @shuvroroy. - **Constants defined from namespaced or imported constants.** A class constant or namespaced constant such as `const MAX = LIMIT;` now holds the value of the constant its own file means, whether imported with `use const`, declared in its namespace, or global, when it is read from another file. A parameter default that names a constant is read the same way when the argument is left out. - **Constants holding a class name or an enum case.** A global or namespaced constant such as `const DEFAULT = Widget::class;` or `const SUIT = Suit::Hearts;` now has that type, including when it is passed to a templated function or used as a parameter default. diff --git a/src/type_engine/variable/forward_walk/param_seeding.rs b/src/type_engine/variable/forward_walk/param_seeding.rs index d70a6a186..e55b66920 100644 --- a/src/type_engine/variable/forward_walk/param_seeding.rs +++ b/src/type_engine/variable/forward_walk/param_seeding.rs @@ -10,7 +10,7 @@ use mago_span::HasSpan; use crate::atom::bytes_to_str; use crate::parser::extract_hint_type; use crate::php_type::PhpType; -use crate::types::{MethodInfo, ResolvedType}; +use crate::types::{ClassInfo, MethodInfo, ResolvedType}; /// What seeding a function-like body needs to know about its declaration. #[derive(Clone, Copy)] @@ -794,12 +794,33 @@ pub(crate) fn declared_param_type( } } + // A `self` parameter in a trait holds a class using the trait, so it gets `$this`'s bounds. + if is_the_current_trait(¶m_results, ctx.current_class) { + crate::type_engine::trait_context::extend_this_with_trait_bounds( + &mut param_results, + ctx.current_class, + ctx.file.all_classes, + ctx.file.class_loader, + ctx.file.backend, + ); + } + DeclaredParam { effective: effective_type, results: param_results, } } +/// Whether a parameter's resolved type is exactly the trait being analysed, +/// which is what a `self` hint inside a trait resolves to. +fn is_the_current_trait(param_results: &[ResolvedType], current_class: &ClassInfo) -> bool { + current_class.kind == crate::types::ClassLikeKind::Trait + && matches!( + param_results, + [only] if only.class_info.as_ref().is_some_and(|ci| ci.fqn() == current_class.fqn()) + ) +} + /// Turn a variadic parameter's element types into the array it receives. /// /// Named arguments land in it under their names, so its keys are diff --git a/tests/integration/diagnostics_unknown_members.rs b/tests/integration/diagnostics_unknown_members.rs index 9e6ca5331..01ff66981 100644 --- a/tests/integration/diagnostics_unknown_members.rs +++ b/tests/integration/diagnostics_unknown_members.rs @@ -8313,6 +8313,52 @@ trait MyTrait { ); } +/// A parameter typed `self` inside a trait is an instance of a class using +/// the trait, so it sees the `@phpstan-require-extends` class's members the +/// way `$this` does, in a method and in a closure (Laravel model events). +#[test] +fn self_typed_parameter_inside_trait_sees_required_class() { + let backend = create_test_backend(); + let uri = "file:///test.php"; + let text = r#"getTable(); + $model->notOnModel(); + }); + } + + public static function viaParameter(self $model): string { + return $model->getTable(); + } +} + +class Post extends Model { + use Stamps; +} +"#; + let diags = unknown_member_diagnostics(&backend, uri, text); + let messages: Vec = diags + .iter() + .map(|d| message_text(&d.message).to_string()) + .collect(); + assert!( + !messages.iter().any(|m| m.contains("getTable")), + "getTable() comes from the required class, got: {messages:?}" + ); + assert!( + messages.iter().any(|m| m.contains("notOnModel")), + "a member neither the trait nor the required class has must still be flagged, got: {messages:?}" + ); +} + /// `$this->` and `static::` inside a closure nested within a trait /// method should be suppressed just like direct trait method bodies. #[test]