Skip to content

Restore void on the dual-mode $display functions with conditional return types - #13359

Closed
westonruter wants to merge 11 commits into
WordPress:trunkfrom
westonruter:fix/display-conditional-void-returns
Closed

Restore void on the dual-mode $display functions with conditional return types#13359
westonruter wants to merge 11 commits into
WordPress:trunkfrom
westonruter:fix/display-conditional-void-returns

Conversation

@westonruter

@westonruter westonruter commented Sep 1, 2026

Copy link
Copy Markdown
Member

✅ Committed in:


Follow-up to r63379, addressing review feedback from @IanDelMar on #13082.

The point raised there was that the void unions on the functions taking a $display parameter were less a type-system problem than a consequence of those functions having two responsibilities: depending on the argument they either print a result or return one. Replacing void with null removed the union, but it also removed information: Under string|null PHPStan treats the display-mode result as a legitimate value, so consuming a meaningless one is no longer reported.

That is correct, and it turns out to understate the problem.

void in a plain union conveys nothing

PHPStan raises Result of function … (void) is used. only when the resolved return type is exactly void. A plain union never resolves to that, so @return string|void and @return void|string carry no more information than string|null does. Verified with two otherwise identical functions:

/** @return void|string */
function plain_union( string $prefix = '', bool $display = true ) { … }

/**
 * @return void|string
 *
 * @phpstan-return ( $display is true ? void : string )
 */
function with_conditional( string $prefix = '', bool $display = true ) { … }

var_dump( plain_union( 'x' ) );       // no error
var_dump( with_conditional( 'x' ) );  // Result of function with_conditional (void) is used. [function.void]

Only the conditional annotation does any work. So this is not a matter of putting back what r61768 and r63379 took away — the functions that still carry void today were never getting anything from it either.

The shape used throughout

Every conditional added here has one branch that is exactly void, meaning the call has no value to give, and one branch that is the retrieval type, unioned with null where the function can bail. Bails reachable in retrieval mode say return null;, because in that mode the null is a value the caller observes; a bare return; is kept only where it can never be reached by a caller expecting a value.

The two voids are not redundant. The void branch is what makes the display-mode call resolve to exactly void and so be reported. The null in the other branch is what keeps strlen( post_type_archive_title( '', false ) ) reported as passing a possible null. Narrowing that branch to plain string would trade a real bug class for nothing.

Changes

Eleven commits, each independently reviewable.

1. The nine functions from r63379. wp_title(), single_post_title(), post_type_archive_title(), single_term_title(), the_date(), the_modified_date(), edit_term_link(), next_posts() and previous_posts() go back to string|void and gain a conditional @phpstan-return. The trailing return null; statements added by r63379 are removed, since void in the union licenses falling off the end.

single_cat_title() and single_tag_title() delegate to single_term_title() and take the same annotation, which requires expanding their one-line body into an early return — returning the delegate's value unconditionally never returns void, and PHPStan reports the void as unused.

2. Five more with the same shape, found by sweeping core for the pattern: comment_class(), the_title(), wp_loginout(), wp_register() and wp_update_php_annotation(). The first four already documented void|string and so, per the above, were getting nothing for it. wp_update_php_annotation() needs a small body change: its trailing return null; is reached in both modes, so the missing-annotation path becomes an early bail instead. No behavior change.

3. An unrelated docs bug found by the same sweep. WP_Styles::print_inline_style(), WP_Scripts::print_extra_script() and the deprecated WP_Scripts::print_scripts_l10n() document their return inverted: 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. Corrected, and given conditional annotations as well — not for void detection, but for narrowing:

Call Before After
print_inline_style( $h ) string|bool bool
print_inline_style( $h, false ) string|bool string|false
print_extra_script( $h ) bool|string|null true|null
print_extra_script( $h, false ) bool|string|null string|null

This matters at the two internal call sites that pass false and then use the result as a string, where true was previously considered possible. Happy to split this commit off into its own ticket if preferred.

4. The tags taking the flag inside an $args array. 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().

These were nearly left out, 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 wp_list_categories( 'echo=0&title_li=' ) and report correct code as an error. Testing rather than reasoning about it showed two of the assumptions behind that to be wrong:

  • Array shapes in a condition are not sealed. array( 'echo' => false, 'aria_label' => 'a' ) matches array{ echo: false, ... }, so extra keys are not a problem.
  • The query-string case is avoidable with a third branch. When $args is neither the falsy-flag shape nor an array, the type stays a union and nothing is reported. All that gives up is the undecidable call styles — the bare the_title_attribute(), the empty array and an explicit truthy flag all still resolve to void.

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. Matching only false reports array( 'echo' => 0 ) as void while it actually returns the markup.

wp_tag_cloud() and paginate_comments_links() 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. Where $args is documented as an array the third branch is always true and PHPStan says so, so those take the plain two-branch form.

Narrowing paginate_comments_links() to string[] resolves three existing errors as a side effect, 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.

wp_dropdown_languages() turns out not to belong to this group at all — it prints and then returns the markup regardless of the flag, so its void is correct for the bail on a missing id or name, and only the description needed saying.

5–7. Settling on one shape. Addressing @copilot's review, which spotted that some conditionals gave the retrieval branch as string|null while the @return above said string|void, so the two tags disagreed about the third outcome. Resolved in the direction described under The shape used throughout, rather than by adding null to the public tag, which would have produced string|null|void — not an idiom used elsewhere in core, and one that blurs the distinction this pull request restores. single_cat_title() and single_tag_title() keep null, correctly: neither bails, and their retrieval path hands back the delegate's value.

8. trackback_url(), the last function in core with this shape. No bail, so the condition is the simple one. Its @param also said the argument was "Not used.", which is wrong — it is read twice, once to raise the 2.5.0 deprecation notice and again to choose between echoing and returning.

9. twentytwenty_site_description(), the same shape in a bundled theme. Also separable onto its own ticket if preferred.

10. A regression this pull request introduced, caught by @copilot. get_search_form() still honors the boolean $echo flag that r44956 replaced with $args, casting a non-array argument to bool and using it as the flag. The condition described only the array form, so get_search_form( false ) resolved to void while at runtime it returns the markup.

The guard that would have covered this was present earlier on the branch and was removed because PHPStan reported $args is array as Condition … is always true. That report was the symptom rather than the problem: @param array $args is what makes the non-array branch look unreachable, while a call passing false is still resolved against the argument's own type — so the branch that mattered went and the wrong one stayed. Declaring the legacy form in @phpstan-param array<string, mixed>|bool $args makes the two agree, and the condition then nests the boolean case:

Call Resolves to Reported
get_search_form( false ) string no
get_search_form( true ) void yes
get_search_form() void yes
get_search_form( array( 'echo' => false ) ) string no
get_search_form( $someBool ) string|null no

The last row is why the boolean case is nested rather than folded into the first branch: where the flag cannot be determined the type stays a union and nothing is reported.

The neighbors were checked for the same trap. get_calendar() also accepts legacy positional arguments, but its first one sets initial rather than display, so get_calendar( false ) prints and resolving it to void is already correct. wp_login_form() has no back-compat branch.

11. Three descriptions that promise more than the function delivers, from a further @copilot pass. next_posts() says it returns "the link URL for next posts page" when retrieving, but maps a missing link to an empty string, which 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 — an empty string is a string — so only the descriptions changed.

Deliberately not changed

Sweeping all of src for void in a return union turns up 35 further symbols in core and the bundled themes. None of them can drop the void, and none of them can be given a conditional that resolves to it. Grouped by why:

Dual-mode, but the printing branch cannot be void. Each returns a meaningful value on a path shared by both modes, so that branch is X|void and never resolves to plain void. Changing what those paths return would alter what callers receive, which is not worth doing for an annotation:

Function Returns on the shared path
single_month_title() false when there is no valid title for the month
wp_list_categories() false when the taxonomy does not exist
wp_nav_menu() false when no menu is found, plus the fallback_cb result
twentytwenty_site_logo() '' when the site has no title

Not dual-mode at all — the void covers only a bail, and there is no argument for a condition to switch on: wp_dropdown_languages(), twentytwenty_get_post_meta() and twentytwentyfive_format_binding(). WP_Widget::form() belongs here too: whether it returns anything depends on the subclass, not on an argument, which is what r59336 recorded when it added the void.

Void on success, error on failure — 26 functions whose failure value is not predictable from the arguments and is legitimately consumed, as in if ( false === get_header() ). The void in the union is correct and no report is wanted:

  • void|falseget_header(), get_footer(), get_sidebar(), get_template_part(), add_theme_support(), do_enclose(), do_trackbacks(), the_terms(), update_object_term_cache(), update_user_caches(), WP_Customize_Setting::save(), site_admin_notice(), parent_dropdown(), maintenance_nag(), update_nag(), wp_plugin_update_row(), wp_theme_update_row(), make_site_theme_from_default(), wp_dropdown_cats()
  • void|WP_Error and friends — register_importer(), _fix_attachment_links(), WP_Image_Editor_Imagick::thumbnail_image(), WP_Metadata_Lazyloader::queue_objects(), WP_Metadata_Lazyloader::reset_queue(), WP_XMLRPC_Server::_toggle_sticky(), WP_REST_Edit_Site_Export_Controller::export()

Also unchanged are the functions that print and then return the value unconditionally, where the result is always meaningful and no void is involved: wp_nonce_field(), wp_referer_field(), wp_original_referer_field(), checked() / selected() / disabled() / wp_readonly() / readonly() and __checked_selected_helper(), menu_page_url(), _post_states(), _media_states(), timer_stop(), wp_popular_terms_checklist(), wp_nav_menu_disabled_check(), WP_Scripts::print_inline_script() and WP_Scripts::print_translations(). Deprecated functions of this shape are left alone as well: the_category_ID(), get_author_link(), get_category_rss_link(), get_author_rss_link(), get_most_active_blogs() and wp_get_links().

Verification

  • A temporary probe file confirms that every function touched here resolves to plain void in printing mode and is reported when consumed, and that none of them are reported in retrieval mode — including echo => 0 as well as echo => false, flags accompanied by other keys, and format/type of 'array'. Query-string and dynamic $args are reported in neither mode, nor is get_search_form()'s legacy boolean argument where its value cannot be determined. Retrieval-mode calls type exactly as they did before.
  • Full PHPStan runs at level 10 before and after report the same error set, less the three resolved by the paginate_comments_links() narrowing. Two reports restate a narrower expected type without changing meaning: the pre-existing return.type on print_extra_script() and on wp_list_comments(), both of which stem from a mixed reaching the return.
  • Worth noting for anyone reproducing that: the analysis is not deterministic. Two runs of identical, unmodified code with the result cache cleared differ by three to five errors, all of them array-shape inferences resolving to mixed in one run and to a shape in another. The comparisons above were repeated until stable, and phpstan-diff --changed --staged is clean on every commit.
  • tests/phpstan/baselines/return.missing.neon stays deleted — void in the union is precisely what licenses falling off the end.
  • PHPCS reports no new issues.
  • PHPUnit: Tests_General_, Tests_Link_, Tests_Date_, Tests_Post_, Tests_Comment_, Tests_Term_, Tests_Category_, Tests_User_, Tests_Functions_ and Tests_Dependencies_ all pass.

Trac ticket: https://core.trac.wordpress.org/ticket/65817

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5
Used for: Evaluating the review feedback, sweeping core for other instances of the pattern, and drafting the annotations and this description. The PHPStan and PHPUnit verification was run against the working tree, and the result has been reviewed and is owned by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

westonruter and others added 3 commits September 1, 2026 12:41
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) <noreply@anthropic.com>
…ay` tags

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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, apermo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates PHPDoc/PHPStan typing for WordPress “dual-mode” template functions that either echo output ($display = true) or return it ($display = false), restoring void semantics via conditional @phpstan-return annotations so PHPStan can flag misuse of display-mode results. It also corrects return-value documentation/typing for a few WP_Styles/WP_Scripts $display-controlled methods.

Changes:

  • Restores string|void style PHPDoc unions for $display functions and adds conditional @phpstan-return annotations to distinguish echo vs return modes.
  • Removes trailing return null; statements where “falling off the end” is intended for display mode.
  • Fixes inverted/overbroad return documentation for WP_Styles::print_inline_style() and WP_Scripts::{print_extra_script,print_scripts_l10n}() and adds conditional @phpstan-return narrowing.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/wp-includes/post-template.php Adds conditional PHPStan return typing for the_title() dual-mode behavior.
src/wp-includes/link-template.php Updates edit_term_link(), next_posts(), previous_posts() return docs and removes trailing return null; in display mode.
src/wp-includes/general-template.php Updates multiple title/date-related $display functions with `string
src/wp-includes/functions.php Adjusts wp_update_php_annotation() to bail early and adds conditional @phpstan-return typing.
src/wp-includes/comment-template.php Updates comment_class() return typing with conditional @phpstan-return.
src/wp-includes/class-wp-styles.php Corrects print_inline_style() return documentation and adds conditional narrowing.
src/wp-includes/class-wp-scripts.php Corrects/clarifies $display-dependent return docs and adds conditional narrowing for extra-script printing methods.
Suppressed comments (4)

src/wp-includes/general-template.php:1781

  • The docblock return type omits null, but single_cat_title() can return null in retrieval mode because it delegates to single_term_title(), which bails out with return; on failure/empty term name. Since the conditional @phpstan-return already indicates string|null for $display = false, the public @return should include null too.
 * @return string|void Title when retrieving.
 * @phpstan-return ( $display is true ? void : string|null )

src/wp-includes/general-template.php:1803

  • The docblock return type omits null, but single_tag_title() can return null in retrieval mode because it delegates to single_term_title(), which bails out with return; on failure/empty term name. Since the conditional @phpstan-return already indicates string|null for $display = false, the public @return should include null too.
 * @return string|void Title when retrieving.
 * @phpstan-return ( $display is true ? void : string|null )

src/wp-includes/general-template.php:1825

  • The docblock return type omits null, but single_term_title() can return null (via return;) when there is no queried term, when not in a supported taxonomy context, or when the term name is empty. Since the conditional @phpstan-return already indicates string|null for $display = false, the public @return should include null to keep the documentation accurate.
 * @return string|void Title when retrieving.
 * @phpstan-return ( $display is true ? void : string|null )

src/wp-includes/general-template.php:1738

  • The docblock return type omits null, but post_type_archive_title() can return null on failure (e.g. when not on a post type archive). Since the conditional @phpstan-return already indicates string|null for $display = false, the public @return should include null to keep the documentation accurate.
 * @return string|void Title when retrieving, nothing when displaying or on failure.
 * @phpstan-return ( $display is true ? void : string|null )

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/wp-includes/functions.php Outdated
Comment thread src/wp-includes/general-template.php Outdated
Comment thread src/wp-includes/link-template.php Outdated
Comment thread src/wp-includes/post-template.php Outdated
westonruter and others added 6 commits September 1, 2026 13:28
…late 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…her 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@westonruter
westonruter requested a lite review from Copilot September 1, 2026 22:51
@westonruter

Copy link
Copy Markdown
Member Author

@IanDelMar @apermo I'd appreciate your review of this.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

get_search_form()’s new @phpstan-return does not account for legacy non-array arguments (e.g. get_search_form(false)), causing an incorrect void resolution in PHPStan.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/wp-includes/general-template.php
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) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are predominantly docblock/type-annotation refinements with low behavioral risk, and the remaining notes are minor clarity/consistency improvements.

Review details

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

src/wp-includes/functions.php:8749

  • The early bail when no annotation is available currently returns null even when $display is true, while the @phpstan-return models display-mode calls as void. Returning void in display mode (and null only when retrieving) keeps the implementation consistent with the documented dual-mode return intent.
    src/wp-includes/link-template.php:2547
  • next_posts() returns an empty string when no next page exists (it maps a null link to ''), so the docblock should mention that $display = false can yield an empty string rather than a URL.
    src/wp-includes/post-template.php:49
  • The empty-title early bail uses return null; even when $display is true, but the accompanying @phpstan-return models the display-mode call as void. To keep the implementation aligned with the intended dual-mode semantics, return void when displaying and only return null in retrieval mode.

This issue also appears on line 99 of the same file.

src/wp-includes/post-template.php:101

  • Like the_title(), this empty-title bail returns null even when $parsed_args['echo'] is truthy. Since the docblock/@phpstan-return treat the echoing branch as void, consider returning void when echoing and only returning null for the non-echoing path.
	if ( strlen( $title ) === 0 ) {
		return null;
	}
  • Files reviewed: 13/13 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

`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) <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5

Replying to the four suppressed notes in @Copilot's review, since none of them carry a thread to answer in.

The next_posts() note was correct and is fixed

Addressed in 179a78c, and extended to two more places with the same defect. next_posts() maps a missing link to an empty string, so on the last page a caller gets '' rather than the "link URL" the description promised. 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, an empty string being a string, so only the descriptions changed.

The other three stand, deliberately

Those notes all make one point: the shared bails say return null; even in printing mode, while the condition models printing mode as void, and they suggest splitting each bail so that null is returned only when retrieving.

That shape was considered and rejected in favor of the current one. Three things informed the decision.

Both spellings behave identically. PHPStan does not cross-check a conditional return type's branches against the function's actual return statements; this was verified directly, with a bail returning null on a path reachable while printing and a branch declaring void. Printing-mode calls still resolve to plain void and are still reported, and retrieval-mode calls still resolve to string|null. Nothing is gained or lost in analysis either way, so this is a question of documentation philosophy rather than of capability.

The two readings of void are both defensible. The note reads void literally, as "executes no value-returning return". The reading used here is PHPStan's own, where void means "no meaningful value" and resolves to null wherever a value is required. A failure bail reached while printing is exactly that: the markup was not produced, and the caller is not looking at the return value. Writing return null; there is not a claim that null is meaningful; it is the same statement the retrieval path needs, and the condition is what distinguishes the two modes.

The cost is real and the benefit is not. Twenty bails across twelve functions would each grow a nested branch, adding roughly sixty lines to hot template tags purely to satisfy an annotation that already resolves correctly. Diverging from the uniform shape is also what produced the inconsistency raised in the earlier review, where some branches read string|null and others string|void; that was settled by making every condition read the same way, and re-introducing a per-bail distinction would work against it.

A clarification on wp_update_php_annotation(), which may look self-contradictory: this pull request removed a return null; from it and then added one back. Those are different statements. The one removed sat at the end of the function, reachable only after the markup had been echoed, and asserted a value where the caller is not looking. The one now present sits before the print-or-return split and is reached in both modes, so when retrieving, that null genuinely is the returned value. The same distinction governs which bails were converted elsewhere: those reachable while retrieving say return null;, and the paths inside if ( $args['display'] ) in get_calendar(), which run only after the markup is echoed, keep a bare return;.

@apermo apermo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check my own comments, other than that, looking good to me.

* @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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems massive, that is the complete opposite of before, was this wrong all the way?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes 😬

* @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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as below, this change seems massive, I can't believe that this slipped through and was wrong twice all the time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double checked, these were wrong all the way since their introduction in WP4.5 nearly 10 years ago.

@apermo

apermo commented Sep 2, 2026

Copy link
Copy Markdown

I am checking all core for similar errors in "display or return" booleans. If there is a significant number i'll create a new ticket and a new pr for that.

@apermo

apermo commented Sep 2, 2026

Copy link
Copy Markdown

The sweep is done, and it came back clean.

Scope was every function and method in src that takes a boolean display-or-return flag: $display, $echo, $force_echo, $deprecated_echo, $display_message, plus the ones taking it as $args['echo'] or $args['display']. Around 70 symbols in wp-includes, wp-admin and the bundled themes. For each one the @return description was compared against the branch in the body that actually splits printing from returning.

The three you found in WP_Styles and WP_Scripts are the only inversions in core. Nothing else has the two branches swapped.

Two more in that group looked wrong at first, but both are already handled. wp_dropdown_languages() and twentytwenty_generate_css() both had a @return string with a bare return; on their bail, and dmsnell and nomadmystic fixed both in r63314. Your PR then sharpened the wp_dropdown_languages() description on top of that. I only saw them because I first ran the sweep against a checkout that was a few weeks behind trunk. My bad.

PHPStan agrees: on your branch with the repo config there is no return.empty and no return.missing left in src.

So from my side there is nothing else to fix here.

Disclosure: I used Claude Code (Opus 5) for the sweep and for drafting this comment. I reviewed the findings and ran the PHPStan verification in my own checkout.

pento pushed a commit that referenced this pull request Sep 2, 2026
`WP_Styles::print_inline_style()`, `WP_Scripts::print_extra_script()` and the deprecated `WP_Scripts::print_scripts_l10n()` each document their return the wrong way round. All three say the markup comes back when `$display` is true, but the string is returned on the `! $display` branch and the printing branch returns `true`. The wording has read this way since r36744, so anyone consulting it to decide which argument to pass was told the opposite of what the code does.

Swapping `true` for `false` in the three descriptions corrects that, and a conditional `@phpstan-return` pins the two behaviors apart, since the plain unions collapse the distinction. `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.

`WP_Scripts::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.

Developed as subset of #13359.
Follow-up to r36744, r62178.

Props apermo, westonruter.
See #65817.


git-svn-id: https://develop.svn.wordpress.org/trunk@63440 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Sep 2, 2026
`WP_Styles::print_inline_style()`, `WP_Scripts::print_extra_script()` and the deprecated `WP_Scripts::print_scripts_l10n()` each document their return the wrong way round. All three say the markup comes back when `$display` is true, but the string is returned on the `! $display` branch and the printing branch returns `true`. The wording has read this way since r36744, so anyone consulting it to decide which argument to pass was told the opposite of what the code does.

Swapping `true` for `false` in the three descriptions corrects that, and a conditional `@phpstan-return` pins the two behaviors apart, since the plain unions collapse the distinction. `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.

`WP_Scripts::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.

Developed as subset of WordPress/wordpress-develop#13359.
Follow-up to r36744, r62178.

Props apermo, westonruter.
See #65817.

Built from https://develop.svn.wordpress.org/trunk@63440


git-svn-id: http://core.svn.wordpress.org/trunk@62621 1a063a9b-81f0-0310-95a4-ce76da25c4cd
pento pushed a commit that referenced this pull request Sep 2, 2026
Many template tag functions either print their result or return it, depending on a `display` or `echo` param. Their `@return` carried `void` in a union until r61766, r61768 and r62178 replaced it with `null`, on the premise that `void` cannot belong to a union type. That premise holds for PHP's native return types but not for PHPDoc, where PHPStan reads `void` in a union as "may not return at all".

Restoring it is not enough on its own, though. PHPStan raises the "Result of function … (void) is used." error only when the resolved return type is exactly `void`, which a union never is, so the tags that kept `void` were getting no more out of it than the ones converted to `null`. What carries the distinction is a conditional `@phpstan-return`, resolving to plain `void` when the tag prints and to the type it returns otherwise. Thirty-one functions gain one, including one in the bundled Twenty Twenty theme, each with a single `void` branch and a nullable retrieval branch. The trailing `return null;` statements added in r63378 and r63379 are removed; they existed only to satisfy `return.missing`, which does not apply once `void` is in the union. Where the flag lives in an `$args` array the condition has to match every falsy spelling of it, since these tags variously default it to `true` or to `1`. A call whose flag PHPStan cannot see, such as a query string or an array built at runtime, does not resolve to plain `void`, so it is never reported.

Four descriptions are corrected alongside: `next_posts()`, `previous_posts()` and `wp_register()` promised a link where an empty string is possible, and `wp_dropdown_languages()` prints and then returns the markup rather than choosing between the two. Tags returning a meaningful value on a path shared by both modes cannot resolve to plain `void` and are left alone, among them `single_month_title()`, `wp_list_categories()` and `wp_nav_menu()`.

Developed as subset of #13359.
Follow-up to r32568, r61766, r61768, r62178, r63378, r63379, r63440.

Props marian1, westonruter, apermo.
See #65817, #64704.


git-svn-id: https://develop.svn.wordpress.org/trunk@63441 602fd350-edb4-49c9-b593-d223f7449a82
@westonruter westonruter closed this Sep 2, 2026
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Sep 2, 2026
Many template tag functions either print their result or return it, depending on a `display` or `echo` param. Their `@return` carried `void` in a union until r61766, r61768 and r62178 replaced it with `null`, on the premise that `void` cannot belong to a union type. That premise holds for PHP's native return types but not for PHPDoc, where PHPStan reads `void` in a union as "may not return at all".

Restoring it is not enough on its own, though. PHPStan raises the "Result of function … (void) is used." error only when the resolved return type is exactly `void`, which a union never is, so the tags that kept `void` were getting no more out of it than the ones converted to `null`. What carries the distinction is a conditional `@phpstan-return`, resolving to plain `void` when the tag prints and to the type it returns otherwise. Thirty-one functions gain one, including one in the bundled Twenty Twenty theme, each with a single `void` branch and a nullable retrieval branch. The trailing `return null;` statements added in r63378 and r63379 are removed; they existed only to satisfy `return.missing`, which does not apply once `void` is in the union. Where the flag lives in an `$args` array the condition has to match every falsy spelling of it, since these tags variously default it to `true` or to `1`. A call whose flag PHPStan cannot see, such as a query string or an array built at runtime, does not resolve to plain `void`, so it is never reported.

Four descriptions are corrected alongside: `next_posts()`, `previous_posts()` and `wp_register()` promised a link where an empty string is possible, and `wp_dropdown_languages()` prints and then returns the markup rather than choosing between the two. Tags returning a meaningful value on a path shared by both modes cannot resolve to plain `void` and are left alone, among them `single_month_title()`, `wp_list_categories()` and `wp_nav_menu()`.

Developed as subset of WordPress/wordpress-develop#13359.
Follow-up to r32568, r61766, r61768, r62178, r63378, r63379, r63440.

Props marian1, westonruter, apermo.
See #65817, #64704.

Built from https://develop.svn.wordpress.org/trunk@63441


git-svn-id: http://core.svn.wordpress.org/trunk@62622 1a063a9b-81f0-0310-95a4-ce76da25c4cd
@IanDelMar

Copy link
Copy Markdown

I was only able to have a quick look.

  • @return string|void and @return void|string carry no more information than string|null

    I don't think that is correct. For now, PHPStan seems to treat these as equivalent, but PHPStan evolves and that behaviour may change in the future. For users, void still conveys semantic information: if the function returns the value null, that value has no meaning. This distinction may be irrelevant to some users, but relevant to others. But there may be other opinions on that.

  • wp_tag_cloud(): the $args is ''|array branch could be extended to include '0', although this is very unlikely to have much practical impact. Looking at the conditional return types, the same seems to apply to wp_list_comments(), wp_list_bookmarks(), wp_list_authors(), paginate_comments_links(), the_title_attribute(), wp_list_pages(), wp_page_menu(), and wp_list_users().

  • wpdb::print_error() also returns null at the bottom of the method. I don't think this can be changed back to void simply by adding a conditional return type. It might still be worth documenting it here alongside the other related functions. Although void cannot be part of a native PHP union type, was there actually an issue with using it in the PHPDoc union?

  • print_scripts_l10n() has long been deprecated. Why annotate it with PHPStan tags rather than ignoring any error raised for that function?

  • wp_get_archives(): shouldn't the @return string|void tag also include null, given the semantic distinction between void and null?

  • the_date(), the_modified_date(): the return description says "String if retrieving.". This wording is inconsistent with the descriptions used for the other functions. This also applies to edit_term_link().

  • wp_dropdown_languages(): the description is incorrect. It says that "nothing is returned when the required id or name argument is missing." If those arguments are missing, they are populated with the default 'locale'. The function actually bails when $args['id'] or $args['name'] is falsy. This could be represented as something along the lines of:
    ($args is array{id: null|0|''|'0', ...}|array{name: null|0|''|'0', ...} ? void : string)

  • Unrelated, but I just noticed this: @param true $deprecated_echo on trackback_url() would result in: Parameter #1 $deprecated_echo of function trackback_url expects true, false given.. In php-stubs/wordpress-stubs, this is used deliberately to signal that a deprecated argument was supplied. See: https://phpstan.org/r/2e2aa144-9b64-4815-bf72-2c343512462d

  • General thought: I think the effort being put into improving code quality is very welcome.
    However, I also think that some of the errors reported by PHPStan point to design questions and should prompt us to think about those questions, rather than trying to introduce workarounds solely to silence PHPStan. Once such workarounds land in core, the PHPStan error disappears and therefore no longer indicates that there may be an underlying problem. The issue has not necessarily been resolved; it may simply have been masked.
    I don’t think there is anything wrong with deliberately ignoring some errors. That way, there is still an indication that something may deserve attention, without forcing the implementation or documentation into shapes that primarily exist to satisfy the analyser.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment drafted initially by Claude Opus 5

@IanDelMar Thank you for the review. It seems several of your points were right, and five have been acted on in a follow-up PR: #13371

Responses in order.

void in a plain union

@return string|void and @return void|string carry no more information than string|null

I don't think that is correct. For now, PHPStan seems to treat these as equivalent, but PHPStan evolves and that behaviour may change in the future. For users, void still conveys semantic information: if the function returns the value null, that value has no meaning. This distinction may be irrelevant to some users, but relevant to others. But there may be other opinions on that.

Right, so this is why we need the conditional returns. It's what allows PHPStan to catch an erroneous usage of a void non-value.

That said, the sentence you quoted was too broad on my part. It was only ever a claim about what PHPStan does with a plain union today, not about what the tag means. Your second point stands independently of the analyzer: void tells a reader the value is meaningless in a way null does not. That distinction is what the wp_get_archives() section below now rests on.

'0' in the undecidable branch

wp_tag_cloud(): the $args is ''|array branch could be extended to include '0', although this is very unlikely to have much practical impact. Looking at the conditional return types, the same seems to apply to wp_list_comments(), wp_list_bookmarks(), wp_list_authors(), paginate_comments_links(), the_title_attribute(), wp_list_pages(), wp_page_menu(), and wp_list_users().

Correct, and fixed. The check confirms the reasoning: parse_str( '0', $r ) yields array( 0 => '' ), so wp_parse_args( '0', $defaults ) sets no key the tag reads, the flag keeps its default, and the call prints and returns nothing — indistinguishable from ''. Before the change wp_list_authors( '0' ) resolved to string|null and went unreported while wp_list_authors( '' ) resolved to void.

The branch now reads $args is ''|'0'|array, which also matches how the flag conditions themselves already spell the falsy set as false|0|''|'0'. Ten tags were affected — the nine listed here plus wp_get_archives(), which has the same branch and the same gap. Every other call shape was re-checked and is unchanged: a query string carrying a visible flag, and an $args built at runtime, still stay unions and are reported in neither mode.

Aside: I want to work out an extension to PHPStan which would better handle typing of query strings that get passed to functions like this.

wpdb::print_error()

wpdb::print_error() also returns null at the bottom of the method. I don't think this can be changed back to void simply by adding a conditional return type. It might still be worth documenting it here alongside the other related functions. Although void cannot be part of a native PHP union type, was there actually an issue with using it in the PHPDoc union?

Also correct, and also fixed — with a second method alongside it.

To the question asked: no, there was no issue with void in the PHPDoc union. That premise is the one this pull request set out to correct; it holds for PHP's native return types only.

print_error() documented void|false until r62177, which replaced the void with null and added a trailing return null;. It is the shape defended under Deliberately not changed — the method either succeeds with nothing to hand back, or reports a failure — so leaving it converted contradicted the reasoning applied to the nineteen void|false siblings. check_database_version() in the same class and the same changeset is the void|WP_Error version of the same thing, and its only caller, wp_check_mysql_version(), tests is_wp_error() and ignores the rest. Fixing one and not the other would only have relocated the inconsistency, so both were restored.

As anticipated here, neither can carry a conditional: both switch on object state rather than on an argument, and a PHPStan conditional can only key off a parameter. The distinction is for the reader.

The rest of r62177 was reviewed and stands. prepare() and get_row() return a null their callers genuinely consume; check_connection() and bail() were corrected in the other direction, since their old void branches die rather than return; get_col_info() is documented mixed.

print_scripts_l10n()

print_scripts_l10n() has long been deprecated. Why annotate it with PHPStan tags rather than ignoring any error raised for that function?

No error was being raised for it, so there was nothing to ignore. Given that it it has a conditional return value, I wanted to document it for posterity even if it is deprecated.

Also, what r63440 was fixing is an inverted description: all three methods said the markup comes back when $display is true, when the string is returned on the ! $display branch and the printing branch returns true. That wording had stood since r36744 and is worth correcting on a deprecated method as much as on a live one.

The conditional is one line on a pure pass-through, kept so the deprecated delegate is not documented more loosely than the method it forwards to. Removing it would leave a caller reading print_scripts_l10n() a wider type than print_extra_script() gives for the identical call. It is defensible either way and can be dropped if the preference is to leave deprecated symbols untouched.

wp_get_archives() and null in the tag

wp_get_archives(): shouldn't the @return string|void tag also include null, given the semantic distinction between void and null?

Yes it should, and it now does — for this function and for fourteen others in the same position.

The reason given in the description for not adopting string|null|void was that no such three-way union appears anywhere else in core. That was wrong, and wrong in a telling way. Immediately before r62177 the tree held three of them: WP_Block_Type::__get() as string|string[]|null|void, wpdb::get_row() as array|object|null|void, and one in WP_Theme_JSON as null|void. All three were removed by the same campaign that replaced void in union return types on the premise that a union cannot hold one — the premise r63441 set out to correct. The idiom did exist; it was erased by the mistake being undone here.

Thirteen tags therefore become string|null|void: single_post_title(), post_type_archive_title(), single_cat_title(), single_tag_title(), single_term_title(), wp_get_archives(), get_calendar(), edit_term_link(), the_title(), the_title_attribute(), wp_list_comments(), wp_update_php_annotation() and twentytwenty_site_description(). Two more become string|string[]|null|void: wp_tag_cloud() and paginate_comments_links(). Values first, then null, then void, following the order the tree used before r62177.

The test applied is the retrieval branch of the conditional rather than the wording of the description, so null is named only where a caller can actually observe it. wp_list_pages(), wp_page_menu(), wp_list_authors(), wp_list_bookmarks() and wp_list_users() look like the same shape but are deliberately left alone: their null appears only in the undecidable branch covering a query string or an $args built at runtime, which is the union of both modes rather than a value retrieval mode can return. Their retrieval branch is plain string, so string|void is already complete.

the_date(), the_modified_date() and edit_term_link()

the_date(), the_modified_date(): the return description says "String if retrieving.". This wording is inconsistent with the descriptions used for the other functions. This also applies to edit_term_link().

Agreed on the first two, and fixed. Both said only "String if retrieving." — naming the type rather than the content, and never stating the display-mode outcome, which is the one thing the void exists to convey. the_date() now also names the case where the string is empty, since the function builds its value only when is_new_day(); naming the content without that caveat would promise a date the caller may not get.

edit_term_link() appears to have been read from an earlier commit on the branch. As it stands in r63441 it reads "HTML content when retrieving, null on failure or without the capability to edit the term. Nothing when displaying." — the same structure as the five title tags from single_post_title() through single_term_title(), so changing it alone would make it the odd one out among six.

A survey of the thirty-one dual-mode descriptions does show a genuine split, but a different one. Twenty-three name the flag ("Calendar HTML when $display is false … Nothing otherwise."), six use retrieving/displaying ("Title when retrieving … Nothing when displaying."), and two have a second dimension to account for and so name both ("Nothing when 'echo' is true and 'format' is not 'array'"). Folding the six into the majority form is a reasonable normalization; it just touches five functions beyond the one raised here, so it seemed better proposed than done unilaterally.

wp_dropdown_languages()

wp_dropdown_languages(): the description is incorrect. It says that "nothing is returned when the required id or name argument is missing." If those arguments are missing, they are populated with the default 'locale'. The function actually bails when $args['id'] or $args['name'] is falsy. This could be represented as something along the lines of:
($args is array{id: null|0|''|'0', ...}|array{name: null|0|''|'0', ...} ? void : string)

The description was wrong, exactly as described — id and name both default to 'locale', so neither can go missing, and the bail is on a falsy value the caller supplied. It now says "empty" rather than "required … missing".

The suggested conditional does not survive contact with PHPStan, though. A union of two unsealed array shapes with different required keys cannot be represented, so it is widened to plain non-empty-array:

/** @var array{ id: ''|'0', ... }|array{ name: ''|'0', ... } $u */
\PHPStan\dumpType( $u );   // Dumped type: non-empty-array

The condition therefore stops asking whether id or name is empty and starts asking whether $args has any keys at all:

Call Combined Nested
array( 'id' => '' ) void void
array( 'name' => '' ) void void
array( 'id' => 'x', 'name' => 'y' ) void string
array( 'echo' => false ) void string
array( 'selected' => 'de_DE' ) void string
array() / no argument string string

The two rows that look right in the combined column are coincidence — the empty array passes only by failing non-empty-array, and the two bail shapes pass only by being non-empty. Every realistic call, including all seven call sites in core, would have been typed void and reported. Nesting the two shapes is the only form PHPStan evaluates shape by shape, so that is what was committed:

 * @phpstan-return (
 *     $args is array{ id: ''|'0', ... }
 *         ? void
 *         : ( $args is array{ name: ''|'0', ... } ? void : string )
 * )

The falsy set was narrowed to ''|'0' rather than null|0|''|'0', since both arguments are documented @type string and those are the falsy strings — parallel to the sibling conditions enumerating false|0|''|'0' for a bool|int flag. Easy to widen if modeling undocumented argument types is preferred.

This also corrects a claim in the description above, which filed wp_dropdown_languages() under "not dual-mode at all — there is no argument for a condition to switch on". There is one; it is the bail condition rather than the display flag. That makes it the only function in the set whose conditional keys off a bail shape, so if the pattern is accepted, the other bail-only voids deserve the same sweep.

src/ was also swept for the same construct elsewhere — no other conditional in core unions array shapes, so nothing is carrying this bug.

@param true $deprecated_echo on trackback_url()

Unrelated, but I just noticed this: @param true $deprecated_echo on trackback_url() would result in: Parameter #1 $deprecated_echo of function trackback_url expects true, false given.. In php-stubs/wordpress-stubs, this is used deliberately to signal that a deprecated argument was supplied. See: https://phpstan.org/r/2e2aa144-9b64-4815-bf72-2c343512462d

A genuinely useful idea, and the wordpress-stubs precedent is a good one. It does conflict with what is there now, though: declaring the parameter true while a conditional return switches on it produces a new error on the function itself.

Condition "true is true" in conditional return type is always true.  [conditionalType.alwaysTrue]

So adopting it means removing the conditional. That turns out to cost less than it appears — a call passing false still has its return resolved from the argument's own type, so trackback_url( false ) still types as string while trackback_url() still resolves to void and is reported.

The larger consideration is that it reclassifies every existing trackback_url( false ) call as an argument-type error. That is the intent, but it is a policy decision about how deprecated arguments should be signaled across core rather than a detail of this change, so it seems better raised on its own ticket.

On workarounds versus design questions

General thought: I think the effort being put into improving code quality is very welcome.
However, I also think that some of the errors reported by PHPStan point to design questions and should prompt us to think about those questions, rather than trying to introduce workarounds solely to silence PHPStan. Once such workarounds land in core, the PHPStan error disappears and therefore no longer indicates that there may be an underlying problem. The issue has not necessarily been resolved; it may simply have been masked.
I don’t think there is anything wrong with deliberately ignoring some errors. That way, there is still an indication that something may deserve attention, without forcing the implementation or documentation into shapes that primarily exist to satisfy the analyser.

Good point. Yeah, there are definitely some design issues here. One example that stands out to me is that WP_Term_Query::get_terms() cannot have its return type narrowed since it takes no argument, but the WP_Term_Query::query() wrapper function does take an argument, and so it can have its return type conditionally narrowed.

Every conditional added here creates reports that did not exist before — a call consuming the result of a printing tag is now an error where previously the plain union made it silent. Nothing was ignored and no baseline entry was added. The one baseline removed, return.missing, went because void in the union genuinely licenses falling off the end, which is the accurate description of what those functions do rather than a way around the rule.

Where the underlying design question was reached rather than papered over, it was left alone and written down. The functions listed under Deliberately not changed are there precisely because the annotation cannot express what they do — single_month_title(), wp_list_categories(), wp_nav_menu() and twentytwenty_site_logo() each return a meaningful value on a path shared by both modes, and making them fit would have meant changing what callers receive. That is the dual-responsibility problem originally raised, and it is still open; an annotation was not treated as its resolution.

Where that leaves things: updates are ready for review in a follow-up PR: #13371

westonruter added a commit to IanDelMar/wordpress-develop that referenced this pull request Sep 3, 2026
`WP_Styles::print_inline_style()`, `WP_Scripts::print_extra_script()` and the deprecated `WP_Scripts::print_scripts_l10n()` each document their return the wrong way round. All three say the markup comes back when `$display` is true, but the string is returned on the `! $display` branch and the printing branch returns `true`. The wording has read this way since r36744, so anyone consulting it to decide which argument to pass was told the opposite of what the code does.

Swapping `true` for `false` in the three descriptions corrects that, and a conditional `@phpstan-return` pins the two behaviors apart, since the plain unions collapse the distinction. `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.

`WP_Scripts::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.

Developed as subset of WordPress#13359.
Follow-up to r36744, r62178.

Props apermo, westonruter.
See #65817.


git-svn-id: https://develop.svn.wordpress.org/trunk@63440 602fd350-edb4-49c9-b593-d223f7449a82
westonruter added a commit to IanDelMar/wordpress-develop that referenced this pull request Sep 3, 2026
Many template tag functions either print their result or return it, depending on a `display` or `echo` param. Their `@return` carried `void` in a union until r61766, r61768 and r62178 replaced it with `null`, on the premise that `void` cannot belong to a union type. That premise holds for PHP's native return types but not for PHPDoc, where PHPStan reads `void` in a union as "may not return at all".

Restoring it is not enough on its own, though. PHPStan raises the "Result of function … (void) is used." error only when the resolved return type is exactly `void`, which a union never is, so the tags that kept `void` were getting no more out of it than the ones converted to `null`. What carries the distinction is a conditional `@phpstan-return`, resolving to plain `void` when the tag prints and to the type it returns otherwise. Thirty-one functions gain one, including one in the bundled Twenty Twenty theme, each with a single `void` branch and a nullable retrieval branch. The trailing `return null;` statements added in r63378 and r63379 are removed; they existed only to satisfy `return.missing`, which does not apply once `void` is in the union. Where the flag lives in an `$args` array the condition has to match every falsy spelling of it, since these tags variously default it to `true` or to `1`. A call whose flag PHPStan cannot see, such as a query string or an array built at runtime, does not resolve to plain `void`, so it is never reported.

Four descriptions are corrected alongside: `next_posts()`, `previous_posts()` and `wp_register()` promised a link where an empty string is possible, and `wp_dropdown_languages()` prints and then returns the markup rather than choosing between the two. Tags returning a meaningful value on a path shared by both modes cannot resolve to plain `void` and are left alone, among them `single_month_title()`, `wp_list_categories()` and `wp_nav_menu()`.

Developed as subset of WordPress#13359.
Follow-up to r32568, r61766, r61768, r62178, r63378, r63379, r63440.

Props marian1, westonruter, apermo.
See #65817, #64704.


git-svn-id: https://develop.svn.wordpress.org/trunk@63441 602fd350-edb4-49c9-b593-d223f7449a82
@IanDelMar

Copy link
Copy Markdown
  • print_scripts_l10n(): Neither core nor anyone else should use this function - it is deprecated. Using it should not be rewarded with a narrowed return type.
  • On workarounds versus design questions: This was a general remark, as I had the impression that the initial replacement of void with null was an attempt to resolve the design issue that these functions conditionally either return or echo. If you oppose documenting this behaviour - which is tempting - then I think the alternative should be to consider how that behaviour could be removed. For example, by having get_search_form() and print_search_form() instead of using $args['echo'] = true to turn get_search_form() into a "convenience" wrapper for echo get_search_form().

@westonruter

Copy link
Copy Markdown
Member Author
  • print_scripts_l10n(): Neither core nor anyone else should use this function - it is deprecated. Using it should not be rewarded with a narrowed return type.

I'm including it because the absence of a conditional return it would come up again when scanning for places where it is missing. The method remains deprecated, so it's not suddenly encouraged to be used.

  • On workarounds versus design questions: This was a general remark, as I had the impression that the initial replacement of void with null was an attempt to resolve the design issue that these functions conditionally either return or echo. If you oppose documenting this behaviour - which is tempting - then I think the alternative should be to consider how that behaviour could be removed. For example, by having get_search_form() and print_search_form() instead of using $args['echo'] = true to turn get_search_form() into a "convenience" wrapper for echo get_search_form().

My goal currently is to try to document the behavior of the functions as they exist today, and hopefully add static analysis hints to catch incorrect usage as much as possible. Refactoring to address the fundamental inability to have deterministic return types for a given input is something which should be done, but at a later stage.

@IanDelMar

Copy link
Copy Markdown

My goal currently is to try to document the behavior of the functions as they exist today, and hopefully add static analysis hints to catch incorrect usage as much as possible. Refactoring to address the fundamental inability to have deterministic return types for a given input is something which should be done, but at a later stage.

Yes, I just wanted to raise awareness of the broader implications of introducing workarounds merely to silence PHPStan or to avoid documenting behaviour that seems odd from a native type perspective. In the case of the functions discussed here, those workarounds have already been reverted. Thank you for taking this up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants