From 1ab0236b89a136997a37b04ac3c0f5eed7412e47 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 12:24:52 -0700 Subject: [PATCH 1/7] Add non-decimal-int-string to return type for _wp_filter_build_unique_id() --- src/wp-includes/plugin.php | 2 ++ tests/phpstan/baselines/return.type.neon | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index f64b584374c8e..2002a7485f2af 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -998,6 +998,8 @@ function _wp_call_all_hook( $args ) { * @param int $priority Unused. The order in which the functions * associated with a particular action are executed. * @return string|null Unique function ID for usage as array key, or null if it couldn't be determined. + * + * @phpstan-return non-decimal-int-string|null */ function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string { if ( is_string( $callback ) ) { diff --git a/tests/phpstan/baselines/return.type.neon b/tests/phpstan/baselines/return.type.neon index 9d99e94d0abd6..2dbee1a6a832a 100644 --- a/tests/phpstan/baselines/return.type.neon +++ b/tests/phpstan/baselines/return.type.neon @@ -108,6 +108,11 @@ parameters: identifier: return.type count: 1 path: ../../../src/wp-includes/meta.php + - + message: '#^Function _wp_filter_build_unique_id\(\) should return non\-decimal\-int\-string\|null but returns decimal\-int\-string\.$#' + identifier: return.type + count: 1 + path: ../../../src/wp-includes/plugin.php - message: '#^Function wp_post_revision_title\(\) should return string\|false but returns null\.$#' identifier: return.type From 4ddaa18baadeb00795466384af7a17b9f9d5f931 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 12:42:43 -0700 Subject: [PATCH 2/7] Assert hook callback IDs are not coerced to integer array keys _wp_filter_build_unique_id() returns the string used as the callback key in WP_Hook::$callbacks. PHP casts an array key from string to int whenever the string is the canonical decimal representation of an integer, so a return value such as "5292" silently changes the type of the keys that consumers of that public property read back. Replace assertIsString() with an assertIsNonDecimalIntString() helper that round-trips the value through an array key, which is the runtime equivalent of PHPStan's non-decimal-int-string type. The closure case fails against trunk, where the ID for a bare object is the return value of spl_object_id() cast to a string. Also add coverage for an invokable object callback, which had none and is the other callback shape stored under a bare object ID, and add the missing @ticket annotations to the tests introduced in r62408. Co-Authored-By: Claude Opus 5 --- tests/phpunit/tests/hooks/buildUniqueId.php | 72 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/hooks/buildUniqueId.php b/tests/phpunit/tests/hooks/buildUniqueId.php index 387a24bbc2fd1..20ef31bdd9942 100644 --- a/tests/phpunit/tests/hooks/buildUniqueId.php +++ b/tests/phpunit/tests/hooks/buildUniqueId.php @@ -8,29 +8,63 @@ */ class Tests_Hooks_BuildUniqueId extends WP_UnitTestCase { + public static function set_up_before_class() { + parent::set_up_before_class(); + + require_once __DIR__ . '/../../includes/mock-invokable.php'; + } + + /** + * @ticket 58291 + * @ticket 65919 + */ public function test_string_callback_returns_string(): void { $result = _wp_filter_build_unique_id( '', '__return_null', 10 ); - $this->assertIsString( $result ); + $this->assertIsNonDecimalIntString( $result ); $this->assertSame( '__return_null', $result ); } + /** + * @ticket 58291 + * @ticket 65919 + */ public function test_closure_returns_string(): void { $cb = function (): void {}; $result = _wp_filter_build_unique_id( '', $cb, 10 ); - $this->assertIsString( $result ); + $this->assertIsNonDecimalIntString( $result ); + } + + /** + * @ticket 58291 + * @ticket 65919 + */ + public function test_invokable_object_returns_string(): void { + $result = _wp_filter_build_unique_id( '', new Mock_Invokable(), 10 ); + $this->assertIsNonDecimalIntString( $result ); } + /** + * @ticket 58291 + * @ticket 65919 + */ public function test_object_callback_returns_string(): void { $a = new MockAction(); $result = _wp_filter_build_unique_id( '', array( $a, 'action' ), 10 ); - $this->assertIsString( $result ); + $this->assertIsNonDecimalIntString( $result ); } + /** + * @ticket 58291 + * @ticket 65919 + */ public function test_static_callback_returns_string(): void { $result = _wp_filter_build_unique_id( '', array( 'MockAction', 'action' ), 10 ); - $this->assertIsString( $result ); + $this->assertIsNonDecimalIntString( $result ); } + /** + * @ticket 58291 + */ public function test_two_different_objects_produce_different_ids(): void { $a = new MockAction(); $b = new MockAction(); @@ -40,6 +74,9 @@ public function test_two_different_objects_produce_different_ids(): void { ); } + /** + * @ticket 58291 + */ public function test_same_object_produces_same_id(): void { $a = new MockAction(); $this->assertSame( @@ -48,15 +85,42 @@ public function test_same_object_produces_same_id(): void { ); } + /** + * @ticket 58291 + */ public function test_malformed_array_missing_method_returns_null(): void { $a = new MockAction(); $result = _wp_filter_build_unique_id( '', array( $a ), 10 ); $this->assertNull( $result ); } + /** + * @ticket 58291 + */ public function test_malformed_array_non_string_method_returns_null(): void { $a = new MockAction(); $result = _wp_filter_build_unique_id( '', array( $a, 123 ), 10 ); $this->assertNull( $result ); } + + /** + * Asserts that a value is a string which PHP does not cast to an integer when used as an array key. + * + * The return value of _wp_filter_build_unique_id() is used as the callback key in WP_Hook::$callbacks. + * PHP silently casts an array key from string to int when the string is the canonical decimal + * representation of an integer, which changes the type of the keys that consumers of that public + * property read back. This is the runtime equivalent of PHPStan's `non-decimal-int-string` type. + * + * @param mixed $value Value to check. + */ + private function assertIsNonDecimalIntString( $value ): void { + $this->assertIsString( $value, 'The unique ID is not a string.' ); + + $array = array( $value => true ); + + $this->assertIsString( + array_key_first( $array ), + sprintf( 'The unique ID "%s" was cast to an integer when used as an array key.', $value ) + ); + } } From 067dbe92b68337fa8db402f408e3a57cc40eaf31 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 12:56:06 -0700 Subject: [PATCH 3/7] Narrow the WP_Hook callback key type to non-decimal-int-string _wp_filter_build_unique_id() declares that it returns a string PHP will not cast to an integer when used as an array key, so the keys actually stored in WP_Hook::$callbacks are narrower than the array these annotations claimed. Stating the real invariant turns the property into a second static analysis guard. Should the unique ID type ever widen back to a plain string, PHPStan reports the assignment in add_filter() from rule level 7 upwards, without the experimental reportUnsafeArrayStringKeyCasting option that was otherwise needed to catch it. Analysing the tree at level 8 yields an identical error set before and after, so the narrowing costs nothing. The Iterator and ArrayAccess type parameters, the offsetGet() and offsetSet() signatures, and the current() and next() return types all move with the property, since every one of them describes the same array. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wp-hook.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/class-wp-hook.php b/src/wp-includes/class-wp-hook.php index 38895c87f1349..3675070106236 100644 --- a/src/wp-includes/class-wp-hook.php +++ b/src/wp-includes/class-wp-hook.php @@ -20,8 +20,8 @@ * accepted_args: int, * } * - * @phpstan-implements Iterator> - * @phpstan-implements ArrayAccess> + * @phpstan-implements Iterator> + * @phpstan-implements ArrayAccess> */ #[AllowDynamicProperties] final class WP_Hook implements Iterator, ArrayAccess { @@ -31,7 +31,7 @@ final class WP_Hook implements Iterator, ArrayAccess { * * @since 4.7.0 * @var array - * @phpstan-var array> + * @phpstan-var array> */ public $callbacks = array(); @@ -502,7 +502,7 @@ public function offsetExists( $offset ) { * * @param int $offset The offset to retrieve. * @return array|null If set, the value at the specified offset, null otherwise. - * @phpstan-return array|null + * @phpstan-return array|null */ #[ReturnTypeWillChange] public function offsetGet( $offset ) { @@ -518,7 +518,7 @@ public function offsetGet( $offset ) { * * @param int|null $offset The offset to assign the value to. * @param array $value The value to set. - * @phpstan-param array $value + * @phpstan-param array $value */ #[ReturnTypeWillChange] public function offsetSet( $offset, $value ) { @@ -554,7 +554,7 @@ public function offsetUnset( $offset ) { * @link https://www.php.net/manual/en/iterator.current.php * * @return array|false Array of callbacks at current priority, false if there are no more elements. - * @phpstan-return array|false + * @phpstan-return array|false */ #[ReturnTypeWillChange] public function current() { @@ -569,7 +569,7 @@ public function current() { * @link https://www.php.net/manual/en/iterator.next.php * * @return array|false Array of callbacks at next priority, false if there are no more elements. - * @phpstan-return array|false + * @phpstan-return array|false */ #[ReturnTypeWillChange] public function next() { From 0e14e020564d6442fbf61368f1219251c7b9c9e5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 13:10:37 -0700 Subject: [PATCH 4/7] Keep the hook callback ID for object callbacks a string Since r62408 the unique ID for a callback that is a bare object has been the return value of spl_object_id() cast to a string, such as "5292". PHP casts an array key from string to int whenever the string is the canonical decimal representation of an integer, so the cast was undone one layer down, at the point of storage: the keys of WP_Hook::$callbacks became integers for closures and __invoke instances, where every earlier release stored the 32 character hex string returned by spl_object_hash(). That property is public and is introspected by caching, profiling and debugging plugins. Any consumer running under strict_types, or passing a key to a parameter declared string, fatals on the integer. Prefix the ID with a non-numeric literal so PHP leaves it a string. This also retires the return.type baseline entry recorded when the narrower return type was first declared. PHPStan still cannot verify the result, since it has no type for a string carrying a known literal prefix and so infers only non-falsy-string. That is reported from rule level 7 upwards and is invisible at the level 5 the project analyses at. Co-Authored-By: Claude Opus 5 --- src/wp-includes/plugin.php | 8 +++++++- tests/phpstan/baselines/return.type.neon | 5 ----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index 2002a7485f2af..407f9fd586f41 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -989,6 +989,7 @@ function _wp_call_all_hook( $args ) { * and no longer returns false, but can still return void for invalid callbacks. * @since 6.9.0 Returns explicit null if an invalid callback is supplied. * @since 7.1.0 Uses spl_object_id() instead of spl_object_hash() for performance. + * @since 7.2.0 The ID for an object callback is prefixed so that it is never cast to an integer array key. * * @access private * @@ -1007,7 +1008,12 @@ function _wp_filter_build_unique_id( $hook_name, $callback, $priority ): ?string } if ( is_object( $callback ) ) { - return (string) spl_object_id( $callback ); + /* + * The prefix keeps the ID from being the decimal representation of an integer. PHP casts such a + * string to int when it is used as an array key, which would change the type of the keys in + * WP_Hook::$callbacks and break consumers that pass them to string functions. + */ + return 'spl_object_id:' . spl_object_id( $callback ); } if ( ! isset( $callback[1] ) || ! is_string( $callback[1] ) ) { diff --git a/tests/phpstan/baselines/return.type.neon b/tests/phpstan/baselines/return.type.neon index 2dbee1a6a832a..9d99e94d0abd6 100644 --- a/tests/phpstan/baselines/return.type.neon +++ b/tests/phpstan/baselines/return.type.neon @@ -108,11 +108,6 @@ parameters: identifier: return.type count: 1 path: ../../../src/wp-includes/meta.php - - - message: '#^Function _wp_filter_build_unique_id\(\) should return non\-decimal\-int\-string\|null but returns decimal\-int\-string\.$#' - identifier: return.type - count: 1 - path: ../../../src/wp-includes/plugin.php - message: '#^Function wp_post_revision_title\(\) should return string\|false but returns null\.$#' identifier: return.type From 026cec7d5c3775dcad102a5a8dac393eb7b1e15a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 13:58:00 -0700 Subject: [PATCH 5/7] Keep the widget factory key for registered instances a string r62408 replaced spl_object_hash() with spl_object_id() in WP_Widget_Factory as well, and there without any cast, so the key for a widget registered as an instance is now an integer where every earlier release stored a 32 character hex string. WP_Widget_Factory::$widgets is public, so this is the same break as the one in WP_Hook. It also violates a contract inside core: get_widget_key() documents that it returns a string, and both the legacy widget block and the widget types REST controller pass what it returns to the_widget(), which documents its first parameter as a string and forwards it to the the_widget action, documented the same way. Nothing fatals in core because the lookups agree on the integer, but any listener declaring a string parameter under strict_types does. Prefix the key so PHP leaves it a string, and give the property a key and value type. The key type is what a bare spl_object_id() value would violate, and the value type resolves several reports of accessing a property or calling a method on mixed: analysing the file at rule level 10 goes from six errors to two. The one remaining report is that the branch registering by class name assigns an object where a WP_Widget is declared, since neither the type system nor WP_Widget itself expresses that a subclass named for registration must have a constructor callable with no arguments. It is reported from rule level 7 upwards and so is invisible at the level 5 the project analyses at. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wp-widget-factory.php | 21 +++++++-- tests/phpunit/tests/widgets.php | 50 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-widget-factory.php b/src/wp-includes/class-wp-widget-factory.php index b6233eabba002..5d889110bf4e1 100644 --- a/src/wp-includes/class-wp-widget-factory.php +++ b/src/wp-includes/class-wp-widget-factory.php @@ -16,11 +16,24 @@ #[AllowDynamicProperties] class WP_Widget_Factory { + /** + * Prefix for the key under which a widget registered as an instance is stored. + * + * Without it the key would be the decimal representation of an integer, which PHP casts from + * string to int when it is used as an array key. + * + * @since 7.2.0 + */ + private const INSTANCE_KEY_PREFIX = 'spl_object_id:'; + /** * Widgets array. * + * Keyed by class name for a widget registered by name, and by a prefixed object ID for a widget + * registered as an instance. + * * @since 2.8.0 - * @var array + * @var array */ public $widgets = array(); @@ -52,12 +65,13 @@ public function WP_Widget_Factory() { * @since 2.8.0 * @since 4.6.0 Updated the `$widget` parameter to also accept a WP_Widget instance object * instead of simply a `WP_Widget` subclass name. + * @since 7.2.0 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. */ public function register( $widget ) { if ( $widget instanceof WP_Widget ) { - $this->widgets[ spl_object_id( $widget ) ] = $widget; + $this->widgets[ self::INSTANCE_KEY_PREFIX . spl_object_id( $widget ) ] = $widget; } else { $this->widgets[ $widget ] = new $widget(); } @@ -69,12 +83,13 @@ public function register( $widget ) { * @since 2.8.0 * @since 4.6.0 Updated the `$widget` parameter to also accept a WP_Widget instance object * instead of simply a `WP_Widget` subclass name. + * @since 7.2.0 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. */ public function unregister( $widget ) { if ( $widget instanceof WP_Widget ) { - unset( $this->widgets[ spl_object_id( $widget ) ] ); + unset( $this->widgets[ self::INSTANCE_KEY_PREFIX . spl_object_id( $widget ) ] ); } else { unset( $this->widgets[ $widget ] ); } diff --git a/tests/phpunit/tests/widgets.php b/tests/phpunit/tests/widgets.php index 8eb5914c9400d..7362146fda7fc 100644 --- a/tests/phpunit/tests/widgets.php +++ b/tests/phpunit/tests/widgets.php @@ -45,6 +45,56 @@ public function test_register_and_unregister_widget_core_widget() { $this->assertArrayNotHasKey( $widget_class, $wp_widget_factory->widgets ); } + /** + * Tests that a widget registered as an instance is keyed by a string. + * + * WP_Widget_Factory::$widgets is public, and its keys were strings in every release before the + * object ID was introduced. PHP casts an array key from string to int whenever the string is the + * canonical decimal representation of an integer, so a bare spl_object_id() value would change + * the type of the keys that consumers of that property read back. + * + * @see register_widget() + * @ticket 65919 + * + * @global WP_Widget_Factory $wp_widget_factory + */ + public function test_register_widget_instance_is_keyed_by_string() { + global $wp_widget_factory; + + register_widget( new WP_Widget_Search() ); + + $this->assertCount( 1, $wp_widget_factory->widgets ); + $this->assertIsString( array_key_first( $wp_widget_factory->widgets ) ); + } + + /** + * Tests that the key returned for a widget registered as an instance is a string. + * + * WP_Widget_Factory::get_widget_key() is documented as returning a string, and core passes what + * it returns to the_widget(), which in turn passes it to the 'the_widget' action, both of which + * document the value as a string. + * + * @see WP_Widget_Factory::get_widget_key() + * @ticket 65919 + * + * @global WP_Widget_Factory $wp_widget_factory + */ + public function test_get_widget_key_for_instance_returns_string() { + global $wp_widget_factory; + + $widget = new WP_Widget_Search(); + $widget->id_base = 'better_search'; + $widget->name = 'Better Search'; + $widget->option_name = 'widget_' . $widget->id_base; + $widget->widget_options['classname'] = 'widget_' . $widget->id_base; + $widget->control_options['id_base'] = $widget->id_base; + + register_widget( $widget ); + + $this->assertIsString( $wp_widget_factory->get_widget_key( 'better_search' ) ); + $this->assertSame( $widget, $wp_widget_factory->get_widget_object( 'better_search' ) ); + } + /** * Test that registering a widget class and registering a widget instance work together. * From d7341e6a975481d1a5a72fe9a9b392423ae6368f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 20 Aug 2026 14:02:24 -0700 Subject: [PATCH 6/7] Describe the prefixed callback keys as arriving in 7.1.1 Both prefixes fix a regression that shipped in 7.1, so the point release is the first version to carry them, not trunk's 7.2.0. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wp-widget-factory.php | 6 +++--- src/wp-includes/plugin.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-widget-factory.php b/src/wp-includes/class-wp-widget-factory.php index 5d889110bf4e1..cd981b32547ad 100644 --- a/src/wp-includes/class-wp-widget-factory.php +++ b/src/wp-includes/class-wp-widget-factory.php @@ -22,7 +22,7 @@ class WP_Widget_Factory { * Without it the key would be the decimal representation of an integer, which PHP casts from * string to int when it is used as an array key. * - * @since 7.2.0 + * @since 7.1.1 */ private const INSTANCE_KEY_PREFIX = 'spl_object_id:'; @@ -65,7 +65,7 @@ public function WP_Widget_Factory() { * @since 2.8.0 * @since 4.6.0 Updated the `$widget` parameter to also accept a WP_Widget instance object * instead of simply a `WP_Widget` subclass name. - * @since 7.2.0 The key for an instance is prefixed so that it is never cast to an integer. + * @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. */ @@ -83,7 +83,7 @@ public function register( $widget ) { * @since 2.8.0 * @since 4.6.0 Updated the `$widget` parameter to also accept a WP_Widget instance object * instead of simply a `WP_Widget` subclass name. - * @since 7.2.0 The key for an instance is prefixed so that it is never cast to an integer. + * @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. */ diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index 407f9fd586f41..717aebb0e5860 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -989,7 +989,7 @@ function _wp_call_all_hook( $args ) { * and no longer returns false, but can still return void for invalid callbacks. * @since 6.9.0 Returns explicit null if an invalid callback is supplied. * @since 7.1.0 Uses spl_object_id() instead of spl_object_hash() for performance. - * @since 7.2.0 The ID for an object callback is prefixed so that it is never cast to an integer array key. + * @since 7.1.1 The ID for an object callback is prefixed so that it is never cast to an integer array key. * * @access private * From 1c9caa714d01a911b9f464276d5bc73c2bc95152 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 21 Aug 2026 08:56:39 -0700 Subject: [PATCH 7/7] Narrow the widget factory key type to non-decimal-int-string WP_Widget_Factory::$widgets was given a plain string key type when the prefix was added, on the reasoning that a plain string already rejects both shapes the regression could take. PHPStan evaluates the two writes inline, typing a bare spl_object_id() as int and a cast one as decimal-int-string, and it models a decimal-int-string key as producing an array, so array catches either. Narrowing further appeared to buy nothing and to cost reports against correct code, since neither the prefixed key nor a class name can be proven non-numeric. That weighed the wrong thing. Neither key type produces a single report below rule level 7, so at the level this project analyses at the choice is invisible, and the one report narrowing adds is the same one already accepted for the identical expression in _wp_filter_build_unique_id(), where PHPStan equally cannot prove that a string carrying a literal prefix is not a decimal integer. Two files disagreeing about the type of a key built the same way is the larger cost, so state the invariant the prefix actually establishes. The narrower type goes in a @phpstan-var tag below the plain @var, the way WP_Hook::$callbacks already carries it, since non-decimal-int-string is not part of the documented PHPDoc vocabulary. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wp-widget-factory.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/class-wp-widget-factory.php b/src/wp-includes/class-wp-widget-factory.php index cd981b32547ad..1f6ed3a58919e 100644 --- a/src/wp-includes/class-wp-widget-factory.php +++ b/src/wp-includes/class-wp-widget-factory.php @@ -34,6 +34,7 @@ class WP_Widget_Factory { * * @since 2.8.0 * @var array + * @phpstan-var array */ public $widgets = array();