@@ -608,12 +608,12 @@ fn try_resolve_in_function(
608608 & mut results,
609609 ) ;
610610
611- // ── Substitute method -level template params in
612- // `class-string<T>` for standalone function parameters ──────
613- // Same logic as in `resolve_variable_in_members`: when the
614- // resolved parameter type is `class-string<T> ` and `T` is a
615- // function-level template with an upper bound, replace `T`
616- // with the bound so that `$class::` resolves static members .
611+ // ── Substitute function -level template params with their
612+ // bounds for standalone function parameters ─────────── ──────
613+ // When the resolved parameter type is `class-string<T>` or a
614+ // bare template name `T ` and `T` is a function-level template
615+ // with an upper bound, replace `T` with the bound so that
616+ // member access resolves against the bound type .
617617 let func_start = func. span ( ) . start . offset as usize ;
618618 for rt in results. iter_mut ( ) {
619619 if matches ! ( & rt. type_string, PhpType :: ClassString ( Some ( inner) ) if matches!( inner. as_ref( ) , PhpType :: Named ( _) ) )
@@ -624,6 +624,12 @@ fn try_resolve_in_function(
624624 func_start,
625625 ) ;
626626 }
627+ // Bare template param → bound (e.g. `T` → `Builder|QueryBuilder`).
628+ rt. type_string = substitute_template_param_bounds (
629+ rt. type_string . clone ( ) ,
630+ ctx. content ,
631+ func_start,
632+ ) ;
627633 }
628634
629635 walk_statements_for_assignments ( func. body . statements . iter ( ) , & body_ctx, & mut results, false ) ;
@@ -792,6 +798,22 @@ fn resolve_variable_in_members<'b>(
792798 raw_docblock_type. as_deref ( ) ,
793799 ) ;
794800
801+ // ── Substitute method-level template params with
802+ // their bounds before class resolution ──────────
803+ // When the effective type contains a bare template
804+ // parameter name (e.g. `T` from `@template T of
805+ // Builder|QueryBuilder`), replace it with the bound
806+ // type so that `$query->where(...)` resolves members
807+ // on `Builder|QueryBuilder` instead of failing with
808+ // "subject type 'T' could not be resolved".
809+ let effective_type = effective_type. map ( |ty| {
810+ substitute_template_param_bounds (
811+ ty,
812+ ctx. content ,
813+ method. span ( ) . start . offset as usize ,
814+ )
815+ } ) ;
816+
795817 let resolved_from_effective = effective_type
796818 . as_ref ( )
797819 . map ( |ty| {
@@ -998,6 +1020,102 @@ fn resolve_variable_in_members<'b>(
9981020/// is being unconditionally reassigned). When `conditional` is `true`,
9991021/// a new assignment **adds** to the list (the variable *might* be this
10001022/// type).
1023+
1024+ /// Substitute method/function-level template parameter names with their
1025+ /// upper bounds from `@template T of Bound` annotations.
1026+ ///
1027+ /// This handles the general case where a parameter type IS a template
1028+ /// parameter (e.g. `@param T $query` where `@template T of Builder`).
1029+ /// Without this substitution, `T` remains an unresolvable named type
1030+ /// and member access on `$query` fails with "subject type 'T' could not
1031+ /// be resolved".
1032+ ///
1033+ /// Works on any `PhpType` structure — bare names, unions, intersections,
1034+ /// nullable wrappers, generics, etc. — via `PhpType::substitute`.
1035+ fn substitute_template_param_bounds (
1036+ ty : PhpType ,
1037+ content : & str ,
1038+ method_start_offset : usize ,
1039+ ) -> PhpType {
1040+ // Quick check: only act when the type contains at least one bare
1041+ // identifier that could be a template parameter. This avoids the
1042+ // docblock parse for the common case where the type is a concrete
1043+ // class name or scalar.
1044+ if !type_may_contain_template_param ( & ty) {
1045+ return ty;
1046+ }
1047+
1048+ let before = & content[ ..method_start_offset] ;
1049+ let docblock = extract_preceding_docblock ( before) ;
1050+
1051+ let Some ( docblock) = docblock else {
1052+ return ty;
1053+ } ;
1054+
1055+ let bounds = docblock:: extract_template_params_with_bounds ( docblock) ;
1056+ if bounds. is_empty ( ) {
1057+ return ty;
1058+ }
1059+
1060+ let mut subs = std:: collections:: HashMap :: new ( ) ;
1061+ for ( name, bound) in bounds {
1062+ if let Some ( bound_str) = bound {
1063+ subs. insert ( name, PhpType :: parse ( & bound_str) ) ;
1064+ }
1065+ }
1066+
1067+ if subs. is_empty ( ) {
1068+ return ty;
1069+ }
1070+
1071+ ty. substitute ( & subs)
1072+ }
1073+
1074+ /// Check whether a `PhpType` tree may contain a bare template parameter
1075+ /// name — i.e. a `Named` variant whose value is not a well-known scalar
1076+ /// or pseudo-type. This is a cheap pre-filter so that we only parse the
1077+ /// docblock when there is a realistic chance of finding a substitution.
1078+ fn type_may_contain_template_param ( ty : & PhpType ) -> bool {
1079+ match ty {
1080+ PhpType :: Named ( name) => {
1081+ // Well-known scalars/pseudo-types are never template params.
1082+ !matches ! (
1083+ name. as_str( ) ,
1084+ "int"
1085+ | "float"
1086+ | "string"
1087+ | "bool"
1088+ | "null"
1089+ | "void"
1090+ | "never"
1091+ | "mixed"
1092+ | "object"
1093+ | "array"
1094+ | "iterable"
1095+ | "callable"
1096+ | "resource"
1097+ | "true"
1098+ | "false"
1099+ | "self"
1100+ | "static"
1101+ | "$this"
1102+ | "parent"
1103+ | "class-string"
1104+ )
1105+ }
1106+ PhpType :: Union ( members) | PhpType :: Intersection ( members) => {
1107+ members. iter ( ) . any ( type_may_contain_template_param)
1108+ }
1109+ PhpType :: Nullable ( inner) => type_may_contain_template_param ( inner) ,
1110+ PhpType :: Generic ( base, args) => {
1111+ // Check if the base itself could be a template param, or any arg.
1112+ type_may_contain_template_param ( & PhpType :: Named ( base. clone ( ) ) )
1113+ || args. iter ( ) . any ( type_may_contain_template_param)
1114+ }
1115+ _ => false ,
1116+ }
1117+ }
1118+
10011119/// Substitute method-level template parameters inside `class-string<T>`
10021120/// types with their upper bounds from `@template T of Bound` annotations.
10031121///
0 commit comments