From cc7f68416620e57392f6360be3c33cceed776462 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 12:41:33 -0700 Subject: [PATCH 01/11] Restore `void` on the dual-mode `$display` template tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r61768 replaced `string|void` with `string|null` across the template tags that either echo their result or return it, and r63379 followed by adding the trailing `return null;` that the annotation then obliged. Both steps were mechanically correct but lost information: under `string|null`, PHPStan treats the display-mode result as a legitimate value, so consuming a meaningless one no longer reports anything. Restore `string|void` on the nine functions r63379 touched and pin the duality down with a conditional `@phpstan-return`, so display mode resolves to `void` and retrieval mode to `string` — or to `string|null` where a failure path bails before the display branch. PHPStan raises `function.void` at call sites again, while retrieval-mode calls keep their usable type. The trailing `return null;` statements go away, since `void` in the union licenses falling off the end. The failure bails in `single_post_title()`, `post_type_archive_title()`, `single_term_title()` and `edit_term_link()` go back to a bare `return;`; the conditional return type is what now makes them read as nothing when displaying and as null when retrieving. `single_cat_title()` and `single_tag_title()` delegate to `single_term_title()`, so they take the same annotation. Their one-line body becomes an early return, because returning the delegate's value unconditionally never returns void and PHPStan reports the `void` in the union as unused. `single_month_title()` is deliberately left alone: its display branch legitimately returns `false` on failure, so it cannot resolve to plain `void` and would gain the accuracy without the detection. Full PHPStan runs before and after report an identical 27,183 errors, and the `return.missing` baseline stays deleted. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/general-template.php | 58 +++++++++++++++------------- src/wp-includes/link-template.php | 19 ++++----- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index b626b5ca605ce..558bdaa18a42f 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -1543,7 +1543,8 @@ function _wp_render_title_tag() { * Default '»'. * @param bool $display Optional. Whether to display or retrieve title. Default true. * @param string $seplocation Optional. Location of the separator (either 'left' or 'right'). - * @return string|null String when `$display` is false, null otherwise. + * @return string|void String when `$display` is false, nothing otherwise. + * @phpstan-return ( $display is true ? void : string ) */ function wp_title( $sep = '»', $display = true, $seplocation = '' ) { global $wp_locale; @@ -1678,8 +1679,6 @@ function wp_title( $sep = '»', $display = true, $seplocation = '' ) { } echo $title; - - return null; } /** @@ -1696,13 +1695,14 @@ function wp_title( $sep = '»', $display = true, $seplocation = '' ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|null Title when retrieving. + * @return string|void Title when retrieving. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_post_title( $prefix = '', $display = true ) { $_post = get_queried_object(); if ( ! isset( $_post->post_title ) ) { - return null; + return; } /** @@ -1720,8 +1720,6 @@ function single_post_title( $prefix = '', $display = true ) { } echo $prefix . $title; - - return null; } /** @@ -1734,11 +1732,12 @@ function single_post_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|null Title when retrieving, null when displaying or on failure. + * @return string|void Title when retrieving, nothing when displaying or on failure. + * @phpstan-return ( $display is true ? void : string|null ) */ function post_type_archive_title( $prefix = '', $display = true ) { if ( ! is_post_type_archive() ) { - return null; + return; } $post_type = get_query_var( 'post_type' ); @@ -1763,8 +1762,6 @@ function post_type_archive_title( $prefix = '', $display = true ) { } echo $prefix . $title; - - return null; } /** @@ -1778,10 +1775,15 @@ function post_type_archive_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|null Title when retrieving. + * @return string|void Title when retrieving. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_cat_title( $prefix = '', $display = true ) { - return single_term_title( $prefix, $display ); + if ( ! $display ) { + return single_term_title( $prefix, false ); + } + + single_term_title( $prefix, true ); } /** @@ -1795,10 +1797,15 @@ function single_cat_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|null Title when retrieving. + * @return string|void Title when retrieving. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_tag_title( $prefix = '', $display = true ) { - return single_term_title( $prefix, $display ); + if ( ! $display ) { + return single_term_title( $prefix, false ); + } + + single_term_title( $prefix, true ); } /** @@ -1812,13 +1819,14 @@ function single_tag_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|null Title when retrieving. + * @return string|void Title when retrieving. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_term_title( $prefix = '', $display = true ) { $term = get_queried_object(); if ( ! $term ) { - return null; + return; } if ( is_category() ) { @@ -1849,11 +1857,11 @@ function single_term_title( $prefix = '', $display = true ) { */ $term_name = apply_filters( 'single_term_title', $term->name ); } else { - return null; + return; } if ( empty( $term_name ) ) { - return null; + return; } if ( ! $display ) { @@ -1861,8 +1869,6 @@ function single_term_title( $prefix = '', $display = true ) { } echo $prefix . $term_name; - - return null; } /** @@ -2898,7 +2904,8 @@ function the_date_xml() { * @param string $before Optional. Output before the date. Default empty. * @param string $after Optional. Output after the date. Default empty. * @param bool $display Optional. Whether to echo the date or return it. Default true. - * @return string|null String if retrieving. + * @return string|void String if retrieving. + * @phpstan-return ( $display is true ? void : string ) */ function the_date( $format = '', $before = '', $after = '', $display = true ) { global $currentday, $previousday; @@ -2927,8 +2934,6 @@ function the_date( $format = '', $before = '', $after = '', $display = true ) { } echo $the_date; - - return null; } /** @@ -2975,7 +2980,8 @@ function get_the_date( $format = '', $post = null ) { * @param string $before Optional. Output before the date. Default empty. * @param string $after Optional. Output after the date. Default empty. * @param bool $display Optional. Whether to echo the date or return it. Default true. - * @return string|null String if retrieving. + * @return string|void String if retrieving. + * @phpstan-return ( $display is true ? void : string ) */ function the_modified_date( $format = '', $before = '', $after = '', $display = true ) { $the_modified_date = $before . get_the_modified_date( $format ) . $after; @@ -2997,8 +3003,6 @@ function the_modified_date( $format = '', $before = '', $after = '', $display = } echo $the_modified_date; - - return null; } /** diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index b50328793857e..41a7488a870d3 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1131,7 +1131,8 @@ function get_edit_term_link( $term, $taxonomy = '', $object_type = '' ) { * @param string $after Optional. Display after edit link. Default empty. * @param int|WP_Term|null $term Optional. Term ID or object. If null, the queried object will be inspected. Default null. * @param bool $display Optional. Whether or not to echo the return. Default true. - * @return string|null HTML content. + * @return string|void HTML content. + * @phpstan-return ( $display is true ? void : string|null ) */ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $display = true ) { if ( is_null( $term ) ) { @@ -1141,11 +1142,11 @@ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $d } if ( ! $term ) { - return null; + return; } if ( ! current_user_can( 'edit_term', $term->term_id ) ) { - return null; + return; } if ( empty( $link ) ) { @@ -1169,8 +1170,6 @@ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $d } echo $link; - - return null; } /** @@ -2543,7 +2542,8 @@ function get_next_posts_page_link( $max_page = 0 ) { * * @param int $max_page Optional. Max pages. Default 0. * @param bool $display Optional. Whether to echo the link. Default true. - * @return string|null The link URL for next posts page if `$display = false`. + * @return string|void The link URL for next posts page if `$display = false`. + * @phpstan-return ( $display is true ? void : string ) */ function next_posts( $max_page = 0, $display = true ) { $link = get_next_posts_page_link( $max_page ); @@ -2554,8 +2554,6 @@ function next_posts( $max_page = 0, $display = true ) { } echo $output; - - return null; } /** @@ -2655,7 +2653,8 @@ function get_previous_posts_page_link() { * @since 0.71 * * @param bool $display Optional. Whether to echo the link. Default true. - * @return string|null The previous posts page link if `$display = false`. + * @return string|void The previous posts page link if `$display = false`. + * @phpstan-return ( $display is true ? void : string ) */ function previous_posts( $display = true ) { $link = get_previous_posts_page_link(); @@ -2666,8 +2665,6 @@ function previous_posts( $display = true ) { } echo $output; - - return null; } /** From 92857ebb885b88ac3a01da14a982a3db1759d5bb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 12:51:54 -0700 Subject: [PATCH 02/11] Extend the conditional `void` return types to the rest of the `$display` tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of core for the same echo-or-return shape turned up five more functions, and with them the reason the pattern kept going unnoticed: `@return void|string` on its own conveys nothing to PHPStan. The `function.void` report fires only when the resolved return type is exactly `void`, and a plain union never resolves to that. So `comment_class()`, `the_title()`, `wp_loginout()` and `wp_register()` have carried the `void` through every annotation sweep while getting no analysis out of it. Give all four the conditional `@phpstan-return` that actually does the work, and reorder the union to `string|void` to match the functions the previous commit touched. `the_title()` bails early when the title is empty, so its retrieval branch is `string|null` rather than `string`. `wp_update_php_annotation()` is documented `string|null … null otherwise`, the same wording r61768 left behind elsewhere, and needs a body change to follow: its trailing `return null;` is reached in both modes, so it moves to an early bare `return;` on the missing-annotation path and the echoing path now falls off the end. Behavior is unchanged. Deliberately excluded are the functions that echo and then return the value unconditionally, where the result is always meaningful -- `wp_nonce_field()`, `checked()` and its siblings, `menu_page_url()`, `timer_stop()` among them -- along with those returning a meaningful `false` or `true` while displaying, such as `single_month_title()` and the `WP_Scripts` and `WP_Styles` `print_*()` methods. The dozen or so tags taking `echo` inside an `$args` array are left alone for the reason `wp_list_users()` was: `$args` accepts a query string as well as an array, so an `array{echo: false}` condition would resolve to the `void` branch for the still-common `'echo=0'` call style and report correct code as an error. Full PHPStan runs before and after report the same error set. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/comment-template.php | 3 ++- src/wp-includes/functions.php | 19 +++++++++++-------- src/wp-includes/general-template.php | 8 +++++--- src/wp-includes/post-template.php | 5 +++-- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 43bd68ff972a4..7b76fbf2f5f24 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -488,7 +488,8 @@ function comment_author_url_link( $link_text = '', $before = '', $after = '', $c * @param int|WP_Post $post Optional. Post ID or WP_Post object. Default current post. * @param bool $display Optional. Whether to print or return the output. * Default true. - * @return void|string Void if `$display` argument is true, comment classes if `$display` is false. + * @return string|void Comment classes if `$display` is false, nothing otherwise. + * @phpstan-return ( $display is true ? void : string ) */ function comment_class( $css_class = '', $comment = null, $post = null, $display = true ) { // Separates classes with a single space, collates classes for comment DIV. diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 3d488c286d4a3..a7ffa542b4dc5 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -8737,19 +8737,22 @@ function wp_get_default_update_php_url() { * @param string $before Markup to output before the annotation. Default `

`. * @param string $after Markup to output after the annotation. Default `

`. * @param bool $display Whether to echo or return the markup. Default `true` for echo. - * @return string|null Update PHP page annotation if available and $display is false, null otherwise. + * @return string|void Update PHP page annotation if available and `$display` is false, + * nothing otherwise. + * @phpstan-return ( $display is true ? void : string|null ) */ function wp_update_php_annotation( $before = '

', $after = '

', $display = true ) { $annotation = wp_get_update_php_annotation(); - if ( $annotation ) { - if ( $display ) { - echo $before . $annotation . $after; - } else { - return $before . $annotation . $after; - } + if ( ! $annotation ) { + return; } - return null; + + if ( ! $display ) { + return $before . $annotation . $after; + } + + echo $before . $annotation . $after; } /** diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 558bdaa18a42f..90773f3701455 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -581,7 +581,8 @@ function wp_get_tooltip_helper( $content, $args = array() ) { * * @param string $redirect Optional path to redirect to on login/logout. * @param bool $display Default to echo and not return the link. - * @return void|string Void if `$display` argument is true, log in/out link if `$display` is false. + * @return string|void Log in/out link if `$display` is false, nothing otherwise. + * @phpstan-return ( $display is true ? void : string ) */ function wp_loginout( $redirect = '', $display = true ) { if ( ! is_user_logged_in() ) { @@ -901,8 +902,9 @@ function wp_lostpassword_url( $redirect = '' ) { * @param string $before Text to output before the link. Default `
  • `. * @param string $after Text to output after the link. Default `
  • `. * @param bool $display Default to echo and not return the link. - * @return void|string Void if `$display` argument is true, registration or admin link - * if `$display` is false. + * @return string|void Registration or admin link if `$display` is false, + * nothing otherwise. + * @phpstan-return ( $display is true ? void : string ) */ function wp_register( $before = '
  • ', $after = '
  • ', $display = true ) { if ( ! is_user_logged_in() ) { diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index ea06ad3b64a7b..a471766980838 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -37,8 +37,9 @@ function get_the_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * @param string $before Optional. Markup to prepend to the title. Default empty. * @param string $after Optional. Markup to append to the title. Default empty. * @param bool $display Optional. Whether to echo or return the title. Default true for echo. - * @return void|string Void if `$display` argument is true or the title is empty, - * current post title if `$display` is false. + * @return string|void Current post title if `$display` is false, nothing otherwise + * or when the title is empty. + * @phpstan-return ( $display is true ? void : string|null ) */ function the_title( $before = '', $after = '', $display = true ) { $title = get_the_title(); From 9ea267e00a449157de243b08bcfd267fc69e49ec Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 12:58:09 -0700 Subject: [PATCH 03/11] Correct the inverted `$display` return docs on three `print_*()` methods `WP_Styles::print_inline_style()`, `WP_Scripts::print_extra_script()` and the deprecated `WP_Scripts::print_scripts_l10n()` all document their return the wrong way round. Each says the markup comes back when `$display` is true, but the string is returned on the `! $display` branch and the printing branch returns `true`. The description has read this way since the `$display` parameter was introduced, so anyone consulting it to decide which argument to pass was told the opposite of what the code does. Swap `true` for `false` in the three descriptions, and pin the two behaviours apart with a conditional `@phpstan-return`, since the plain unions collapse the distinction the same way the `$display` template tags did. `print_inline_style()` now resolves to `bool` when printing and `string|false` when retrieving, rather than `string|bool` either way, and `print_extra_script()` to `true|null` and `string|null` rather than `bool|string|null`. The narrower retrieval types matter at the two internal call sites that pass `false` and then use the result as a string. `print_inline_script()` and `print_translations()` are left alone. Both print and then return the same value, so their existing `string|false` is accurate in either mode and there is nothing for a condition to separate. The pre-existing `return.type` report on `print_extra_script()`, which stems from `WP_Dependencies::get_data()` returning mixed, is unchanged apart from restating the narrower expected type. The error set is otherwise identical before and after. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/class-wp-scripts.php | 6 ++++-- src/wp-includes/class-wp-styles.php | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index e48658a1e7f7c..f8d32d375d040 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -201,8 +201,9 @@ public function print_scripts( $handles = false, $group = false ) { * @param string $handle The script's registered handle. * @param bool $display Optional. Whether to print the extra script * instead of just returning it. Default true. - * @return bool|string|null Null if no data exists, extra scripts if `$display` is true, + * @return bool|string|null Null if no data exists, extra scripts if `$display` is false, * true otherwise. + * @phpstan-return ( $display is true ? true|null : string|null ) */ public function print_scripts_l10n( $handle, $display = true ) { _deprecated_function( __FUNCTION__, '3.3.0', 'WP_Scripts::print_extra_script()' ); @@ -217,8 +218,9 @@ public function print_scripts_l10n( $handle, $display = true ) { * @param string $handle The script's registered handle. * @param bool $display Optional. Whether to print the extra script * instead of just returning it. Default true. - * @return bool|string|null Null if no data exists, extra scripts if `$display` is true, + * @return bool|string|null Null if no data exists, extra scripts if `$display` is false, * true otherwise. + * @phpstan-return ( $display is true ? true|null : string|null ) */ public function print_extra_script( $handle, $display = true ) { $output = $this->get_data( $handle, 'data' ); diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 20487ca9b7068..531940951a5a1 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -296,8 +296,9 @@ public function add_inline_style( $handle, $code ) { * @param string $handle The style's registered handle. * @param bool $display Optional. Whether to print the inline style * instead of just returning it. Default true. - * @return string|bool False if no data exists, inline styles if `$display` is true, + * @return string|bool False if no data exists, inline styles if `$display` is false, * true otherwise. + * @phpstan-return ( $display is true ? bool : string|false ) */ public function print_inline_style( $handle, $display = true ) { $output = $this->get_data( $handle, 'after' ); From e60fdaf30d4c600b674f7b6d54b47482d4b23082 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 13:28:33 -0700 Subject: [PATCH 04/11] Apply the conditional `void` return types to the `echo` argument template tags The thirteen template tags taking their print-or-return flag inside an `$args` array were previously passed over on the grounds that `$args` accepts a query string as well as an array, so an `array{echo: false}` condition would resolve to the `void` branch for the still-common `'echo=0'` call style and report correct code as an error. Testing rather than reasoning about it shows the objection is avoidable, and that two of the assumptions behind it were wrong. Array shapes in a conditional are not sealed, so a caller passing `array( 'echo' => false, 'aria_label' => 'a' )` matches `array{ echo: false, ... }` as intended. And the query-string case is handled by a third branch: when `$args` is neither the falsy-flag shape nor an array, the type stays a union and nothing is reported. What that branch gives up is only the undecidable call styles; the bare `the_title_attribute()`, the empty array and an explicit truthy flag all still resolve to `void` and are reported when consumed. The flag also has to be matched as `false|0|''|'0'` rather than `false`, since these tags variously default it to `true` or to `1` and callers follow suit. Matching only `false` reports `array( 'echo' => 0 )` as void while it actually returns the markup. Covered are `the_title_attribute()`, `get_search_form()`, `get_calendar()` (whose flag is `display`), `wp_login_form()`, `wp_get_archives()`, `wp_list_pages()`, `wp_page_menu()`, `wp_list_comments()`, `wp_list_bookmarks()`, `wp_list_authors()`, `wp_list_users()`, `wp_tag_cloud()` and `paginate_comments_links()`. The last two answer to `format` and `type` as well, either of which returns an array even while printing, so their conditions nest that dimension first. `wp_list_users()` also loses the trailing `return null;` r63378 gave it, so that its printing path is genuinely void. For the three tags whose `$args` is documented as an array, the `$args is array` guard is always true and PHPStan says so, so those take the plain two-branch form. `wp_dropdown_languages()` turns out not to belong to this group at all: it prints and then returns the markup regardless of the flag. Its `void` is correct, for the bail on a missing `id` or `name`, and only the description needed saying. Left alone are `wp_list_categories()` and `wp_nav_menu()`, which return a meaningful `false` on a missing taxonomy and a missing menu respectively, on a path shared by both modes. Their printing branch is therefore `false|void` rather than `void` and cannot be reported, the same reason `single_month_title()` was passed over. Narrowing `paginate_comments_links()` to `string[]` also resolves three existing errors, two of them in Twenty Twenty, where the result of a call passing `echo => false` and no `type` was still typed as possibly an array. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/author-template.php | 7 ++++++- src/wp-includes/bookmark-template.php | 7 ++++++- src/wp-includes/category-template.php | 12 ++++++++++-- src/wp-includes/comment-template.php | 9 +++++++-- src/wp-includes/general-template.php | 16 ++++++++++++---- src/wp-includes/l10n.php | 4 +++- src/wp-includes/link-template.php | 15 +++++++++++---- src/wp-includes/post-template.php | 22 +++++++++++++++++++--- src/wp-includes/user.php | 9 ++++++--- 9 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/author-template.php b/src/wp-includes/author-template.php index 4c715a62b51f1..77b1adaf96ac0 100644 --- a/src/wp-includes/author-template.php +++ b/src/wp-includes/author-template.php @@ -449,7 +449,12 @@ function get_author_posts_url( $author_id, $author_nicename = '' ) { * @type int[]|string $exclude Array or comma/space-separated list of author IDs to exclude. Default empty. * @type int[]|string $include Array or comma/space-separated list of author IDs to include. Default empty. * } - * @return void|string Void if 'echo' argument is true, list of authors if 'echo' is false. + * @return string|void List of authors if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is ''|array ? void : string|null ) + * ) */ function wp_list_authors( $args = '' ) { global $wpdb; diff --git a/src/wp-includes/bookmark-template.php b/src/wp-includes/bookmark-template.php index 893494a7e92cd..b3f94decef2d2 100644 --- a/src/wp-includes/bookmark-template.php +++ b/src/wp-includes/bookmark-template.php @@ -206,7 +206,12 @@ function _walk_bookmarks( $bookmarks, $args = '' ) { * $categorize is true. Accepts 'ASC' (ascending) or 'DESC' (descending). * Default 'ASC'. * } - * @return void|string Void if 'echo' argument is true, HTML list of bookmarks if 'echo' is false. + * @return string|void HTML list of bookmarks if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is ''|array ? void : string|null ) + * ) */ function wp_list_bookmarks( $args = '' ) { $defaults = array( diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index 76409d0832f2e..1352141dbb592 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -710,8 +710,16 @@ function wp_list_categories( $args = '' ) { * associated with the taxonomy. * @type bool $echo Whether or not to echo the return value. Default true. * } - * @return void|string|string[] Void if 'echo' argument is true, or on failure. Otherwise, tag cloud - * as a string or an array, depending on 'format' argument. + * @return string|string[]|void Tag cloud as a string, or as an array when the 'format' argument + * is 'array'. Nothing when 'echo' is true and 'format' is not + * 'array', or on failure. + * @phpstan-return ( + * $args is array{ format: 'array', ... } + * ? string[]|void + * : ( $args is array{ echo: false|0|''|'0', ... } + * ? string|void + * : ( $args is ''|array ? void : string|string[]|void ) ) + * ) */ function wp_tag_cloud( $args = '' ) { $defaults = array( diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 7b76fbf2f5f24..fa87fbf6e0e45 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -2229,8 +2229,13 @@ function _get_comment_reply_id( $post = null ) { * @type bool $echo Whether to echo the output or return it. Default true. * } * @param WP_Comment[] $comments Optional. Array of WP_Comment objects. Default null. - * @return void|string Void if 'echo' argument is true, or no comments to list. - * Otherwise, HTML list of comments. + * @return string|void HTML list of comments if 'echo' is false, nothing otherwise + * or when there are no comments to list. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string|void + * : ( $args is ''|array ? void : string|void ) + * ) */ function wp_list_comments( $args = array(), $comments = null ) { global $wp_query, $comment_alt, $comment_depth, $comment_thread_alt, $overridden_cpage, $in_comment_loop; diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 90773f3701455..b6976ffa72201 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -236,7 +236,8 @@ function get_template_part( $slug, $name = null, $args = array() ) { * multiple search forms on the same page and improve * accessibility. Default empty. * } - * @return void|string Void if 'echo' argument is true, search form HTML if 'echo' is false. + * @return string|void Search form HTML if 'echo' is false, nothing otherwise. + * @phpstan-return ( $args is array{ echo: false|0|''|'0', ... } ? string : void ) */ function get_search_form( $args = array() ) { /** @@ -722,7 +723,8 @@ function wp_registration_url() { * Default false. * * } - * @return void|string Void if 'echo' argument is true, login form HTML if 'echo' is false. + * @return string|void Login form HTML if 'echo' is false, nothing otherwise. + * @phpstan-return ( $args is array{ echo: false|0|''|'0', ... } ? string : void ) */ function wp_login_form( $args = array() ) { $defaults = array( @@ -2222,7 +2224,12 @@ function get_archives_link( $url, $text, $format = 'html', $before = '', $after * @type string $day Day. Default current day. * @type string $w Week. Default current week. * } - * @return void|string Void if 'echo' argument is true, archive links if 'echo' is false. + * @return string|void Archive links if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string|void + * : ( $args is ''|array ? void : string|void ) + * ) */ function wp_get_archives( $args = '' ) { global $wpdb, $wp_locale; @@ -2492,7 +2499,8 @@ function calendar_week_mod( $num ) { * @type bool $display Whether to display the calendar output. Default true. * @type string $post_type Optional. Post type. Default 'post'. * } - * @return void|string Void if `$display` argument is true, calendar HTML if `$display` is false. + * @return string|void Calendar HTML if `$display` is false, nothing otherwise. + * @phpstan-return ( $args is array{ display: false|0|''|'0', ... } ? string|void : void ) */ function get_calendar( $args = array() ) { global $wpdb, $m, $monthnum, $year, $wp_locale, $posts; diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 0f9dd0d4016a0..a0be55146f7e6 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1735,7 +1735,9 @@ function wp_get_l10n_php_file_data( $php_file ) { * @type bool $explicit_option_en_us Whether the English (United States) option uses an explicit value of en_US * instead of an empty value. Default false. * } - * @return string|void HTML dropdown list of languages. + * @return string|void HTML dropdown list of languages. Always returned, whether or not + * 'echo' is true; nothing is returned when the required `id` or `name` + * argument is missing. */ function wp_dropdown_languages( $args = array() ) { diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 41a7488a870d3..2a4ea25608b27 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -3265,10 +3265,17 @@ function previous_comments_link( $label = '' ) { * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * * @param string|array $args Optional args. See paginate_links(). Default empty array. - * @return void|string|array Void if 'echo' argument is true and 'type' is not an array, - * or if the query is not for an existing single post of any post type. - * Otherwise, markup for comment page links or array of comment page links, - * depending on 'type' argument. + * @return string|string[]|void Markup for comment page links, or an array of them when the 'type' + * argument is 'array'. Nothing when 'echo' is true and 'type' is not + * 'array', or if the query is not for an existing single post of any + * post type. + * @phpstan-return ( + * $args is array{ type: 'array', ... } + * ? string[]|void + * : ( $args is array{ echo: false|0|''|'0', ... } + * ? string|void + * : ( $args is ''|array ? void : string|string[]|void ) ) + * ) */ function paginate_comments_links( $args = array() ) { global $wp_rewrite; diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index a471766980838..f13ee56a9e98e 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -77,7 +77,13 @@ function the_title( $before = '', $after = '', $display = true ) { * @type bool $echo Whether to echo or return the title. Default true for echo. * @type WP_Post $post Current post object to retrieve the title for. * } - * @return void|string Void if 'echo' argument is true, the title attribute if 'echo' is false. + * @return string|void The title attribute if 'echo' is false, nothing otherwise + * or when the title is empty. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string|void + * : ( $args is ''|array ? void : string|void ) + * ) */ function the_title_attribute( $args = '' ) { $defaults = array( @@ -1299,7 +1305,12 @@ function wp_dropdown_pages( $args = '' ) { * @type Walker $walker Walker instance to use for listing pages. Default empty which results in a * Walker_Page instance being used. * } - * @return void|string Void if 'echo' argument is true, HTML list of pages if 'echo' is false. + * @return string|void HTML list of pages if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is ''|array ? void : string|null ) + * ) */ function wp_list_pages( $args = '' ) { $defaults = array( @@ -1422,7 +1433,12 @@ function wp_list_pages( $args = '' ) { * @type Walker $walker Walker instance to use for listing pages. Default empty which results in a * Walker_Page instance being used. * } - * @return void|string Void if 'echo' argument is true, HTML menu if 'echo' is false. + * @return string|void HTML menu if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is ''|array ? void : string|null ) + * ) */ function wp_page_menu( $args = array() ) { $defaults = array( diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 01b567a74d86d..0a0aed8348073 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -907,7 +907,12 @@ function get_users( $args = array() ) { * @type string $exclude An array, comma-, or space-separated list of user IDs to exclude. Default empty. * @type string $include An array, comma-, or space-separated list of user IDs to include. Default empty. * } - * @return string|null The output if echo is false. Otherwise null. + * @return string|void The output if 'echo' is false, nothing otherwise. + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is ''|array ? void : string|null ) + * ) */ function wp_list_users( $args = array() ) { $defaults = array( @@ -1015,8 +1020,6 @@ function wp_list_users( $args = array() ) { } echo $return; - - return null; } /** From f0e1c8c9ae145f9abe5ee2c2a587a440f76a5d88 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 13:38:22 -0700 Subject: [PATCH 05/11] Spell the retrieval branch `void` where the bail is a bare `return;` Eight of the conditional return types gave the retrieval branch as `string|null` while the `@return` above them said `string|void`, so the two tags disagreed about the third outcome. The `null` came from the failure paths, which bail with a bare `return;` and so are void, not a returned null. Saying `void` in both places settles it, and matches the shape the `echo` argument tags already use, where PHPStan itself reported the `null` as never returned. Callers are unaffected: `void` in a branch that is not the whole type resolves to `null` at the call site, so retrieval-mode calls still type as `string|null` exactly as before, and display mode still resolves to plain `void` and is still reported when consumed. Affected are `single_post_title()`, `post_type_archive_title()`, `single_cat_title()`, `single_tag_title()`, `single_term_title()`, `edit_term_link()`, `the_title()` and `wp_update_php_annotation()`. Those whose description did not mention the failure case now say so, since the type alone no longer hints at it. `WP_Scripts::print_extra_script()` and `print_scripts_l10n()` keep `string|null`, correctly: their bail is an explicit `return null;` and the `@return` above already carries the `null`. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/functions.php | 2 +- src/wp-includes/general-template.php | 18 +++++++++--------- src/wp-includes/link-template.php | 5 +++-- src/wp-includes/post-template.php | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index a7ffa542b4dc5..0b5bc61f37251 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -8739,7 +8739,7 @@ function wp_get_default_update_php_url() { * @param bool $display Whether to echo or return the markup. Default `true` for echo. * @return string|void Update PHP page annotation if available and `$display` is false, * nothing otherwise. - * @phpstan-return ( $display is true ? void : string|null ) + * @phpstan-return ( $display is true ? void : string|void ) */ function wp_update_php_annotation( $before = '

    ', $after = '

    ', $display = true ) { $annotation = wp_get_update_php_annotation(); diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index b6976ffa72201..f46664528d821 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -1699,8 +1699,8 @@ function wp_title( $sep = '»', $display = true, $seplocation = '' ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving. - * @phpstan-return ( $display is true ? void : string|null ) + * @return string|void Title when retrieving, nothing when displaying or on failure. + * @phpstan-return ( $display is true ? void : string|void ) */ function single_post_title( $prefix = '', $display = true ) { $_post = get_queried_object(); @@ -1737,7 +1737,7 @@ function single_post_title( $prefix = '', $display = true ) { * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|null ) + * @phpstan-return ( $display is true ? void : string|void ) */ function post_type_archive_title( $prefix = '', $display = true ) { if ( ! is_post_type_archive() ) { @@ -1779,8 +1779,8 @@ function post_type_archive_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving. - * @phpstan-return ( $display is true ? void : string|null ) + * @return string|void Title when retrieving, nothing when displaying or on failure. + * @phpstan-return ( $display is true ? void : string|void ) */ function single_cat_title( $prefix = '', $display = true ) { if ( ! $display ) { @@ -1801,8 +1801,8 @@ function single_cat_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving. - * @phpstan-return ( $display is true ? void : string|null ) + * @return string|void Title when retrieving, nothing when displaying or on failure. + * @phpstan-return ( $display is true ? void : string|void ) */ function single_tag_title( $prefix = '', $display = true ) { if ( ! $display ) { @@ -1823,8 +1823,8 @@ function single_tag_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving. - * @phpstan-return ( $display is true ? void : string|null ) + * @return string|void Title when retrieving, nothing when displaying or on failure. + * @phpstan-return ( $display is true ? void : string|void ) */ function single_term_title( $prefix = '', $display = true ) { $term = get_queried_object(); diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 2a4ea25608b27..5e7c4fe37c1d7 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1131,8 +1131,9 @@ function get_edit_term_link( $term, $taxonomy = '', $object_type = '' ) { * @param string $after Optional. Display after edit link. Default empty. * @param int|WP_Term|null $term Optional. Term ID or object. If null, the queried object will be inspected. Default null. * @param bool $display Optional. Whether or not to echo the return. Default true. - * @return string|void HTML content. - * @phpstan-return ( $display is true ? void : string|null ) + * @return string|void HTML content when retrieving, nothing when displaying, + * on failure, or without the capability to edit the term. + * @phpstan-return ( $display is true ? void : string|void ) */ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $display = true ) { if ( is_null( $term ) ) { diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index f13ee56a9e98e..7db987367a442 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -39,7 +39,7 @@ function get_the_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * @param bool $display Optional. Whether to echo or return the title. Default true for echo. * @return string|void Current post title if `$display` is false, nothing otherwise * or when the title is empty. - * @phpstan-return ( $display is true ? void : string|null ) + * @phpstan-return ( $display is true ? void : string|void ) */ function the_title( $before = '', $after = '', $display = true ) { $title = get_the_title(); From 6adf2752a6c99117df3f92de70eb1bac3fec2c80 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 15:12:49 -0700 Subject: [PATCH 06/11] Use `null` in the delegating title wrappers, which return a value rather than bail `single_cat_title()` and `single_tag_title()` were given the same `string|void` retrieval branch as the functions that bail with a bare `return;`, but neither of them bails. Their retrieval path is a single `return single_term_title( $prefix, false );`, which hands back that function's `string|null` as an actual value, so `null` is what the branch should say. Callers see no difference, since a `void` in one branch of a conditional resolves to `null` at the call site either way. The point is that the annotation should describe what the function does, and only a bare `return;` justifies writing `void`. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/general-template.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index f46664528d821..c10ba50395248 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -1780,7 +1780,7 @@ function post_type_archive_title( $prefix = '', $display = true ) { * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|void ) + * @phpstan-return ( $display is true ? void : string|null ) */ function single_cat_title( $prefix = '', $display = true ) { if ( ! $display ) { @@ -1802,7 +1802,7 @@ function single_cat_title( $prefix = '', $display = true ) { * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|void ) + * @phpstan-return ( $display is true ? void : string|null ) */ function single_tag_title( $prefix = '', $display = true ) { if ( ! $display ) { From 88f78ee23c2d5b9e5963eb6dfdf595ca81ab7f22 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 15:20:58 -0700 Subject: [PATCH 07/11] Give every conditional one `void` branch and one nullable branch The conditional return types had settled into two shapes. Where the function bails before the print-or-return split, the retrieval branch read `string|void`, because the bail is a bare `return;` and that is void rather than a returned null. Where it does not bail, the branch read plain `string`. The first shape is defensible but reads oddly, since `void` then appears on both sides of the condition and it is not obvious that only one of them is doing the work. Settle on a single shape instead: one branch is exactly `void`, meaning the call has no value to give, and the other is the retrieval type unioned with `null` where a bail exists. To make the second half true, the bails reachable in retrieval mode now say `return null;` rather than falling out of the function, because in that mode the null is a value the caller observes. Bare `return;` is kept where it remains correct: the paths inside `if ( $args['display'] )` in `get_calendar()`, which run only after the markup has been echoed and can never be reached by a caller expecting a value. This does not resurrect what r63379 added and this branch removed. Those were trailing `return null;` statements on the printing path, asserting a value where the caller is not looking. The bails converted here sit before the split and are reached in both modes. Covered are `single_post_title()`, `post_type_archive_title()`, `single_term_title()`, `edit_term_link()`, `the_title()`, `wp_update_php_annotation()`, `the_title_attribute()`, `get_calendar()`, `wp_get_archives()`, `wp_list_comments()`, `wp_tag_cloud()` and `paginate_comments_links()`, with descriptions reworded to name the null case now that the type no longer implies it. Behavior is unchanged, as a bare `return;` and `return null;` both yield null. The reports are unchanged too: all fourteen functions still resolve to plain `void` in printing mode and are reported when consumed, and every retrieval-mode call still types exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/category-template.php | 14 ++++---- src/wp-includes/comment-template.php | 18 +++++----- src/wp-includes/functions.php | 8 ++--- src/wp-includes/general-template.php | 47 +++++++++++++++------------ src/wp-includes/link-template.php | 24 +++++++------- src/wp-includes/post-template.php | 18 +++++----- 6 files changed, 68 insertions(+), 61 deletions(-) diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index 1352141dbb592..633c8faf81c51 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -711,14 +711,14 @@ function wp_list_categories( $args = '' ) { * @type bool $echo Whether or not to echo the return value. Default true. * } * @return string|string[]|void Tag cloud as a string, or as an array when the 'format' argument - * is 'array'. Nothing when 'echo' is true and 'format' is not - * 'array', or on failure. + * is 'array'. Null on failure. Nothing when 'echo' is true and + * 'format' is not 'array'. * @phpstan-return ( * $args is array{ format: 'array', ... } - * ? string[]|void + * ? string[]|null * : ( $args is array{ echo: false|0|''|'0', ... } - * ? string|void - * : ( $args is ''|array ? void : string|string[]|void ) ) + * ? string|null + * : ( $args is ''|array ? void : string|string[]|null ) ) * ) */ function wp_tag_cloud( $args = '' ) { @@ -753,7 +753,7 @@ function wp_tag_cloud( $args = '' ) { ); // Always query top tags. if ( empty( $tags ) || is_wp_error( $tags ) ) { - return; + return null; } foreach ( $tags as $key => $tag ) { @@ -764,7 +764,7 @@ function wp_tag_cloud( $args = '' ) { } if ( is_wp_error( $link ) ) { - return; + return null; } $tags[ $key ]->link = $link; diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index fa87fbf6e0e45..cbd2ec835252d 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -2229,12 +2229,12 @@ function _get_comment_reply_id( $post = null ) { * @type bool $echo Whether to echo the output or return it. Default true. * } * @param WP_Comment[] $comments Optional. Array of WP_Comment objects. Default null. - * @return string|void HTML list of comments if 'echo' is false, nothing otherwise - * or when there are no comments to list. + * @return string|void HTML list of comments when 'echo' is false, null when there are no + * comments to list. Nothing otherwise. * @phpstan-return ( * $args is array{ echo: false|0|''|'0', ... } - * ? string|void - * : ( $args is ''|array ? void : string|void ) + * ? string|null + * : ( $args is ''|array ? void : string|null ) * ) */ function wp_list_comments( $args = array(), $comments = null ) { @@ -2280,12 +2280,12 @@ function wp_list_comments( $args = array(), $comments = null ) { if ( null !== $comments ) { $comments = (array) $comments; if ( empty( $comments ) ) { - return; + return null; } if ( 'all' !== $parsed_args['type'] ) { $comments_by_type = separate_comments( $comments ); if ( empty( $comments_by_type[ $parsed_args['type'] ] ) ) { - return; + return null; } $_comments = $comments_by_type[ $parsed_args['type'] ]; } else { @@ -2326,7 +2326,7 @@ function wp_list_comments( $args = array(), $comments = null ) { if ( 'all' !== $parsed_args['type'] ) { $comments_by_type = separate_comments( $comments ); if ( empty( $comments_by_type[ $parsed_args['type'] ] ) ) { - return; + return null; } $_comments = $comments_by_type[ $parsed_args['type'] ]; @@ -2338,14 +2338,14 @@ function wp_list_comments( $args = array(), $comments = null ) { // Otherwise, fall back on the comments from `$wp_query->comments`. } else { if ( empty( $wp_query->comments ) ) { - return; + return null; } if ( 'all' !== $parsed_args['type'] ) { if ( empty( $wp_query->comments_by_type ) ) { $wp_query->comments_by_type = separate_comments( $wp_query->comments ); } if ( empty( $wp_query->comments_by_type[ $parsed_args['type'] ] ) ) { - return; + return null; } $_comments = $wp_query->comments_by_type[ $parsed_args['type'] ]; } else { diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 0b5bc61f37251..5b59d393213ac 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -8737,15 +8737,15 @@ function wp_get_default_update_php_url() { * @param string $before Markup to output before the annotation. Default `

    `. * @param string $after Markup to output after the annotation. Default `

    `. * @param bool $display Whether to echo or return the markup. Default `true` for echo. - * @return string|void Update PHP page annotation if available and `$display` is false, - * nothing otherwise. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void Update PHP page annotation when `$display` is false, null when no + * annotation is available. Nothing otherwise. + * @phpstan-return ( $display is true ? void : string|null ) */ function wp_update_php_annotation( $before = '

    ', $after = '

    ', $display = true ) { $annotation = wp_get_update_php_annotation(); if ( ! $annotation ) { - return; + return null; } if ( ! $display ) { diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index c10ba50395248..85493cb0b059f 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -1699,14 +1699,15 @@ function wp_title( $sep = '»', $display = true, $seplocation = '' ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void Title when retrieving, null on failure. + * Nothing when displaying. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_post_title( $prefix = '', $display = true ) { $_post = get_queried_object(); if ( ! isset( $_post->post_title ) ) { - return; + return null; } /** @@ -1736,12 +1737,13 @@ function single_post_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void Title when retrieving, null on failure. + * Nothing when displaying. + * @phpstan-return ( $display is true ? void : string|null ) */ function post_type_archive_title( $prefix = '', $display = true ) { if ( ! is_post_type_archive() ) { - return; + return null; } $post_type = get_query_var( 'post_type' ); @@ -1779,7 +1781,8 @@ function post_type_archive_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving, nothing when displaying or on failure. + * @return string|void Title when retrieving, null on failure. + * Nothing when displaying. * @phpstan-return ( $display is true ? void : string|null ) */ function single_cat_title( $prefix = '', $display = true ) { @@ -1801,7 +1804,8 @@ function single_cat_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving, nothing when displaying or on failure. + * @return string|void Title when retrieving, null on failure. + * Nothing when displaying. * @phpstan-return ( $display is true ? void : string|null ) */ function single_tag_title( $prefix = '', $display = true ) { @@ -1823,14 +1827,15 @@ function single_tag_title( $prefix = '', $display = true ) { * * @param string $prefix Optional. What to display before the title. * @param bool $display Optional. Whether to display or retrieve title. Default true. - * @return string|void Title when retrieving, nothing when displaying or on failure. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void Title when retrieving, null on failure. + * Nothing when displaying. + * @phpstan-return ( $display is true ? void : string|null ) */ function single_term_title( $prefix = '', $display = true ) { $term = get_queried_object(); if ( ! $term ) { - return; + return null; } if ( is_category() ) { @@ -1861,11 +1866,11 @@ function single_term_title( $prefix = '', $display = true ) { */ $term_name = apply_filters( 'single_term_title', $term->name ); } else { - return; + return null; } if ( empty( $term_name ) ) { - return; + return null; } if ( ! $display ) { @@ -2224,11 +2229,12 @@ function get_archives_link( $url, $text, $format = 'html', $before = '', $after * @type string $day Day. Default current day. * @type string $w Week. Default current week. * } - * @return string|void Archive links if 'echo' is false, nothing otherwise. + * @return string|void Archive links when 'echo' is false, null when the post type is + * not viewable. Nothing otherwise. * @phpstan-return ( * $args is array{ echo: false|0|''|'0', ... } - * ? string|void - * : ( $args is ''|array ? void : string|void ) + * ? string|null + * : ( $args is ''|array ? void : string|null ) * ) */ function wp_get_archives( $args = '' ) { @@ -2265,7 +2271,7 @@ function wp_get_archives( $args = '' ) { $post_type_object = get_post_type_object( $parsed_args['post_type'] ); if ( ! is_post_type_viewable( $post_type_object ) ) { - return; + return null; } $parsed_args['post_type'] = $post_type_object->name; @@ -2499,8 +2505,9 @@ function calendar_week_mod( $num ) { * @type bool $display Whether to display the calendar output. Default true. * @type string $post_type Optional. Post type. Default 'post'. * } - * @return string|void Calendar HTML if `$display` is false, nothing otherwise. - * @phpstan-return ( $args is array{ display: false|0|''|'0', ... } ? string|void : void ) + * @return string|void Calendar HTML when `$display` is false, null when the site has + * no posts. Nothing otherwise. + * @phpstan-return ( $args is array{ display: false|0|''|'0', ... } ? string|null : void ) */ function get_calendar( $args = array() ) { global $wpdb, $m, $monthnum, $year, $wp_locale, $posts; @@ -2611,7 +2618,7 @@ function get_calendar( $args = array() ) { if ( ! $gotsome ) { $cache[ $key ] = ''; wp_cache_set( 'get_calendar', $cache, 'calendar' ); - return; + return null; } } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 5e7c4fe37c1d7..a785e12d36d47 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1131,9 +1131,9 @@ function get_edit_term_link( $term, $taxonomy = '', $object_type = '' ) { * @param string $after Optional. Display after edit link. Default empty. * @param int|WP_Term|null $term Optional. Term ID or object. If null, the queried object will be inspected. Default null. * @param bool $display Optional. Whether or not to echo the return. Default true. - * @return string|void HTML content when retrieving, nothing when displaying, - * on failure, or without the capability to edit the term. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void HTML content when retrieving, null on failure or without the + * capability to edit the term. Nothing when displaying. + * @phpstan-return ( $display is true ? void : string|null ) */ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $display = true ) { if ( is_null( $term ) ) { @@ -1143,11 +1143,11 @@ function edit_term_link( $link = '', $before = '', $after = '', $term = null, $d } if ( ! $term ) { - return; + return null; } if ( ! current_user_can( 'edit_term', $term->term_id ) ) { - return; + return null; } if ( empty( $link ) ) { @@ -3267,22 +3267,22 @@ function previous_comments_link( $label = '' ) { * * @param string|array $args Optional args. See paginate_links(). Default empty array. * @return string|string[]|void Markup for comment page links, or an array of them when the 'type' - * argument is 'array'. Nothing when 'echo' is true and 'type' is not - * 'array', or if the query is not for an existing single post of any - * post type. + * argument is 'array'. Null if the query is not for an existing single + * post of any post type. Nothing when 'echo' is true and 'type' is not + * 'array'. * @phpstan-return ( * $args is array{ type: 'array', ... } - * ? string[]|void + * ? string[]|null * : ( $args is array{ echo: false|0|''|'0', ... } - * ? string|void - * : ( $args is ''|array ? void : string|string[]|void ) ) + * ? string|null + * : ( $args is ''|array ? void : string|string[]|null ) ) * ) */ function paginate_comments_links( $args = array() ) { global $wp_rewrite; if ( ! is_singular() ) { - return; + return null; } $page = get_query_var( 'cpage' ); diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index 7db987367a442..0c21f6815a28b 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -37,15 +37,15 @@ function get_the_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * @param string $before Optional. Markup to prepend to the title. Default empty. * @param string $after Optional. Markup to append to the title. Default empty. * @param bool $display Optional. Whether to echo or return the title. Default true for echo. - * @return string|void Current post title if `$display` is false, nothing otherwise - * or when the title is empty. - * @phpstan-return ( $display is true ? void : string|void ) + * @return string|void Current post title when `$display` is false, null when the title + * is empty. Nothing otherwise. + * @phpstan-return ( $display is true ? void : string|null ) */ function the_title( $before = '', $after = '', $display = true ) { $title = get_the_title(); if ( strlen( $title ) === 0 ) { - return; + return null; } $title = $before . $title . $after; @@ -77,12 +77,12 @@ function the_title( $before = '', $after = '', $display = true ) { * @type bool $echo Whether to echo or return the title. Default true for echo. * @type WP_Post $post Current post object to retrieve the title for. * } - * @return string|void The title attribute if 'echo' is false, nothing otherwise - * or when the title is empty. + * @return string|void The title attribute when 'echo' is false, null when the title is + * empty. Nothing otherwise. * @phpstan-return ( * $args is array{ echo: false|0|''|'0', ... } - * ? string|void - * : ( $args is ''|array ? void : string|void ) + * ? string|null + * : ( $args is ''|array ? void : string|null ) * ) */ function the_title_attribute( $args = '' ) { @@ -97,7 +97,7 @@ function the_title_attribute( $args = '' ) { $title = get_the_title( $parsed_args['post'] ); if ( strlen( $title ) === 0 ) { - return; + return null; } $title = $parsed_args['before'] . $title . $parsed_args['after']; From 41f35347a72d45d1b8788ceef0da5633c9ac0fe9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 15:44:21 -0700 Subject: [PATCH 08/11] Add the conditional return type to `trackback_url()` A sweep for `void` sitting in a union with no conditional to resolve it turns up one more function of this shape in core. `trackback_url()` echoes the trackback URL or returns it, exactly like the tags already covered, and it has no bail, so the condition is the simple one: `void` when printing, `string` when not. Its `@param` said the argument was "Not used.", which is wrong and was actively misleading now that the return type depends on it. It is read twice, once to warn that retrieving the value this way has been deprecated since 2.5.0 and again to choose between echoing and returning. The description now leads with "Deprecated." and points at `get_trackback_url()`, following `the_author()`, whose identically named argument is documented that way, and a changelog entry records the deprecation in the terse form `_wp_can_use_pcre_u()` uses. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/comment-template.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index cbd2ec835252d..68091c5f4c65e 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -1240,10 +1240,14 @@ function get_trackback_url() { * Displays the current post's trackback URL. * * @since 0.71 + * @since 2.5.0 Deprecated the `$deprecated_echo` argument. * - * @param bool $deprecated_echo Not used. - * @return void|string Should only be used to echo the trackback URL, use get_trackback_url() - * for the result instead. + * @see get_trackback_url() + * + * @param bool $deprecated_echo Deprecated. Use {@see get_trackback_url()}. Echo the URL or + * return it. Default true. + * @return string|void The trackback URL when `$deprecated_echo` is false, nothing otherwise. + * @phpstan-return ( $deprecated_echo is true ? void : string ) */ function trackback_url( $deprecated_echo = true ) { if ( true !== $deprecated_echo ) { From 63daacd97453509e17b270ee9f2d505410360960 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 15:45:03 -0700 Subject: [PATCH 09/11] Add the conditional return type to `twentytwenty_site_description()` The bundled theme has one function of the same shape as the template tags covered here: it echoes the site description or returns it, depending on a `$display` argument, and bails with a bare `return;` when the site has no description. It takes the same treatment, `void` when printing and `string|null` when not, with the bail made explicit since in retrieval mode that null is the value the caller receives. `twentytwenty_site_logo()` is the near neighbor and is left alone. It returns an empty string when the site has no title, on a path shared by both modes, so its printing branch is `string|void` rather than `void` and cannot be reported. Changing that `return '';` would alter what callers receive, which is not worth doing for an annotation. `twentytwenty_get_post_meta()` and `twentytwentyfive_format_binding()` carry `void` in a union too, but neither takes a print-or-return argument; the `void` covers their bails, and there is nothing for a condition to switch on. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-content/themes/twentytwenty/inc/template-tags.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-content/themes/twentytwenty/inc/template-tags.php b/src/wp-content/themes/twentytwenty/inc/template-tags.php index e15ae6652bbec..28eaad2b4e42d 100644 --- a/src/wp-content/themes/twentytwenty/inc/template-tags.php +++ b/src/wp-content/themes/twentytwenty/inc/template-tags.php @@ -107,13 +107,15 @@ function twentytwenty_site_logo( $args = array(), $display = true ) { * @since Twenty Twenty 1.0 * * @param bool $display Display or return the HTML. - * @return string|void The HTML to display. + * @return string|void The HTML when `$display` is false, null when the site has no + * description. Nothing otherwise. + * @phpstan-return ( $display is true ? void : string|null ) */ function twentytwenty_site_description( $display = true ) { $description = get_bloginfo( 'description' ); if ( ! $description ) { - return; + return null; } $wrapper = '
    %s
    '; From aff028da0c3afee05c81ac8a14b819a767357708 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 23:03:55 -0700 Subject: [PATCH 10/11] Account for the legacy boolean argument in `get_search_form()` The condition added for this function only described the array form, so `get_search_form( false )` resolved to `void` while at runtime it returns the markup. The function still honors the boolean `$echo` flag that r44956 replaced with `$args`, casting a non-array argument to bool and using it as the flag, and the condition skipped straight past that. An earlier guard would have covered it, but was dropped because PHPStan reported `$args is array` as always true. That report was the symptom rather than the problem: `@param array $args` is what makes the branch look unreachable, while a call passing `false` is still resolved against the argument's own type, so the branch that mattered was removed and the wrong one kept. Declaring the legacy form in `@phpstan-param` makes both agree. `get_search_form( false )` now resolves to `string`, an explicit `true` and a bare call still resolve to `void`, and a variable of unknown boolean value resolves to the union, so nothing is reported where the flag cannot be determined. `get_calendar()` accepts legacy positional arguments too, but its first one sets `initial` rather than `display`, so `get_calendar( false )` prints and resolving it to `void` is already right. `wp_login_form()` has no such back-compat branch. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/general-template.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 85493cb0b059f..b56619af29b33 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -237,7 +237,12 @@ function get_template_part( $slug, $name = null, $args = array() ) { * accessibility. Default empty. * } * @return string|void Search form HTML if 'echo' is false, nothing otherwise. - * @phpstan-return ( $args is array{ echo: false|0|''|'0', ... } ? string : void ) + * @phpstan-param array|bool $args + * @phpstan-return ( + * $args is array{ echo: false|0|''|'0', ... } + * ? string + * : ( $args is false|0|''|'0' ? string : void ) + * ) */ function get_search_form( $args = array() ) { /** From 179a78c9b3419ee5f7f8b6b5b7482848f1e34686 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 1 Sep 2026 23:49:51 -0700 Subject: [PATCH 11/11] Note the empty string these three tags can return `next_posts()` promises "the link URL for next posts page" when retrieving, but it maps a missing link to an empty string, so that is what a caller gets on the last page. `previous_posts()` has the identical shape, and `wp_register()` promises a registration or admin link while setting the value to an empty string when registration is disabled and the visitor is logged out, or when a logged-in user cannot reach the dashboard. The types were already right, since an empty string is a string. It is the descriptions that promised something the functions do not always deliver, and a caller reading only the summary would not think to guard against it. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/general-template.php | 5 +++-- src/wp-includes/link-template.php | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index b56619af29b33..e5c65279e52b5 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -909,8 +909,9 @@ function wp_lostpassword_url( $redirect = '' ) { * @param string $before Text to output before the link. Default `
  • `. * @param string $after Text to output after the link. Default `
  • `. * @param bool $display Default to echo and not return the link. - * @return string|void Registration or admin link if `$display` is false, - * nothing otherwise. + * @return string|void The registration or admin link when `$display` is false, or an empty + * string when registration is disabled or the logged-in user cannot + * access the dashboard. Nothing otherwise. * @phpstan-return ( $display is true ? void : string ) */ function wp_register( $before = '
  • ', $after = '
  • ', $display = true ) { diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index a785e12d36d47..cabb7cedfab21 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -2543,7 +2543,8 @@ function get_next_posts_page_link( $max_page = 0 ) { * * @param int $max_page Optional. Max pages. Default 0. * @param bool $display Optional. Whether to echo the link. Default true. - * @return string|void The link URL for next posts page if `$display = false`. + * @return string|void The next posts page link when `$display` is false, or an empty + * string when there is no next page. Nothing otherwise. * @phpstan-return ( $display is true ? void : string ) */ function next_posts( $max_page = 0, $display = true ) { @@ -2654,7 +2655,8 @@ function get_previous_posts_page_link() { * @since 0.71 * * @param bool $display Optional. Whether to echo the link. Default true. - * @return string|void The previous posts page link if `$display = false`. + * @return string|void The previous posts page link when `$display` is false, or an empty + * string when there is no previous page. Nothing otherwise. * @phpstan-return ( $display is true ? void : string ) */ function previous_posts( $display = true ) {