From d66ad8eb198688081c16df20b962555fc7b5b058 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 1 Oct 2026 08:53:28 +0000 Subject: [PATCH 1/3] Docs: Add PHPStan templates and generics. Adds templates to the functions that return the value they are passed, such as `sanitize_term()` and `sanitize_category()`, and gives `urlencode_deep()`, `rawurlencode_deep()` and `urldecode_deep()` a conditional return type based on `map_deep()`'s. `WP_Widget` becomes generic in the shape of its instance settings, so that a widget can declare its settings with `@extends WP_Widget<...>`. The old instance and the form instance can be empty, the new instance is the raw input, and `update()` can return `false`. The widget registration functions accept `class-string`, and `WP_Widget_Factory::register()` ignores the argument count error that follows, since every widget class declares its own constructor. `get_term_to_edit()` now documents the `WP_Term` it returns, rather than `int` or `null`, which it never returns. See #65817. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VieGtSQtUoVfHiGwTmkvtc --- src/wp-includes/category.php | 4 ++++ src/wp-includes/class-wp-widget-factory.php | 5 +++++ src/wp-includes/class-wp-widget.php | 10 ++++++++++ src/wp-includes/formatting.php | 12 ++++++++++++ src/wp-includes/taxonomy.php | 6 +++++- src/wp-includes/widgets.php | 2 ++ tests/phpstan/baselines/argument.type.neon | 2 +- tests/phpstan/baselines/isset.property.neon | 2 +- tests/phpstan/baselines/property.nonObject.neon | 10 ---------- tests/phpstan/baselines/return.unusedType.neon | 10 ---------- 10 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/category.php b/src/wp-includes/category.php index e6085418e255d..1e8d9a79ff780 100644 --- a/src/wp-includes/category.php +++ b/src/wp-includes/category.php @@ -291,6 +291,10 @@ function cat_is_ancestor_of( $cat1, $cat2 ) { * @param object|array $category Category data. * @param string $context Optional. Default 'display'. * @return object|array Same type as $category with sanitized data for safe use. + * + * @phpstan-template T of array|object + * @phpstan-param T $category + * @phpstan-return T */ function sanitize_category( $category, $context = 'display' ) { return sanitize_term( $category, 'category', $context ); diff --git a/src/wp-includes/class-wp-widget-factory.php b/src/wp-includes/class-wp-widget-factory.php index 1f6ed3a58919e..f7009788549e4 100644 --- a/src/wp-includes/class-wp-widget-factory.php +++ b/src/wp-includes/class-wp-widget-factory.php @@ -69,11 +69,14 @@ public function WP_Widget_Factory() { * @since 7.1.1 The key for an instance is prefixed so that it is never cast to an integer. * * @param string|WP_Widget $widget Either the name of a `WP_Widget` subclass or an instance of a `WP_Widget` subclass. + * + * @phpstan-param class-string|WP_Widget $widget */ public function register( $widget ) { if ( $widget instanceof WP_Widget ) { $this->widgets[ self::INSTANCE_KEY_PREFIX . spl_object_id( $widget ) ] = $widget; } else { + // @phpstan-ignore arguments.count (Widget classes declare their own constructor, which takes no arguments.) $this->widgets[ $widget ] = new $widget(); } } @@ -87,6 +90,8 @@ public function register( $widget ) { * @since 7.1.1 The key for an instance is prefixed so that it is never cast to an integer. * * @param string|WP_Widget $widget Either the name of a `WP_Widget` subclass or an instance of a `WP_Widget` subclass. + * + * @phpstan-param class-string|WP_Widget $widget */ public function unregister( $widget ) { if ( $widget instanceof WP_Widget ) { diff --git a/src/wp-includes/class-wp-widget.php b/src/wp-includes/class-wp-widget.php index b131c50db3226..1f854f4b70b53 100644 --- a/src/wp-includes/class-wp-widget.php +++ b/src/wp-includes/class-wp-widget.php @@ -16,6 +16,8 @@ * * @since 2.8.0 * @since 4.4.0 Moved to its own file from wp-includes/widgets.php + * + * @phpstan-template T of array = array */ #[AllowDynamicProperties] class WP_Widget { @@ -109,6 +111,8 @@ class WP_Widget { * @param array $args Display arguments including 'before_title', 'after_title', * 'before_widget', and 'after_widget'. * @param array $instance The settings for the particular instance of the widget. + * + * @phpstan-param T $instance */ public function widget( $args, $instance ) { die( 'function WP_Widget::widget() must be overridden in a subclass.' ); @@ -127,6 +131,10 @@ public function widget( $args, $instance ) { * WP_Widget::form(). * @param array $old_instance Old settings for this instance. * @return array Settings to save or bool false to cancel saving. + * + * @phpstan-param array $new_instance + * @phpstan-param T|array{} $old_instance + * @phpstan-return array|false */ public function update( $new_instance, $old_instance ) { return $new_instance; @@ -140,6 +148,8 @@ public function update( $new_instance, $old_instance ) { * @param array $instance The settings for the particular instance of the widget. * @return string|void Default return is 'noform'. A subclass which echoes its own * form returns nothing. + * + * @phpstan-param T|array{} $instance */ public function form( $instance ) { echo '

' . __( 'There are no options for this widget.' ) . '

'; diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index c39beaf7b1d68..3a18046999864 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2926,6 +2926,10 @@ function stripslashes_from_strings_only( $value ) { * * @param mixed $value The array or string to be encoded. * @return mixed The encoded value. + * + * @phpstan-template T + * @phpstan-param T $value + * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) */ function urlencode_deep( $value ) { return map_deep( $value, 'urlencode' ); @@ -2938,6 +2942,10 @@ function urlencode_deep( $value ) { * * @param mixed $value The array or string to be encoded. * @return mixed The encoded value. + * + * @phpstan-template T + * @phpstan-param T $value + * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) */ function rawurlencode_deep( $value ) { return map_deep( $value, 'rawurlencode' ); @@ -2950,6 +2958,10 @@ function rawurlencode_deep( $value ) { * * @param mixed $value The array or string to be decoded. * @return mixed The decoded value. + * + * @phpstan-template T + * @phpstan-param T $value + * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) */ function urldecode_deep( $value ) { return map_deep( $value, 'urldecode' ); diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 1159ca64e6d2d..6c225b229409c 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -1267,7 +1267,7 @@ function get_term_field( $field, $term, $taxonomy = '', $context = 'display' ) { * * @param int|object $id Term ID or object. * @param string $taxonomy Taxonomy name. - * @return string|int|null|WP_Error Will return empty string if $term is not an object. + * @return WP_Term|string|WP_Error Sanitized term, an empty string if `$id` is not a term, or WP_Error on failure. */ function get_term_to_edit( $id, $taxonomy ) { $term = get_term( $id, $taxonomy ); @@ -1786,6 +1786,10 @@ function term_is_ancestor_of( $term1, $term2, $taxonomy ) { * Accepts 'raw', 'edit', 'db', 'display', 'rss', * 'attribute', or 'js'. Default 'display'. * @return array|object Term with all fields sanitized. + * + * @phpstan-template T of array|object + * @phpstan-param T $term + * @phpstan-return T */ function sanitize_term( $term, $taxonomy, $context = 'display' ) { $fields = array( 'term_id', 'name', 'description', 'slug', 'count', 'parent', 'term_group', 'term_taxonomy_id', 'object_id' ); diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php index 5e679f4b92ca9..88a1cbc7ce90e 100644 --- a/src/wp-includes/widgets.php +++ b/src/wp-includes/widgets.php @@ -116,6 +116,8 @@ * @global WP_Widget_Factory $wp_widget_factory * * @param string|WP_Widget $widget Either the name of a `WP_Widget` subclass or an instance of a `WP_Widget` subclass. + * + * @phpstan-param class-string|WP_Widget $widget */ function register_widget( $widget ) { global $wp_widget_factory; diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 9697035667cac..649549ead5798 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -839,7 +839,7 @@ parameters: count: 1 path: ../../../src/wp-includes/class-wp-user.php - - message: '#^Parameter \#1 \$number of method WP_Widget\:\:_set\(\) expects int, string given\.$#' + message: '#^Parameter \#1 \$number of method WP_Widget\\>\:\:_set\(\) expects int, string given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-widget.php diff --git a/tests/phpstan/baselines/isset.property.neon b/tests/phpstan/baselines/isset.property.neon index f5e272b598f34..fa7b3d126e71d 100644 --- a/tests/phpstan/baselines/isset.property.neon +++ b/tests/phpstan/baselines/isset.property.neon @@ -144,7 +144,7 @@ parameters: count: 1 path: ../../../src/wp-includes/class-wp-user.php - - message: '#^Property WP_Widget\:\:\$alt_option_name \(string\) in isset\(\) is not nullable\.$#' + message: '#^Property WP_Widget\\>\:\:\$alt_option_name \(string\) in isset\(\) is not nullable\.$#' identifier: isset.property count: 1 path: ../../../src/wp-includes/class-wp-widget.php diff --git a/tests/phpstan/baselines/property.nonObject.neon b/tests/phpstan/baselines/property.nonObject.neon index 895c9e3656d3d..4c8e2bfea6150 100644 --- a/tests/phpstan/baselines/property.nonObject.neon +++ b/tests/phpstan/baselines/property.nonObject.neon @@ -73,16 +73,6 @@ parameters: identifier: property.nonObject count: 1 path: ../../../src/wp-admin/includes/class-wp-plugin-install-list-table.php - - - message: '#^Cannot access property \$parent on array\|object\.$#' - identifier: property.nonObject - count: 1 - path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php - - - message: '#^Cannot access property \$term_id on array\|object\.$#' - identifier: property.nonObject - count: 2 - path: ../../../src/wp-admin/includes/class-wp-terms-list-table.php - message: '#^Cannot access property \$info on array\|object\.$#' identifier: property.nonObject diff --git a/tests/phpstan/baselines/return.unusedType.neon b/tests/phpstan/baselines/return.unusedType.neon index 2acebf529a781..a1a27541a6f7c 100644 --- a/tests/phpstan/baselines/return.unusedType.neon +++ b/tests/phpstan/baselines/return.unusedType.neon @@ -78,16 +78,6 @@ parameters: identifier: return.unusedType count: 1 path: ../../../src/wp-includes/revision.php - - - message: '#^Function get_term_to_edit\(\) never returns int so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: ../../../src/wp-includes/taxonomy.php - - - message: '#^Function get_term_to_edit\(\) never returns null so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: ../../../src/wp-includes/taxonomy.php - message: '#^Function wp_is_password_reset_allowed_for_user\(\) never returns WP_Error so it can be removed from the return type\.$#' identifier: return.unusedType From d2e5e8424c3e39c42cc488900783dc2d12ff1f65 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 5 Oct 2026 14:26:39 +0000 Subject: [PATCH 2/3] Docs: Stop claiming sanitize_term() returns its input type unchanged. Both sanitize_term() and sanitize_category() rewrite field values and add a filter key, so returning the template type T was unsound for array shapes. Replace the template with a conditional return type that keeps WP_Term, and otherwise returns array or object. Also add the class-string|WP_Widget parameter type to unregister_widget(), matching register_widget(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015BuoHZQvJMvPEYp4Sfjgvd --- src/wp-includes/category.php | 4 +--- src/wp-includes/taxonomy.php | 4 +--- src/wp-includes/widgets.php | 2 ++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/category.php b/src/wp-includes/category.php index 1e8d9a79ff780..38fe4e6adba65 100644 --- a/src/wp-includes/category.php +++ b/src/wp-includes/category.php @@ -292,9 +292,7 @@ function cat_is_ancestor_of( $cat1, $cat2 ) { * @param string $context Optional. Default 'display'. * @return object|array Same type as $category with sanitized data for safe use. * - * @phpstan-template T of array|object - * @phpstan-param T $category - * @phpstan-return T + * @phpstan-return ($category is WP_Term ? WP_Term : ($category is array ? array : object)) */ function sanitize_category( $category, $context = 'display' ) { return sanitize_term( $category, 'category', $context ); diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 6c225b229409c..0a2f6434fcd18 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -1787,9 +1787,7 @@ function term_is_ancestor_of( $term1, $term2, $taxonomy ) { * 'attribute', or 'js'. Default 'display'. * @return array|object Term with all fields sanitized. * - * @phpstan-template T of array|object - * @phpstan-param T $term - * @phpstan-return T + * @phpstan-return ($term is WP_Term ? WP_Term : ($term is array ? array : object)) */ function sanitize_term( $term, $taxonomy, $context = 'display' ) { $fields = array( 'term_id', 'name', 'description', 'slug', 'count', 'parent', 'term_group', 'term_taxonomy_id', 'object_id' ); diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php index 88a1cbc7ce90e..5b38cb1cab2ac 100644 --- a/src/wp-includes/widgets.php +++ b/src/wp-includes/widgets.php @@ -140,6 +140,8 @@ function register_widget( $widget ) { * @global WP_Widget_Factory $wp_widget_factory * * @param string|WP_Widget $widget Either the name of a `WP_Widget` subclass or an instance of a `WP_Widget` subclass. + * + * @phpstan-param class-string|WP_Widget $widget */ function unregister_widget( $widget ) { global $wp_widget_factory; From 10a0f3006080ef875fa40b28dae1458c238ef746 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:24:57 +0000 Subject: [PATCH 3/3] Docs: Drop the template from the `*_deep()` URL encoding functions. `map_deep()` modifies an object's properties in place, so `urlencode_deep()`, `rawurlencode_deep()` and `urldecode_deep()` do not necessarily return the object type they are given. They now return `object` for an object, and use a conditional return type on `$value` rather than a template, which was only left carrying the array keys and added nothing to the inferred types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL6dYS5ydouQDBQmjem93A --- src/wp-includes/formatting.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 3a18046999864..f1970349d5bcd 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2927,9 +2927,7 @@ function stripslashes_from_strings_only( $value ) { * @param mixed $value The array or string to be encoded. * @return mixed The encoded value. * - * @phpstan-template T - * @phpstan-param T $value - * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) + * @phpstan-return ($value is array ? array : ($value is object ? object : string)) */ function urlencode_deep( $value ) { return map_deep( $value, 'urlencode' ); @@ -2943,9 +2941,7 @@ function urlencode_deep( $value ) { * @param mixed $value The array or string to be encoded. * @return mixed The encoded value. * - * @phpstan-template T - * @phpstan-param T $value - * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) + * @phpstan-return ($value is array ? array : ($value is object ? object : string)) */ function rawurlencode_deep( $value ) { return map_deep( $value, 'rawurlencode' ); @@ -2959,9 +2955,7 @@ function rawurlencode_deep( $value ) { * @param mixed $value The array or string to be decoded. * @return mixed The decoded value. * - * @phpstan-template T - * @phpstan-param T $value - * @phpstan-return (T is array ? array, mixed> : (T is object ? T : string)) + * @phpstan-return ($value is array ? array : ($value is object ? object : string)) */ function urldecode_deep( $value ) { return map_deep( $value, 'urldecode' );