From c47cf86e251b6e8568dadb0995eb17e4e90eda41 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 17:34:28 -0700 Subject: [PATCH 01/21] Improve typing for comments Add PHPStan conditional return types, array shapes, and narrowed property/parameter types across the comment APIs. * Define a `CommentArray` shape alias for `WP_Comment::to_array()`, describing the full key set of a comment row. The shape is open (trailing `...`) because `WP_Comment` is `#[AllowDynamicProperties]`, so sealing it would produce false "offset does not exist" errors. Integer-ID string fields (`comment_ID`, `comment_post_ID`, `comment_karma`, `comment_parent`, `user_id`) come from integer DB columns and default to '0', so they are typed as `numeric-string`, and the NOT NULL DATETIME fields as `non-empty-string`. Fields that only look enumerable but are extensible by plugins (`comment_type`, `comment_approved`) are intentionally left as `string` so the shape does not over-narrow and mask real values. * Add conditional return types for `get_comment()` and `get_comments()` so callers get the right type per `$output` / `$args`. `get_comment()` returns null when the comment cannot be resolved regardless of the output format, so `|null` is included in every branch. The aliases are class-scoped, so the free functions cannot reference `CommentArray` in their conditional returns; once phpstan/phpstan#9164 lands they can adopt it as well. * Make `WP_Comment::get_children()` return type conditional on the 'format' arg: 'flat' returns a flattened list re-indexed by `array_merge()`, while 'tree' returns the `WP_Comment` map keyed by comment ID. Annotate `$_args` with the shape resulting from merging `$args` over `$defaults`, since PHPStan cannot infer `wp_parse_args()` merge semantics, and narrow the `get_comments()` result to the map expected by the `$children` property (with the known fields/count caveat noted in a TODO). * Spell out the key type as `int|numeric-string` wherever comment arrays are keyed by comment ID. PHPStan already normalizes numeric-string array keys this way, since PHP casts canonical numeric-string keys to integers at runtime, so this makes the runtime reality explicit rather than implying string keys that will not actually exist. Document on `WP_Comment::$children` that the ID map only holds for the default 'threaded' hierarchical argument, and that a 'flat' or false override stores a sequentially-keyed array instead. * Fix latent issues surfaced by the analysis: `get_comment()` now only calls `WP_Comment::get_instance()` for numeric input instead of passing through arbitrary values; the `__get()` magic getter always returns a value; and `__isset()` casts `$comment_post_ID` to int and guards against a null `get_post()` result before dereferencing it. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment-query.php | 4 +- src/wp-includes/class-wp-comment.php | 64 +++++++++++++++++++--- src/wp-includes/comment.php | 16 +++++- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index cfabfd7e6b964..960da676f6b1a 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -359,7 +359,8 @@ public function parse_query( $query = '' ) { * @since 4.2.0 Moved parsing to WP_Comment_Query::parse_query(). * * @param string|array $query Array or URL query string of parameters. - * @return array|int List of comments, or number of comments when 'count' is passed as a query var. + * @return WP_Comment[]|int[]|int List of comments, or number of comments when 'count' is passed as a query var. + * @phpstan-return array|WP_Comment[]|int[]|non-negative-int */ public function query( $query ) { $this->query_vars = wp_parse_args( $query ); @@ -374,6 +375,7 @@ public function query( $query ) { * @global wpdb $wpdb WordPress database abstraction object. * * @return int|int[]|WP_Comment[] List of comments or number of found comments if `$count` argument is true. + * @phpstan-return array|WP_Comment[]|int[]|non-negative-int */ public function get_comments() { global $wpdb; diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index b1beea1e34883..f60cfbe00744d 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -11,6 +11,25 @@ * Core class used to organize comments as instantiated objects with defined members. * * @since 4.4.0 + * + * @phpstan-type CommentArray array{ + * comment_ID: numeric-string, + * comment_post_ID: numeric-string, + * comment_author: string, + * comment_author_email: string, + * comment_author_url: string, + * comment_author_IP: string, + * comment_date: non-empty-string, + * comment_date_gmt: non-empty-string, + * comment_content: string, + * comment_karma: numeric-string, + * comment_approved: string, + * comment_agent: string, + * comment_type: string, + * comment_parent: numeric-string, + * user_id: numeric-string, + * ... + * } */ #[AllowDynamicProperties] final class WP_Comment { @@ -74,6 +93,7 @@ final class WP_Comment { * * @since 4.4.0 * @var string + * @phpstan-var non-empty-string */ public $comment_date = '0000-00-00 00:00:00'; @@ -82,6 +102,7 @@ final class WP_Comment { * * @since 4.4.0 * @var string + * @phpstan-var non-empty-string */ public $comment_date_gmt = '0000-00-00 00:00:00'; @@ -154,8 +175,14 @@ final class WP_Comment { /** * Comment children. * + * Mapping of comment ID to WP_Comment object, as populated by the default + * `hierarchical => 'threaded'` argument of get_children(). Note that if a + * caller passes a `hierarchical` value of 'flat' or `false` to + * get_children(), a sequentially-keyed array of WP_Comment objects (also + * including all descendants, in the 'flat' case) is stored here instead. + * * @since 4.4.0 - * @var array + * @var array */ protected $children; @@ -171,7 +198,7 @@ final class WP_Comment { * Post fields. * * @since 4.4.0 - * @var array + * @var string[] */ protected $post_fields = array( 'post_author', 'post_date', 'post_date_gmt', 'post_content', 'post_title', 'post_excerpt', 'post_status', 'comment_status', 'ping_status', 'post_name', 'to_ping', 'pinged', 'post_modified', 'post_modified_gmt', 'post_content_filtered', 'post_parent', 'guid', 'menu_order', 'post_type', 'post_mime_type', 'comment_count' ); @@ -183,6 +210,7 @@ final class WP_Comment { * @global wpdb $wpdb WordPress database abstraction object. * * @param int $id Comment ID. + * @phpstan-param int|numeric-string $id * @return WP_Comment|false Comment object, otherwise false. */ public static function get_instance( $id ) { @@ -195,7 +223,8 @@ public static function get_instance( $id ) { $_comment = wp_cache_get( $comment_id, 'comment' ); - if ( ! $_comment ) { + if ( ! is_object( $_comment ) ) { + /** @var object{ comment_ID: string, comment_post_ID: string, comment_author: string, comment_author_email: string, comment_author_url: string, comment_author_IP: string, comment_date: string, comment_date_gmt: string, comment_content: string, comment_karma: string, comment_approved: string, comment_agent: string, comment_type: string, comment_parent: string, user_id: string }|null $_comment */ $_comment = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->comments WHERE comment_ID = %d LIMIT 1", $comment_id ) ); if ( ! $_comment ) { @@ -229,9 +258,12 @@ public function __construct( $comment ) { * @since 4.4.0 * * @return array Object as array. + * @phpstan-return CommentArray */ public function to_array() { - return get_object_vars( $this ); + /** @var CommentArray $comment */ + $comment = get_object_vars( $this ); + return $comment; } /** @@ -268,7 +300,16 @@ public function to_array() { * `$meta_query`. Also accepts false, an empty array, or * 'none' to disable `ORDER BY` clause. * } - * @return WP_Comment[] Array of `WP_Comment` objects. + * @return array Array of `WP_Comment` objects. + * + * @phpstan-param array{ + * format?: 'tree'|'flat', + * status?: 'hold'|'approve'|'all'|string, + * hierarchical?: 'threaded'|'flat'|false, + * orderby?: string|string[]|false, + * ... + * } $args + * @phpstan-return ($args is array{ format: 'flat', ... } ? list : array) */ public function get_children( $args = array() ) { $defaults = array( @@ -278,6 +319,7 @@ public function get_children( $args = array() ) { 'orderby' => '', ); + /** @var array{ format: 'tree'|'flat', status: string, hierarchical: 'threaded'|'flat'|false, orderby: string|string[]|false, ... } $_args */ $_args = wp_parse_args( $args, $defaults ); $_args['parent'] = $this->comment_ID; @@ -285,7 +327,10 @@ public function get_children( $args = array() ) { if ( $this->populated_children ) { $this->children = array(); } else { - $this->children = get_comments( $_args ); + // TODO: If $args contains `fields => 'ids'` or `count => true` then this breaks. + /** @var array $child_comments */ + $child_comments = get_comments( $_args ); + $this->children = $child_comments; } } @@ -357,8 +402,8 @@ public function populated_children( $set ) { */ public function __isset( $name ) { if ( in_array( $name, $this->post_fields, true ) && 0 !== (int) $this->comment_post_ID ) { - $post = get_post( $this->comment_post_ID ); - return property_exists( $post, $name ); + $post = get_post( (int) $this->comment_post_ID ); + return $post && property_exists( $post, $name ); } return false; @@ -376,8 +421,9 @@ public function __isset( $name ) { */ public function __get( $name ) { if ( in_array( $name, $this->post_fields, true ) ) { - $post = get_post( $this->comment_post_ID ); + $post = get_post( (int) $this->comment_post_ID ); return $post->$name; } + return null; } } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 20303dc1e7e17..a9cd07567e2ae 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -217,6 +217,10 @@ function get_approved_comments( $post_id, $args = array() ) { * correspond to a WP_Comment object, an associative array, or a numeric array, * respectively. Default OBJECT. * @return WP_Comment|array|null Depends on $output value. + * @phpstan-return ( + * $output is 'ARRAY_A' ? non-empty-array|null + * : ( $output is 'ARRAY_N' ? non-empty-array|null + * : WP_Comment|null ) ) */ function get_comment( $comment = null, $output = OBJECT ) { if ( empty( $comment ) && isset( $GLOBALS['comment'] ) ) { @@ -227,8 +231,10 @@ function get_comment( $comment = null, $output = OBJECT ) { $_comment = $comment; } elseif ( is_object( $comment ) ) { $_comment = new WP_Comment( $comment ); + } elseif ( is_numeric( $comment ) ) { + $_comment = WP_Comment::get_instance( (int) $comment ); } else { - $_comment = WP_Comment::get_instance( $comment ); + $_comment = null; } if ( ! $_comment ) { @@ -267,6 +273,13 @@ function get_comment( $comment = null, $output = OBJECT ) { * @param string|array $args Optional. Array or string of arguments. See WP_Comment_Query::__construct() * for information on accepted arguments. Default empty string. * @return WP_Comment[]|int[]|int List of comments or number of found comments if `$count` argument is true. + * @phpstan-return ( + * $args is array{ count: true, ... } ? non-negative-int : ( + * $args is array{ fields: 'ids', ... } ? int[] : ( + * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] + * ) + * ) + * ) */ function get_comments( $args = '' ) { $query = new WP_Comment_Query(); @@ -519,6 +532,7 @@ function delete_comment_meta( $comment_id, $meta_key, $meta_value = '' ) { * - true values are returned as '1' * - numbers are returned as strings * Arrays and objects retain their original type. + * @phpstan-param int|numeric-string $comment_id */ function get_comment_meta( $comment_id, $key = '', $single = false ) { return get_metadata( 'comment', $comment_id, $key, $single ); From 663fb32f88f03ff97840524875d2e03b3b6bdeaa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 18:14:06 -0700 Subject: [PATCH 02/21] Rename CommentArray to Data_Array --- src/wp-includes/class-wp-comment.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index f60cfbe00744d..efdd665a7d11c 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -12,7 +12,7 @@ * * @since 4.4.0 * - * @phpstan-type CommentArray array{ + * @phpstan-type Data_Array array{ * comment_ID: numeric-string, * comment_post_ID: numeric-string, * comment_author: string, @@ -258,10 +258,10 @@ public function __construct( $comment ) { * @since 4.4.0 * * @return array Object as array. - * @phpstan-return CommentArray + * @phpstan-return Data_Array */ public function to_array() { - /** @var CommentArray $comment */ + /** @var Data_Array $comment */ $comment = get_object_vars( $this ); return $comment; } From 47bc8590d3dda8b41d6c8f5b1790b97af69b1e07 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:21:19 -0700 Subject: [PATCH 03/21] Add conditional return type to get_approved_comments() Annotate `get_approved_comments()` with a PHPStan conditional return type so static analysis can resolve the concrete result at each call site, mirroring the treatment already given to `get_comments()`. The outermost condition is keyed on `$post_id` rather than `$args`, because the function returns early with an empty array whenever `$post_id` is falsey - before `$args` is ever parsed. That case applies to every mode, including `count`, where it is the only branch whose declared type would not otherwise admit an empty array. The remaining conditions narrow on `count`, `fields`, and `hierarchical` in the same order as `get_comments()`. The non-prefixed `@return` tag is left as the less-specific `WP_Comment[]|int[]|int`, with its description extended to document the empty-array case. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/comment.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index a9cd07567e2ae..3026968f95f99 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -183,7 +183,17 @@ function check_comment( $author, $email, $url, $comment, $user_ip, $user_agent, * @type string $order How to order retrieved comments. Default 'ASC'. * } * @return WP_Comment[]|int[]|int The approved comments, or number of comments if `$count` - * argument is true. + * argument is true. An empty array is returned when `$post_id` + * is falsey, even when `$count` is true. + * @phpstan-return ( + * $post_id is 0 ? array{} : ( + * $args is array{ count: true, ... } ? non-negative-int : ( + * $args is array{ fields: 'ids', ... } ? non-negative-int[] : ( + * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] + * ) + * ) + * ) + * ) */ function get_approved_comments( $post_id, $args = array() ) { if ( ! $post_id ) { From 6d6207752e4b44e5869f0660981f130cbc922867 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:29:51 -0700 Subject: [PATCH 04/21] Correct comment array key types and narrow comment ID types Replace the `array` key type used across the comment APIs with `array`. A `numeric-string` array key is not representable in PHP: keys that are numeric strings are coerced to integers on assignment, so the union could never describe a real array. Both sites that build these arrays - `WP_Comment::add_child()` and `WP_Comment_Query::fill_descendants()` - key on `comment_ID`, which is a numeric string, and therefore always produce integer keys. `WP_Comment::add_child()` now casts `comment_ID` to `int` explicitly when indexing, so the key type is established by the code rather than asserted by the annotation. This is behavior-preserving, since PHP already performed the same coercion implicitly. Comment ID arrays are narrowed from `int[]` to `non-negative-int[]`, and `WP_Comment_Query::get_comment_ids()` gains a `@phpstan-return` of `non-negative-int|list`, reflecting that `comment_ID` is an unsigned column. `list` is used only for `get_comment_ids()`, whose result is built directly from `wpdb::get_col()` with no filter in between; the filterable public entry points keep the weaker array types, since `comments_pre_query` and `the_comments` can return arbitrarily keyed arrays. Also correct `WP_Comment::$children`, which is null until `get_children()` populates it, and add a `void` return type to `WP_Comment::add_child()`. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment-query.php | 6 ++++-- src/wp-includes/class-wp-comment.php | 12 +++++++----- src/wp-includes/comment.php | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 960da676f6b1a..1930302f3e14b 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -95,6 +95,7 @@ class WP_Comment_Query { * * @since 4.0.0 * @var int[]|WP_Comment[] + * @phpstan-var non-negative-int[]|WP_Comment[] */ public $comments; @@ -360,7 +361,7 @@ public function parse_query( $query = '' ) { * * @param string|array $query Array or URL query string of parameters. * @return WP_Comment[]|int[]|int List of comments, or number of comments when 'count' is passed as a query var. - * @phpstan-return array|WP_Comment[]|int[]|non-negative-int + * @phpstan-return array|non-negative-int[]|non-negative-int */ public function query( $query ) { $this->query_vars = wp_parse_args( $query ); @@ -375,7 +376,7 @@ public function query( $query ) { * @global wpdb $wpdb WordPress database abstraction object. * * @return int|int[]|WP_Comment[] List of comments or number of found comments if `$count` argument is true. - * @phpstan-return array|WP_Comment[]|int[]|non-negative-int + * @phpstan-return array|non-negative-int[]|non-negative-int */ public function get_comments() { global $wpdb; @@ -543,6 +544,7 @@ public function get_comments() { * @global wpdb $wpdb WordPress database abstraction object. * * @return int|array A single count of comment IDs if a count query. An array of comment IDs if a full query. + * @phpstan-return non-negative-int|list */ protected function get_comment_ids() { global $wpdb; diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index efdd665a7d11c..452100ad82203 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -181,8 +181,10 @@ final class WP_Comment { * get_children(), a sequentially-keyed array of WP_Comment objects (also * including all descendants, in the 'flat' case) is stored here instead. * + * Null until populated by {@see WP_Comment::get_children()}. + * * @since 4.4.0 - * @var array + * @var array|null */ protected $children; @@ -309,7 +311,7 @@ public function to_array() { * orderby?: string|string[]|false, * ... * } $args - * @phpstan-return ($args is array{ format: 'flat', ... } ? list : array) + * @phpstan-return ($args is array{ format: 'flat', ... } ? list : array) */ public function get_children( $args = array() ) { $defaults = array( @@ -328,7 +330,7 @@ public function get_children( $args = array() ) { $this->children = array(); } else { // TODO: If $args contains `fields => 'ids'` or `count => true` then this breaks. - /** @var array $child_comments */ + /** @var array $child_comments */ $child_comments = get_comments( $_args ); $this->children = $child_comments; } @@ -360,8 +362,8 @@ public function get_children( $args = array() ) { * * @param WP_Comment $child Child comment. */ - public function add_child( WP_Comment $child ) { - $this->children[ $child->comment_ID ] = $child; + public function add_child( WP_Comment $child ): void { + $this->children[ (int) $child->comment_ID ] = $child; } /** diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 3026968f95f99..cd54040749bd1 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -285,8 +285,8 @@ function get_comment( $comment = null, $output = OBJECT ) { * @return WP_Comment[]|int[]|int List of comments or number of found comments if `$count` argument is true. * @phpstan-return ( * $args is array{ count: true, ... } ? non-negative-int : ( - * $args is array{ fields: 'ids', ... } ? int[] : ( - * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] + * $args is array{ fields: 'ids', ... } ? non-negative-int[] : ( + * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] * ) * ) * ) From ccb51f143e00dd5023345b43a3790f1ade5b09a5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:45:09 -0700 Subject: [PATCH 05/21] Document comment status and type values, narrow comment_approved Enumerate the values core actually uses for `WP_Comment::$comment_approved` and `WP_Comment::$comment_type`, and type the former as `non-empty-string`. `comment_approved` is backed by a `varchar(20) NOT NULL default '1'` column and is never assigned an empty string, so `non-empty-string` holds. Its documented values now include 'post-trashed', which `wp_trash_post()` writes to every comment on a post being trashed, and which `wp_count_comments()` reports as a distinct bucket. `comment_type` gains the 'pingback' and 'trackback' values, which predate 'note' and were previously undocumented here. It is deliberately left as a plain `string` rather than narrowed alongside `comment_approved`: although the column has defaulted to 'comment' since 5.5.0, comments created before then may still store an empty string, which is why `get_comment_type()` normalizes that case on read. A note records this so the type is not tightened later. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 452100ad82203..35c95ef86317f 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -23,7 +23,7 @@ * comment_date_gmt: non-empty-string, * comment_content: string, * comment_karma: numeric-string, - * comment_approved: string, + * comment_approved: non-empty-string, * comment_agent: string, * comment_type: string, * comment_parent: numeric-string, @@ -128,8 +128,12 @@ final class WP_Comment { /** * Comment approval status. * + * The values used in core are '0' (unapproved), '1' (approved), 'spam', 'trash', + * and 'post-trashed' (set for every comment on a post that is moved to the trash). + * * @since 4.4.0 * @var string + * @phpstan-var non-empty-string */ public $comment_approved = '1'; @@ -144,6 +148,13 @@ final class WP_Comment { /** * Comment type. * + * The values used in core are 'comment', 'pingback', 'trackback', and 'note'. Custom + * comment types are possible. + * + * Comments created before 5.5.0 may store an empty string rather than 'comment', so this + * cannot be relied upon to be non-empty. {@see get_comment_type()} normalizes that case + * when reading. + * * @since 4.4.0 * @since 5.5.0 Default value changed to `comment`. * @var string From d57727aaeb03b3d6cafd5b1147257283a3f37a53 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:45:33 -0700 Subject: [PATCH 06/21] Define post_fields as list --- src/wp-includes/class-wp-comment.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 35c95ef86317f..5bedd2c8c6028 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -212,6 +212,7 @@ final class WP_Comment { * * @since 4.4.0 * @var string[] + * @phpstan-var list */ protected $post_fields = array( 'post_author', 'post_date', 'post_date_gmt', 'post_content', 'post_title', 'post_excerpt', 'post_status', 'comment_status', 'ping_status', 'post_name', 'to_ping', 'pinged', 'post_modified', 'post_modified_gmt', 'post_content_filtered', 'post_parent', 'guid', 'menu_order', 'post_type', 'post_mime_type', 'comment_count' ); From 4c9230472ac87d38dd0b41f104d6bb868dcaaa7d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:45:54 -0700 Subject: [PATCH 07/21] Provide narrower non-PHPStan types --- src/wp-includes/class-wp-comment.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 5bedd2c8c6028..9ffb23fff1d55 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -271,7 +271,7 @@ public function __construct( $comment ) { * * @since 4.4.0 * - * @return array Object as array. + * @return array Object as array. * @phpstan-return Data_Array */ public function to_array() { @@ -314,7 +314,7 @@ public function to_array() { * `$meta_query`. Also accepts false, an empty array, or * 'none' to disable `ORDER BY` clause. * } - * @return array Array of `WP_Comment` objects. + * @return WP_Comment[] Array of `WP_Comment` objects. * * @phpstan-param array{ * format?: 'tree'|'flat', From f9db5f5e92debadb4b2674adaa55a2535c5f544f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:46:09 -0700 Subject: [PATCH 08/21] Add native return types --- src/wp-includes/class-wp-comment.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 9ffb23fff1d55..d5d2c3fbad1ee 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -274,7 +274,7 @@ public function __construct( $comment ) { * @return array Object as array. * @phpstan-return Data_Array */ - public function to_array() { + public function to_array(): array { /** @var Data_Array $comment */ $comment = get_object_vars( $this ); return $comment; @@ -325,7 +325,7 @@ public function to_array() { * } $args * @phpstan-return ($args is array{ format: 'flat', ... } ? list : array) */ - public function get_children( $args = array() ) { + public function get_children( $args = array() ): array { $defaults = array( 'format' => 'tree', 'status' => 'all', @@ -400,7 +400,7 @@ public function get_child( $child_id ) { * * @param bool $set Whether the comment's children have already been populated. */ - public function populated_children( $set ) { + public function populated_children( $set ): void { $this->populated_children = (bool) $set; } From a297331af8fb1d1035872d94d44fd75c3550acdc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:46:24 -0700 Subject: [PATCH 09/21] Provide enum type for $output param --- src/wp-includes/comment.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index cd54040749bd1..f347e76e15457 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -227,6 +227,7 @@ function get_approved_comments( $post_id, $args = array() ) { * correspond to a WP_Comment object, an associative array, or a numeric array, * respectively. Default OBJECT. * @return WP_Comment|array|null Depends on $output value. + * @phpstan-param 'OBJECT'|'ARRAY_A'|'ARRAY_N' $output * @phpstan-return ( * $output is 'ARRAY_A' ? non-empty-array|null * : ( $output is 'ARRAY_N' ? non-empty-array|null From 30e377aef80b5470168bf5aa12023e843a9b626e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:49:11 -0700 Subject: [PATCH 10/21] Guard against fatal error in WP_Comment magic getter when underlying post is gone --- src/wp-includes/class-wp-comment.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index d5d2c3fbad1ee..5d60236a3dc4d 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -436,6 +436,9 @@ public function __isset( $name ) { public function __get( $name ) { if ( in_array( $name, $this->post_fields, true ) ) { $post = get_post( (int) $this->comment_post_ID ); + if ( ! $post ) { + return null; + } return $post->$name; } return null; From e14a04d3319d440f168d525d68127cadc07b43da Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 19 Jul 2026 19:54:21 -0700 Subject: [PATCH 11/21] Declare the post fields proxied by WP_Comment::__get() `WP_Comment::__get()` proxies the 21 field names listed in `$post_fields` to the comment's post, but none of them were declared on the class, so they were invisible to static analysis, IDE completion, and the generated documentation. Add a `@property-read` for each, typed to match the corresponding `WP_Post` property: `post_author` is `numeric-string|''`, the always-populated `post_status`, `comment_status`, `ping_status`, and `post_type` slugs are `non-empty-string`, `post_parent` is `non-negative-int`, `comment_count` is `numeric-string`, and `menu_order` stays a plain `int` since it can be negative. Every entry is nullable, because the getter returns null when the comment's post no longer exists. `ID` and `post_password` are deliberately absent: they are `WP_Post` properties but are not in `$post_fields`, so the getter does not proxy them. The `-read` variant is used because there is no corresponding `__set()`. Assigning to one of these names creates a real dynamic property that shadows the getter for the rest of that object's lifetime, so they cannot be treated as writable. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 5d60236a3dc4d..c45b35ee478b7 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -10,8 +10,33 @@ /** * Core class used to organize comments as instantiated objects with defined members. * + * The `@property-read` fields below are not stored on the comment. They are proxied to the + * comment's post by {@see WP_Comment::__get()}, and are null when that post no longer exists. + * * @since 4.4.0 * + * @property-read numeric-string|''|null $post_author + * @property-read string|null $post_date + * @property-read string|null $post_date_gmt + * @property-read string|null $post_content + * @property-read string|null $post_title + * @property-read string|null $post_excerpt + * @property-read non-empty-string|null $post_status + * @property-read non-empty-string|null $comment_status + * @property-read non-empty-string|null $ping_status + * @property-read string|null $post_name + * @property-read string|null $to_ping + * @property-read string|null $pinged + * @property-read string|null $post_modified + * @property-read string|null $post_modified_gmt + * @property-read string|null $post_content_filtered + * @property-read non-negative-int|null $post_parent + * @property-read string|null $guid + * @property-read int|null $menu_order + * @property-read non-empty-string|null $post_type + * @property-read string|null $post_mime_type + * @property-read numeric-string|null $comment_count + * * @phpstan-type Data_Array array{ * comment_ID: numeric-string, * comment_post_ID: numeric-string, From d07e16450ff52ad7aeb16eab3c0676bbbdbb86eb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 00:29:16 -0700 Subject: [PATCH 12/21] Restore non-array return modes of WP_Comment::get_children() Remove the native `array` return type added to `WP_Comment::get_children()`, which made the method fatal whenever it was asked for a count. `get_comments()` returns an integer for a `count` query and a list of comment IDs for a `fields` query, and `get_children()` passes both straight through, so an unconditional `array` return type does not describe it. Core relies on this. `WP_REST_Comments_Controller::prepare_links()` calls the method with `count => true` to test cheaply whether a comment has children, and `wp_delete_comment()` and `wp_trash_comment()` call it with `fields => 'ids'` when removing the children of a top-level note. The first of these raised `TypeError: WP_Comment::get_children(): Return value must be of type array, int returned`, failing 102 of the REST comment tests. The `count` and `fields` queries now return their result directly rather than storing it in the object's children cache. That cache holds `WP_Comment` objects and is read back by `add_child()`, `get_child()`, and the 'flat' format, so writing an integer or a list of IDs into it left the object in a state where a later call returned the wrong thing. The recursive call that flattens descendants sets `count` and `fields` explicitly, since that path always merges objects. Document every argument core or the wider ecosystem passes: `fields`, `count`, `type`, `number`, `post_id`, and `order`, alongside a note that any other `WP_Comment_Query` argument is forwarded and that `parent` is always overridden. Several of these were already in use but undocumented. The `@phpstan-return` type is now conditional on `count`, then `fields`, then `format`, so each mode resolves at the call site. Follow-up to f9db5f5e92. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 55 +++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index c45b35ee478b7..444df3792812a 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -311,7 +311,11 @@ public function to_array(): array { * @since 4.4.0 * * @param array $args { - * Array of arguments used to pass to get_comments() and determine format. + * Array of arguments used to pass to {@see get_comments()} and determine format. + * Any other argument accepted by {@see WP_Comment_Query::__construct()} may also be passed, and is + * forwarded to `get_comments()`. Note that `parent` is always overridden with this comment's ID. + * A `$count` or `$fields` query returns the direct children only, and does not populate the + * comment's cached children, since that cache holds `WP_Comment` objects. * * @type string $format Return value format. 'tree' for a hierarchical tree, 'flat' for a flattened array. * Default 'tree'. @@ -338,19 +342,44 @@ public function to_array(): array { * the value of $meta_key, and the array keys of * `$meta_query`. Also accepts false, an empty array, or * 'none' to disable `ORDER BY` clause. + * @type string $fields Which fields to return. Accepts 'ids' for comment IDs, or an + * empty string for full `WP_Comment` objects. Default empty. + * @type bool $count Whether to return a comment count rather than comments. + * Default false. + * @type string $type Limit results to comments of a given type, such as 'comment', + * 'pingback', 'trackback', or 'note'. Accepts 'all' for every + * type. Default empty. + * @type int $number Maximum number of comments to retrieve. Default empty (no limit). + * @type int $post_id Limit results to comments on a given post. Default 0. + * @type string $order How to order retrieved comments. Accepts 'ASC' or 'DESC'. + * Default 'DESC'. * } - * @return WP_Comment[] Array of `WP_Comment` objects. + * @return WP_Comment[]|int[]|int Array of `WP_Comment` objects, an array of comment IDs when + * `$fields` is 'ids', or the number of children when `$count` + * is true. * * @phpstan-param array{ * format?: 'tree'|'flat', * status?: 'hold'|'approve'|'all'|string, * hierarchical?: 'threaded'|'flat'|false, * orderby?: string|string[]|false, + * fields?: 'ids'|'', + * count?: bool, + * type?: string, + * number?: int, + * post_id?: int, + * order?: 'ASC'|'DESC', * ... * } $args - * @phpstan-return ($args is array{ format: 'flat', ... } ? list : array) + * @phpstan-return ( + * $args is array{ count: true, ... } ? non-negative-int : ( + * $args is array{ fields: 'ids', ... } ? non-negative-int[] : ( + * $args is array{ format: 'flat', ... } ? list : array + * ) + * ) + * ) */ - public function get_children( $args = array() ): array { + public function get_children( $args = array() ) { $defaults = array( 'format' => 'tree', 'status' => 'all', @@ -358,15 +387,26 @@ public function get_children( $args = array() ): array { 'orderby' => '', ); - /** @var array{ format: 'tree'|'flat', status: string, hierarchical: 'threaded'|'flat'|false, orderby: string|string[]|false, ... } $_args */ + /** @var array{ format: 'tree'|'flat', status: string, hierarchical: 'threaded'|'flat'|false, orderby: string|string[]|false, fields?: 'ids'|'', count?: bool, type?: string, number?: int, post_id?: int, order?: 'ASC'|'DESC', ... } $_args */ $_args = wp_parse_args( $args, $defaults ); $_args['parent'] = $this->comment_ID; + /* + * A 'count' or 'ids' query returns an integer or a list of comment IDs rather than + * WP_Comment objects. Neither may be written to the children cache, which holds + * WP_Comment objects and is read back by add_child(), get_child(), and the 'flat' + * format below. Return the result directly and leave the cache untouched. + */ + if ( ! empty( $_args['count'] ) || ( isset( $_args['fields'] ) && 'ids' === $_args['fields'] ) ) { + /** @var non-negative-int|non-negative-int[] $child_result */ + $child_result = get_comments( $_args ); + return $child_result; + } + if ( is_null( $this->children ) ) { if ( $this->populated_children ) { $this->children = array(); } else { - // TODO: If $args contains `fields => 'ids'` or `count => true` then this breaks. /** @var array $child_comments */ $child_comments = get_comments( $_args ); $this->children = $child_comments; @@ -378,6 +418,9 @@ public function get_children( $args = array() ): array { foreach ( $this->children as $child ) { $child_args = $_args; $child_args['format'] = 'flat'; + // Descendants are merged in as objects, so never as a count or as IDs. These are provided for typing. + $child_args['count'] = false; + $child_args['fields'] = ''; // get_children() resets this value automatically. unset( $child_args['parent'] ); From a56429e829adef9171a68c857060ecc377a68d19 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 00:39:48 -0700 Subject: [PATCH 13/21] Infer the count and ids return types of WP_Comment::get_children() Split the guard that short-circuits `count` and `fields` queries into two branches so that each is narrowed independently, and drop the inline `@var` that was previously asserting the result type. The combined condition could not be narrowed: because the two disjuncts are only provable together, the argument shape never resolved to `count: true` or to `fields: 'ids'`, and the conditional return type of `get_comments()` fell back to its full union, `array|non-negative-int`. The inline `@var` hid that by overriding the inferred type rather than establishing it, which left the branch looking verified when it was not. With the branches separated, the count query is inferred as `non-negative-int` and the `fields` query as `non-negative-int[]|non-negative-int`. `WP_Comment` is eliminated from both, which is what the guard exists to guarantee, so the annotation is no longer needed. The duplicated `return` is what makes this work, and is commented as such: folding the two branches back into a single condition reinstates the wider union. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 444df3792812a..d108ea6b431e6 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -396,11 +396,13 @@ public function get_children( $args = array() ) { * WP_Comment objects. Neither may be written to the children cache, which holds * WP_Comment objects and is read back by add_child(), get_child(), and the 'flat' * format below. Return the result directly and leave the cache untouched. + * The two return statements are used for the sake of static analysis with conditional + * return types. */ - if ( ! empty( $_args['count'] ) || ( isset( $_args['fields'] ) && 'ids' === $_args['fields'] ) ) { - /** @var non-negative-int|non-negative-int[] $child_result */ - $child_result = get_comments( $_args ); - return $child_result; + if ( ! empty( $_args['count'] ) ) { + return get_comments( $_args ); + } elseif ( isset( $_args['fields'] ) && 'ids' === $_args['fields'] ) { + return get_comments( $_args ); } if ( is_null( $this->children ) ) { From 0c8e95f802cedeba5c1dfdb71504935767bf1504 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 00:49:16 -0700 Subject: [PATCH 14/21] Resolve the ids return type of WP_Comment::get_children() exactly Set `count` to false before running an 'ids' query, so that the conditional return type of `get_comments()` resolves to `non-negative-int[]` rather than to `non-negative-int[]|non-negative-int`. The `$args` shape is open, since callers may pass any argument accepted by `WP_Comment_Query`. An open shape can always carry keys beyond the ones it names, so the absence of `count` cannot be established: neither the enclosing `elseif` nor an `unset()` is enough to rule out the `count` arm of the conditional. Assigning `false` states the same thing positively, which does contradict `count: true`, and the count arm drops out. This is a no-op at runtime, as `WP_Comment_Query` returns comment IDs before it consults `count`. Note in the comment that the two branches must not be merged. They carry identical return statements and invite being folded together, but each is narrowed on its own, and overwriting `count` is only correct in the branch where a count was not asked for. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index d108ea6b431e6..5d54883ac1aa8 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -395,13 +395,14 @@ public function get_children( $args = array() ) { * A 'count' or 'ids' query returns an integer or a list of comment IDs rather than * WP_Comment objects. Neither may be written to the children cache, which holds * WP_Comment objects and is read back by add_child(), get_child(), and the 'flat' - * format below. Return the result directly and leave the cache untouched. - * The two return statements are used for the sake of static analysis with conditional - * return types. + * format below. Return the result directly and leave the cache untouched. The two + * branches must stay separate: each is narrowed independently, and `count` is only + * safe to overwrite in the 'ids' branch. */ if ( ! empty( $_args['count'] ) ) { return get_comments( $_args ); } elseif ( isset( $_args['fields'] ) && 'ids' === $_args['fields'] ) { + $_args['count'] = false; // For static analysis of the conditional return type. return get_comments( $_args ); } From 7675b4aa6bd885434dfeee608cea8294a480f48e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 01:02:04 -0700 Subject: [PATCH 15/21] Infer the cached children type in WP_Comment::get_children() Replace the inline `@var` that typed the result of the children query with the same positive assignment used by the 'ids' branch, and correct the key type that made the annotation necessary in the first place. `get_comments()` declared its non-threaded result as `WP_Comment[]`, which admits string keys, while `WP_Comment::$children` holds `array`. That mismatch, together with the `count` and `fields` arms of the conditional that an open argument shape can never rule out, is what the annotation was suppressing. Every path in `WP_Comment_Query::get_comments()` produces integer keys: a threaded query keys by `comment_ID`, a flat query returns the assembled list of comments and their descendants, and a non-hierarchical query maps over a list of IDs. Declaring the branch as `array` makes the `hierarchical` test resolve to the same type either way, so it collapses. The same conditional in `get_approved_comments()` is updated to match. Setting `count` and `fields` explicitly once, after the guard, then covers the rest. This also makes the per-iteration assignments in the 'flat' loop redundant, and leaves `$child_comments` with nothing to do, since it existed only to carry the annotation. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment.php | 11 +++++------ src/wp-includes/comment.php | 8 ++------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 5d54883ac1aa8..b93426f2bb0e4 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -406,13 +406,15 @@ public function get_children( $args = array() ) { return get_comments( $_args ); } + // Only WP_Comment objects are returned past this point. Stated positively for static analysis. + $_args['count'] = false; + $_args['fields'] = ''; + if ( is_null( $this->children ) ) { if ( $this->populated_children ) { $this->children = array(); } else { - /** @var array $child_comments */ - $child_comments = get_comments( $_args ); - $this->children = $child_comments; + $this->children = get_comments( $_args ); } } @@ -421,9 +423,6 @@ public function get_children( $args = array() ) { foreach ( $this->children as $child ) { $child_args = $_args; $child_args['format'] = 'flat'; - // Descendants are merged in as objects, so never as a count or as IDs. These are provided for typing. - $child_args['count'] = false; - $child_args['fields'] = ''; // get_children() resets this value automatically. unset( $child_args['parent'] ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index f347e76e15457..833ea157a03a1 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -188,9 +188,7 @@ function check_comment( $author, $email, $url, $comment, $user_ip, $user_agent, * @phpstan-return ( * $post_id is 0 ? array{} : ( * $args is array{ count: true, ... } ? non-negative-int : ( - * $args is array{ fields: 'ids', ... } ? non-negative-int[] : ( - * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] - * ) + * $args is array{ fields: 'ids', ... } ? non-negative-int[] : array * ) * ) * ) @@ -286,9 +284,7 @@ function get_comment( $comment = null, $output = OBJECT ) { * @return WP_Comment[]|int[]|int List of comments or number of found comments if `$count` argument is true. * @phpstan-return ( * $args is array{ count: true, ... } ? non-negative-int : ( - * $args is array{ fields: 'ids', ... } ? non-negative-int[] : ( - * $args is array{ hierarchical: 'threaded', ... } ? array : WP_Comment[] - * ) + * $args is array{ fields: 'ids', ... } ? non-negative-int[] : array * ) * ) */ From a108420d94cc56a28e9c5d29cb2f5570ac9bcf2e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 01:18:02 -0700 Subject: [PATCH 16/21] Narrow the WP_Comment_Query result properties Correct `WP_Comment_Query::$comments`, which has no default value and so is null until a query has been run. `WP_Comment_Query::__construct()` only runs a query when it is given one, so a bare `new WP_Comment_Query()` leaves the property unset, as `get_approved_comments()` and `get_comments()` both rely on before calling `query()` separately. Its value type is also narrowed to `array`, matching the key type now declared by `get_comments()`. `$found_comments` and `$max_num_pages` become `non-negative-int`. The first is `FOUND_ROWS()`, a row count, and the second is `ceil()` of that count over a positive page size, so neither can be negative. The two `non-negative-int` types are not provable from the assignments, which cast with `(int)` rather than `absint()`, and each adds one error inside `WP_Comment_Query::get_comments()`. Those join the existing errors of the same kind in that method, all of which stem from the same two causes: `(int)` not expressing what the schema guarantees, and the comment ID cache being read back as `mixed`. They are left for a follow-up that addresses both together. Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/class-wp-comment-query.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 1930302f3e14b..f80864d31c8bc 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -93,9 +93,11 @@ class WP_Comment_Query { /** * List of comments located by the query. * + * Null until a query has been run. + * * @since 4.0.0 - * @var int[]|WP_Comment[] - * @phpstan-var non-negative-int[]|WP_Comment[] + * @var int[]|WP_Comment[]|null + * @phpstan-var non-negative-int[]|array|null */ public $comments; @@ -104,6 +106,7 @@ class WP_Comment_Query { * * @since 4.4.0 * @var int + * @phpstan-var non-negative-int */ public $found_comments = 0; @@ -112,6 +115,7 @@ class WP_Comment_Query { * * @since 4.4.0 * @var int + * @phpstan-var non-negative-int */ public $max_num_pages = 0; From 8ea8ddabfe7bc9461e4a3e0168ef6855e63d62b4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 16:35:02 -0700 Subject: [PATCH 17/21] Improve consistentcy of conditional return formatting --- src/wp-includes/comment.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 833ea157a03a1..3e9c8ba7c05a2 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -227,9 +227,10 @@ function get_approved_comments( $post_id, $args = array() ) { * @return WP_Comment|array|null Depends on $output value. * @phpstan-param 'OBJECT'|'ARRAY_A'|'ARRAY_N' $output * @phpstan-return ( - * $output is 'ARRAY_A' ? non-empty-array|null - * : ( $output is 'ARRAY_N' ? non-empty-array|null - * : WP_Comment|null ) ) + * $output is 'ARRAY_A' ? non-empty-array|null : ( + * $output is 'ARRAY_N' ? non-empty-array|null : WP_Comment|null + * ) + * ) */ function get_comment( $comment = null, $output = OBJECT ) { if ( empty( $comment ) && isset( $GLOBALS['comment'] ) ) { From d91310d37fd27a5dc4b4551ff29db76f8948ea5b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:12:26 -0700 Subject: [PATCH 18/21] Guard WP_Comment::__get() against unattached comments Align the magic getter with __isset(), which already requires a non-zero comment_post_ID: get_post() falls back to the global post when given an empty ID, so reading a post field on an unattached comment returned an unrelated post's value. Now null is returned, matching the contract documented for the proxied post fields. Also add @since 7.1.0 entries to both magic methods covering this and the earlier hardening against the comment's post no longer existing. Co-Authored-By: Claude Fable 5 --- src/wp-includes/class-wp-comment.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index b93426f2bb0e4..62569379fdb6d 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -11,7 +11,8 @@ * Core class used to organize comments as instantiated objects with defined members. * * The `@property-read` fields below are not stored on the comment. They are proxied to the - * comment's post by {@see WP_Comment::__get()}, and are null when that post no longer exists. + * comment's post by {@see WP_Comment::__get()}, and are null when the comment is not attached + * to a post or when that post no longer exists. * * @since 4.4.0 * @@ -480,6 +481,7 @@ public function populated_children( $set ): void { * If `$name` matches a post field, the comment post will be loaded and the post's value checked. * * @since 4.4.0 + * @since 7.1.0 Returns false instead of causing a fatal error when the comment's post cannot be found. * * @param string $name Property to check if set. * @return bool Whether the property is set. @@ -499,12 +501,14 @@ public function __isset( $name ) { * If `$name` matches a post field, the comment post will be loaded and the post's value returned. * * @since 4.4.0 + * @since 7.1.0 Returns null instead of the global post's field when the comment is not attached to + * a post, and no longer raises a warning when the comment's post cannot be found. * * @param string $name Property name. * @return mixed */ public function __get( $name ) { - if ( in_array( $name, $this->post_fields, true ) ) { + if ( in_array( $name, $this->post_fields, true ) && 0 !== (int) $this->comment_post_ID ) { $post = get_post( (int) $this->comment_post_ID ); if ( ! $post ) { return null; From 8fb483df4b7c1ef276d9481f54dab2ccc35f0b12 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:16:45 -0700 Subject: [PATCH 19/21] Narrow ARRAY_N return of get_comment() to non-empty-list The ARRAY_N branch returns array_values(), which always produces a sequentially-keyed list, so non-empty-list is sharper than non-empty-array. Co-Authored-By: Claude Fable 5 --- src/wp-includes/comment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 3e9c8ba7c05a2..23c64dba16f8c 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -228,7 +228,7 @@ function get_approved_comments( $post_id, $args = array() ) { * @phpstan-param 'OBJECT'|'ARRAY_A'|'ARRAY_N' $output * @phpstan-return ( * $output is 'ARRAY_A' ? non-empty-array|null : ( - * $output is 'ARRAY_N' ? non-empty-array|null : WP_Comment|null + * $output is 'ARRAY_N' ? non-empty-list|null : WP_Comment|null * ) * ) */ From ea9b12dfc8f5ad23e3536482eabc3c5857febd2d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 17:29:04 -0700 Subject: [PATCH 20/21] Add @since entries for behavior changes in get_children() and get_comment() WP_Comment::get_children() no longer stores the result of a count or fields query in the children cache, and get_comment() now treats only numeric values as comment IDs rather than casting any unrecognized value to an integer. Record both changes with @since 7.1.0 entries, matching the entries already added for the magic methods. Co-Authored-By: Claude Fable 5 --- src/wp-includes/class-wp-comment.php | 2 ++ src/wp-includes/comment.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/wp-includes/class-wp-comment.php b/src/wp-includes/class-wp-comment.php index 62569379fdb6d..f6b4d8d75f5e3 100644 --- a/src/wp-includes/class-wp-comment.php +++ b/src/wp-includes/class-wp-comment.php @@ -310,6 +310,8 @@ public function to_array(): array { * Gets the children of a comment. * * @since 4.4.0 + * @since 7.1.0 A `count` or `fields` query now returns its result directly rather than + * erroneously storing it in the comment's children cache. * * @param array $args { * Array of arguments used to pass to {@see get_comments()} and determine format. diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 23c64dba16f8c..489bf9415600f 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -217,6 +217,8 @@ function get_approved_comments( $post_id, $args = array() ) { * comment variable will be used, if it is set. * * @since 2.0.0 + * @since 7.1.0 Only numeric values are now treated as comment IDs; other unrecognized values + * return null instead of being cast to an integer ID. * * @global WP_Comment $comment Global comment object. * From 941f7367533f04380c624c8d2d62a231f77faa49 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 20 Jul 2026 18:00:37 -0700 Subject: [PATCH 21/21] Add regression tests for comment runtime behavior changes Cover the behavior changes made on this branch: * WP_Comment::get_children() returns a count or a list of IDs directly, without poisoning the children cache, so a subsequent default query still returns WP_Comment objects. * WP_Comment::__isset() returns false rather than fataling when the comment's post has been deleted, and __get() returns null. * WP_Comment::__get() returns null for an unattached comment rather than reading the field from the global post via get_post( 0 ). * get_comment() treats only numeric values as comment IDs, so malformed numeric strings and other unrecognized values return null instead of being cast to an integer ID. Co-Authored-By: Claude Fable 5 --- tests/phpunit/tests/comment.php | 17 ++++ tests/phpunit/tests/comment/wpComment.php | 113 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 11e78140f1020..c84279e4f1835 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -1912,4 +1912,21 @@ public function test_get_comment_filter() { add_filter( 'get_comment', '__return_null' ); $this->assertNull( get_comment( $comment_id ), 'Expected get_comment() to return null when get_comment filter returns null.' ); } + + /** + * @ticket 64898 + * + * @covers ::get_comment + */ + public function test_get_comment_should_only_treat_numeric_values_as_comment_ids(): void { + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) ); + $this->assertIsInt( $comment_id ); + + $comment = get_comment( (string) $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment, 'Expected a numeric string to be treated as a comment ID.' ); + $this->assertSame( (string) $comment_id, $comment->comment_ID, 'Expected the same comment.' ); + + $this->assertNull( get_comment( $comment_id . 'abc' ), 'Expected a malformed numeric string not to be cast to a comment ID.' ); + $this->assertNull( get_comment( true ), 'Expected true not to be cast to comment ID 1.' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) + } } diff --git a/tests/phpunit/tests/comment/wpComment.php b/tests/phpunit/tests/comment/wpComment.php index 83a8bc8d0f1eb..1f8688d3aeaf9 100644 --- a/tests/phpunit/tests/comment/wpComment.php +++ b/tests/phpunit/tests/comment/wpComment.php @@ -62,4 +62,117 @@ public function test_get_instance_should_succeed_for_float_that_is_equal_to_post $this->assertSame( '1', $found->comment_ID ); } + + /** + * @ticket 64898 + * + * @covers WP_Comment::get_children + */ + public function test_get_children_should_return_count_without_storing_it_in_the_children_cache(): void { + $post_id = self::factory()->post->create(); + $parent = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + $children = self::factory()->comment->create_many( + 2, + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $parent, + ) + ); + + $this->assertIsInt( $parent ); + $comment = get_comment( $parent ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + $count = $comment->get_children( array( 'count' => true ) ); + $this->assertSame( 2, $count, 'Expected the number of direct children.' ); + + $found = $comment->get_children(); + $this->assertContainsOnlyInstancesOf( 'WP_Comment', $found, 'Expected WP_Comment objects from a subsequent default query.' ); + + $found_ids = array(); + foreach ( $found as $child ) { + $found_ids[] = (int) $child->comment_ID; + } + $this->assertSameSets( $children, $found_ids, 'Expected the children cache to be unaffected by the count query.' ); + } + + /** + * @ticket 64898 + * + * @covers WP_Comment::get_children + */ + public function test_get_children_should_return_ids_without_storing_them_in_the_children_cache(): void { + $post_id = self::factory()->post->create(); + $parent = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + $children = self::factory()->comment->create_many( + 2, + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $parent, + ) + ); + + $this->assertIsInt( $parent ); + $comment = get_comment( $parent ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + $ids = $comment->get_children( array( 'fields' => 'ids' ) ); + $this->assertSameSets( $children, $ids, 'Expected the IDs of the direct children.' ); + + $found = $comment->get_children(); + $this->assertContainsOnlyInstancesOf( 'WP_Comment', $found, 'Expected WP_Comment objects from a subsequent default query.' ); + + $found_ids = array(); + foreach ( $found as $child ) { + $found_ids[] = (int) $child->comment_ID; + } + $this->assertSameSets( $children, $found_ids, 'Expected the children cache to be unaffected by the IDs query.' ); + } + + /** + * @ticket 64898 + * + * @covers WP_Comment::__isset + * @covers WP_Comment::__get + */ + public function test_post_fields_should_be_null_when_the_comments_post_is_deleted(): void { + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + + $this->assertIsInt( $post_id ); + $this->assertIsInt( $comment_id ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + wp_delete_post( $post_id, true ); + + $this->assertFalse( isset( $comment->post_title ), 'Expected __isset() to return false when the post is deleted.' ); + $this->assertNull( $comment->post_title, 'Expected __get() to return null when the post is deleted.' ); + } + + /** + * @ticket 64898 + * + * @covers WP_Comment::__isset + * @covers WP_Comment::__get + */ + public function test_post_fields_should_be_null_for_an_unattached_comment(): void { + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => 0 ) ); + + $this->assertIsInt( $post_id ); + $this->assertIsInt( $comment_id ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + $GLOBALS['post'] = get_post( $post_id ); + + $is_set = isset( $comment->post_title ); + $title = $comment->post_title; + + unset( $GLOBALS['post'] ); + + $this->assertFalse( $is_set, 'Expected __isset() to return false for an unattached comment.' ); + $this->assertNull( $title, 'Expected __get() not to read the field from the global post.' ); + } }