Skip to content

Docs: Bring the PHPStan types from the WordPress stubs' function map into core - #13530

Closed
swissspidy wants to merge 25 commits into
WordPress:trunkfrom
swissspidy:try/phpstan-function-map
Closed

swissspidy wants to merge 25 commits into
WordPress:trunkfrom
swissspidy:try/phpstan-function-map

Conversation

@swissspidy

@swissspidy swissspidy commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Splits the second half of #13437 out, as asked there: that PR keeps the szepeviktor/phpstan-wordpress dependency and the extensions core loads from it, and this one carries the docblock work, one commit per component so it can be reviewed and committed a piece at a time.

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

What this is

php-stubs/wordpress-stubs is what every plugin's static analysis reads in place of core. When it generates those stubs it applies a map of PHPStan types on top of core's own docblocks: conditional returns, narrowed parameters, templates, purity, and a few class-level annotations. That map, functionMap.php, is 361 entries long, and each entry is a type core could carry itself.

Carrying them here rather than there means one place instead of two: core's own analysis gets the same types the ecosystem already has, the stubs need no map entry once they are regenerated from a release carrying them, and szepeviktor/phpstan-wordpress needs no extension for them either. That package has already gone this way — its 2.x branch dropped its own return type extensions for get_post(), get_terms(), current_time(), wp_die(), is_wp_error() and more in favour of the map.

Relation to r63941

r63941 (#13614) has since added conditional return types across core, cross-referenced against this PR. This branch is rebased on top of it:

  • Where both added a @phpstan-return, r63941's is kept, in its formatting. The other tags this branch adds to the same function (@phpstan-param, -template, -pure/-impure, -assert-if-true) are kept alongside it.
  • absint() keeps r63941's non-negative-int and its reworked body; the map's template-based conditional is dropped.
  • WP_Theme uses r63941's Theme_Key alias; this branch adds the @phpstan-property-read tags.
  • wp_get_link_cats() now returns WP_Error when the taxonomy is unregistered, so the map's $link_id is empty ? array{} : … no longer holds. It carries array<int, int<1, max>>|WP_Error instead.

How it was produced

A script read the map and inserted each entry into the matching core docblock as @phpstan-param, @phpstan-return, @phpstan-template, @phpstan-pure/-impure, @phpstan-assert-if-true, @phpstan-property-read, @phpstan-type or @final, formatted the way core already writes them: multi-line conditionals, no leading backslash on global class names, tags in a consistent order. Every generated tag was then read through, and the analysis was run after each commit.

228 symbols gain tags, across 72 files. The rest of the 361 are left alone:

  • Those core already says, now including the return types r63941 added. get_posts(), get_terms(), wp_die(), wp_get_upload_dir(), the_title(), current_time(), size_format(), wp_update_comment() and others carry an equivalent @phpstan-return, a template, or hash notation that HashNotationVisitor derives a shape from.
  • 20 because the function is Gutenberg's. block_core_* and render_block_core_* live in the plugin, and the map entry belongs there.
  • check_admin_referer(), check_ajax_referer() and sanitize_post_field() because the map's parameter narrowing contradicts the conditional return core already has.

Where the map is wrong for core

A type that is right for a plugin calling core is not always right inside core. The analysis surfaced the entries below, each checked against the code rather than reshaped until the error went away:

Entry What the map says What core does
add_shortcode() callback takes array<string> do_shortcode_tag() passes '' for a shortcode written without attributes, and core's own five callbacks document it that way
_get_list_table() array{screen?: string} three call sites pass a WP_Screen, which convert_to_screen() accepts
get_tag_regex(), wp_get_inline_script_tag() non-falsy-string both return '' on a documented path
get_html_split_regex(), block_version(), wp_is_uuid() @phpstan-pure a static cache, has_blocks(), and _doing_it_wrong() respectively
wp_widget_rss_form() error: bool the function and wp_widget_rss_process() both give it string|false
WP_Dependencies::query() a template PHPStan reports it as not referenced by any parameter
wp_parse_str() @phpstan-param-out core's own assignment to $result does not match it

One entry is not adopted at all: the generics the map writes for WP_REST_Request, which make the class generic in the shape of its parameters. They report every $request['key'] = … in core as an error, about twenty of them across the REST controllers. That is worth another look upstream before core carries it.

Calls this exposed

Four calls turn out to pass something other than what the function documents. They are corrected in the commit that adds the type showing it, so each commit stands on its own:

  • wp_upload_bits() and add_option() are given the value their deprecated parameter documents, null and an empty string respectively, rather than the other way round.
  • iframe_header() is no longer passed a deprecated argument.
  • get_term_to_edit() documents the WP_Term that sanitize_term() returns, rather than the int|string|null it never returns.

WP_Widget_Factory::register() instantiates a widget class with no arguments, which WP_Widget's own constructor does not accept; that call carries an @phpstan-ignore explaining why, since every widget class declares a constructor of its own.

Effect on the analysis

composer run phpstan is green at every one of the commits below, and each regenerates the baselines it changes, so the branch can be committed a piece at a time without leaving trunk red.

Over the whole branch the baselines lose 32 entries (70 reported errors) and gain 2, both the same WP_Widget error reworded now that the class is generic. parameter.defaultValue.neon reaches zero and is deleted.

The commits

One per component, each green on its own, so they can be committed one at a time:

  1. General: Add the PHPStan types the stubs carry for the general-purpose functions.
  2. Formatting: Add the PHPStan types the stubs carry for the formatting functions.
  3. I18N: Add the PHPStan types the stubs carry for the localization functions.
  4. Plugins: Add the PHPStan types the stubs carry for the plugin and hook API.
  5. HTTP API: Add the PHPStan types the stubs carry for the request functions.
  6. Taxonomy: Add the PHPStan types the stubs carry for the taxonomy and category functions.
  7. Posts, Post Types: Add the PHPStan types the stubs carry for the post functions.
  8. Comments: Add the PHPStan types the stubs carry for the comment functions.
  9. Users: Add the PHPStan types the stubs carry for the user and pluggable functions.
  10. Administration: Add the PHPStan types the stubs carry for the admin functions and list tables.
  11. Options, Meta APIs: Add the PHPStan types the stubs carry for the option, meta and cron functions.
  12. Networks and Sites: Add the PHPStan types the stubs carry for the multisite functions.
  13. Widgets: Add the PHPStan types the stubs carry for the widget functions and classes.
  14. Themes: Add the PHPStan types the stubs carry for WP_Theme and the template tags.
  15. Script Loader: Add the PHPStan types the stubs carry for the script and style functions.
  16. Editor: Add the PHPStan types the stubs carry for the block functions.
  17. Shortcodes: Add the PHPStan types the stubs carry for the shortcode functions.
  18. Media: Add the PHPStan types the stubs carry for the media functions.
  19. Menus: Add the PHPStan types the stubs carry for the nav menu functions.
  20. Bookmarks: Add the PHPStan types the stubs carry for the bookmark functions.
  21. Permalinks: Add the PHPStan types the stubs carry for the link and rewrite functions.
  22. REST API: Add the PHPStan types the stubs carry for the REST and abilities functions.
  23. Feeds: Add the PHPStan types the stubs carry for the feed and robots functions.
  24. Deprecated: Add the PHPStan types the stubs carry for the deprecated functions.

Follow-ups

  • The WP_REST_Request generics need rework upstream before core can carry them.
  • The map entries for block_core_* and render_block_core_* belong in Gutenberg.
  • Once these land, the corresponding entries in the stubs' functionMap.php can be dropped as the stubs are regenerated.

Props

The types come from php-stubs/wordpress-stubs, so props to its contributors: marian1 (IanDelMar), szepeviktor, johnbillion, thelovekesh, swissspidy, nikschavan, mociofiletto.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: The script that inserted the map entries into core's docblocks, the review of its output against the map and the code, the adjustments and call fixes listed above, splitting the work into the commits above, rebasing onto r63941 and resolving the overlap with it, and drafting this description. Every commit was checked with composer run phpstan and phpcs. Directed and reviewed by me.
Session transcripts: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21, https://claude.ai/code/session_01TLY486Lcee7ZabrPWu31SM


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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TLY486Lcee7ZabrPWu31SM

@github-actions

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.

@westonruter

Copy link
Copy Markdown
Member

@swissspidy I just saw this PR as I was working on a PR to add the remaining conditional return types. I used this PR to cross-reference and augment the dedicated PR that I just opened for that:

Comment thread src/wp-admin/includes/bookmark.php Outdated
* @param int $link_id Link ID to look up.
* @return int[] The IDs of the requested link's categories.
*
* @phpstan-return ($link_id is empty ? array{} : array<int, int<1, max>>)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TIL: empty is a type?

Comment on lines +25 to +29
* @phpstan-return null|(
* $output is 'ARRAY_A'
* ? array<string, mixed>
* : ($output is 'ARRAY_N' ? array<int, mixed> : stdClass)
* )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Interesting approach: unioning null with a conditional type. I can see how that factors out duplicated conditions.

Comment thread src/wp-includes/formatting.php
@westonruter

Copy link
Copy Markdown
Member

I wonder if we could gather up props of the contributors for everyone who contributed these to php-stubs/wordpress-stubs?

* @return bool|_WP_Dependency Found, or object Item data.
*
* @phpstan-return (
* $handle is not non-empty-string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TIL: is not works

@swissspidy

Copy link
Copy Markdown
Member Author

Sounds good, I'll rebase mine once yours is committed.

Based on git blame / GitHub activity we could give props to:

marian1 (IanDelMar)
szepeviktor
johnbillion
thelovekesh
swissspidy
nikschavan
mociofiletto

@westonruter

Copy link
Copy Markdown
Member

Conditional return types have been committed in r63941 (51f936d).

swissspidy and others added 16 commits September 26, 2026 07:31
…e functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

This moves the entries for `functions.php` and `load.php` in, along with `wpdb::prepare()`, which takes a `literal-string`, and `WP_Object_Cache::delete()`, whose second parameter is deprecated.

Three entries are adjusted rather than taken as they are. `size_format()` is stated in terms of what core's own parameter allows, `get_tag_regex()` does return an empty string for an empty tag, and neither `wp_is_uuid()` nor the `$version` it validates is as narrow as the map has it: the function calls `_doing_it_wrong()`, so it is not pure.

`wp_upload_bits()` documents its second parameter as deprecated and unused, and the one call in core that passes it an empty string rather than null is corrected.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

Among them the conditional returns of `addslashes_gpc()` and `zeroise()`, the template that keeps `urlencode_deep()` and `sanitize_category()` returning what they were given, and `trailingslashit()` returning a `non-falsy-string`, which is what the extension szepeviktor/phpstan-wordpress carries for it does and all it does.

The `@phpstan-param-out` the map writes for `wp_parse_str()` is left out: PHPStan reports core's own assignment to `$result` as not matching it, and the narrower type is not worth an error inside the function it describes.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…tions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`wp_get_word_count_type()` and `WP_Locale::$word_count_type` are one of three known values, `load_plugin_textdomain()` takes a deprecated second parameter, and `WP_Translations::translate()` returns null only when it is given null.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…k API.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

A hook name is a `non-empty-string`, `has_filter()` and `has_action()` return a bool only when asked about the hook rather than a callback, `did_action()` and `did_filter()` count from zero, and the callbacks `register_activation_hook()` and the menu page functions accept are described as the callables they are.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

The response array `wp_remote_get()` and its siblings return, which `WP_Http::request()` documents in prose and every caller reaches into, becomes the array shape it is: headers, body, response code and message, cookies, filename and the `WP_HTTP_Requests_Response` behind it.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…category functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`get_term()`, `get_term_by()`, `get_category()` and `get_bookmark()`-style `$output` parameters resolve to the object, the associative array or the list they are asked for, and `sanitize_term()` and `sanitize_term_field()` keep the type they were given.

The map states `get_tags()` as returning a `numeric-string` for `fields => count`, which core's own `get_terms()` type does not allow, so that branch is left out. `get_term_to_edit()` documents the `WP_Term` that `sanitize_term()` now says it returns, rather than the `int|string|null` it never returns.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
… functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`register_post_type()` takes a lowercase name, `wp_insert_post()` and `wp_update_post()` return a `WP_Error` only when asked to, `wp_is_post_revision()` never returns a negative ID, and `WP_Query` reads two properties and calls one method that are not declared on it.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`wp_get_comment_status()` returns one of four statuses or false, and `wp_update_comment()` returns a `WP_Error` only when asked to. The map states its success value as `0|1`, where the method behind it, `wpdb::update()`, returns the number of rows it changed, so that is what the type says.

`trackback_url()` is left as it is: its `$deprecated_echo` parameter is already documented and already carries the `@phpstan-ignore` that the conditional return needs.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…le functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`get_user_by()` cannot find a user by a non-positive ID, `wp_create_nonce()` and `wp_hash()` return a lowercase hash, the nonce functions take `-1` as well as a string action, and `wp_mail()`, `wp_rand()` and `wp_generate_password()` are marked impure so PHPStan does not treat repeated calls as equal.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…unctions and list tables.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`_get_list-table()` returns an instance of the list table it is asked for, which is what the template the map writes for it expresses, and `WP_List_Table::set_pagination_args()` takes the three keys it reads.

The map has `_get_list_table()` accepting only a screen name in `$args`, where core passes a `WP_Screen` in three places and the function hands it to `convert_to_screen()`, which accepts one; the type says so.

`iframe_header()` documents its second parameter as deprecated, and the one call in core still passing it is corrected, as is the `add_option()` call whose deprecated argument is null rather than the empty string it is documented as.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ion, meta and cron functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

The cron functions take a list of arguments rather than an arbitrary array, and return a `WP_Error` only when asked to; `get_site_option()`, `get_user_option()` and `register_meta()` take a deprecated parameter that is documented but not typed.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…tisite functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`get_sites()` returns a count, a list of IDs or a list of sites according to the arguments it is given, and `switch_to_blog()` takes a deprecated second parameter.

`switch_to_blog()` takes the site ID as an int, and the one call in core passing `WP_Site::$blog_id`, which is documented as a string, now casts it.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ns and classes.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`WP_Widget` becomes generic in the shape of the instance it stores, so a widget that documents its own settings array has `widget()`, `update()` and `form()` typed in terms of it, and `register_widget()` takes the class name or instance it documents.

The map has `wp_widget_rss_form()` receiving a bool `error` where the function and `wp_widget_rss_process()` both give it `string|false`, so the shape says that.

`WP_Widget_Factory::register()` instantiates a widget class with no arguments, which `WP_Widget`'s own constructor does not accept; the call carries an `@phpstan-ignore` saying why, since every widget class declares a constructor of its own.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…mplate tags.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`WP_Theme`'s magic properties are declared, and the headers its `ArrayAccess` implementation answers for become a named type, so reading an undefined offset resolves to null rather than to mixed.

The map states `single_month_title()` as returning void in its display branch, where the function returns nothing at all on one path; the type says null, which is what a caller receives.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…nd style functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`wp_scripts_get_suffix()` returns one of two strings, the script and style tag builders take attributes whose values may be `true`, and `WP_Dependencies::query()` resolves to a `_WP_Dependency` for the two statuses that return one and to a bool for the rest.

That last type is written without the template the map uses for it, which PHPStan reports as not being referenced by any parameter, and `wp_get_inline_script_tag()` does return an empty string when the processor rejects the script it is given.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`WP_Block_List` declares the offsets its `ArrayAccess` implementation takes, `get_block_wrapper_attributes()` returns a non-empty string once it is given attributes, and `block_version()` answers 0 for empty content.

`block_version()` is not marked pure: it calls `has_blocks()`, which PHPStan cannot see through.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
swissspidy and others added 6 commits September 26, 2026 07:31
php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`register_nav_menus()` takes a map of location slugs to descriptions, and `wp_nav_menu_manage_columns()` returns the fixed set of columns it builds.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ctions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`get_bookmark()` resolves to the object, the associative array or the list its `$output` asks for, the field getters take the field names they accept, and `wp_get_link_cats()` returns an empty array for an empty link ID.

`get_bookmark_field()` is typed without the `WP_Error` the map gives it, which the function returns only through `get_bookmark()` in a branch it does not reach and PHPStan reports as unused.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…write functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`get_permalink()` and its variants return false only when they are not handed a post, `path_join()` and `add_feed()` return a string that is never empty, and `wp_internal_hosts()` returns lowercase hosts.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…ities functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`rest_ensure_response()` returns a `WP_Error` only when it is given one, `rest_sanitize_boolean()` keeps a bool it is given and resolves the documented false-y values, `rest_authorization_required_code()` returns one of two codes, and a route namespace, a route and an ability name are each a `non-falsy-string`.

The generics the map writes for `WP_REST_Request` are not taken: they make the class generic in the shape of its parameters, which reports every `$request['key'] = ...` in core as an error. That is worth another look upstream before core carries it.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`prep_atom_text_construct()` returns the two-element list its callers destructure, the `$deprecated` parameters of `get_wp_title_rss()` and `wp_title_rss()` are typed as the entity they default to, and the robots filters take and return the directive map they filter.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…functions.

php-stubs/wordpress-stubs, which every plugin's static analysis reads in place of core, applies a map of PHPStan types on top of core's docblocks when it generates those stubs. Each entry is a type core can carry itself, where it serves core's own analysis as well as the stubs generated from it.

`is_term()` resolves the same way `term_exists()` does, and `addslashes_gpc()` returns a string only when it is given one. These files are not analyzed themselves, so the types are for the callers that remain.

See #65817.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
@swissspidy
swissspidy force-pushed the try/phpstan-function-map branch from c2b7313 to b7ae4d0 Compare September 26, 2026 07:32
@swissspidy
swissspidy marked this pull request as ready for review September 26, 2026 08:48
Copilot AI lite review requested due to automatic review settings September 26, 2026 08:48

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

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 swissspidy, westonruter.

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

@westonruter

Copy link
Copy Markdown
Member

I'm trying to think of the the best next step to get this in. While the changes could be merged by component as suggested in the description, maybe it makes sense to also or alternatively commit by annotation type? For example, the pure/impure annotations seem like they could land independently. This could make it easier to review.


🤖 Comment by Claude Opus 5.5

Beyond the conditional returns already split out into #13614, and the param/return and pure/impure subsets that have been suggested, the ~300 tags added here can also be split by kind of tag rather than by component. Each subset below tells one story that can be reviewed without knowing the component. Counts are approximate, from grepping the diff.

  1. Call-site fixes (4 code lines + 1 ignore). wp_upload_bits( …, null, … ), add_option( …, '', false ), iframe_header() without the deprecated argument, and the @phpstan-ignore in WP_Widget_Factory::register(). These are the only runtime changes in the PR, and each is correct on its own. Landing them first leaves every other subset as docblock-only.
  2. Deprecated parameters narrowed to literal types (~43). @phpstan-param '' $deprecated, and likewise null, false, true, array{}, '&#8211;' and 1. Mechanical, and each is checkable against the default value. These are what expose the call-site fixes in (1), so the two pair naturally.
  3. ''|callable defaults and callback signatures (~24). The add_*_page() family, the Custom_Background and Custom_Image_Header constructors, unregister_setting(), and signatures such as callable(bool): void for the activation hooks. This empties parameter.defaultValue.neon, so the baseline file is deleted.
  4. Class-level annotations. @phpstan-property-read on WP_Theme and WP_Query, @phpstan-method, @phpstan-implements ArrayAccess<int, WP_Block>, and three @final. These account for most of the property.notFound, property.private and isset.property baseline removals.
  5. Refined string types (~47 non-falsy-string/non-empty-string, 9 lowercase-string). The largest, lowest-risk and most repetitive group. The few parameter narrowings (e.g. non-empty-string $hook_name, non-falsy-string $route) may be worth separating from the return types, since only they can produce new errors for callers. The get_tag_regex() and wp_get_inline_script_tag() corrections belong here.
  6. Integer ranges and literal unions (~14 int<…> + ~10 enums). int<0, max>, 'display'|'save', the HTTP protocol versions, the auth $scheme, -1|string $action on the nonce functions, 401|403, 0|1. Each is small and checkable against the function body.
  7. Array shapes (~26 array{…}). The costliest to review and the most likely to be subtly wrong, so they are best kept on their own. The _get_list_table() screen and wp_widget_rss_form() error corrections belong here.
  8. Templates and generics (~9 @phpstan-template, ~15 uses of T). The identity-style functions (T $value → T, the term and category passthroughs), TUrl and TUuid, class-string<WP_Widget>, and the WP_Widget instance generic. Generics are the concept most likely to need explaining, so it can be explained once for all of them. This is also where the two added baseline entries come from.
  9. Type assertions (7 @phpstan-assert-if-true). wp_is_uuid(), the path validators, shortcode_exists()/has_shortcode(), taxonomy_exists(). These could go with the pure/impure subset, since both affect how PHPStan narrows types across calls rather than describing a value.
  10. literal-string on wpdb::prepare() (1 tag). Worth isolating: it is the one tag that amounts to a policy decision, since it rejects any dynamically built query string passed to prepare().

The 8 @phpstan-return void tags should also be checked: where the function already has @return void, they are redundant and can be dropped.

A possible landing order is 1 → 2 → 3 → 4, followed by 5–10 in any order. The first four are small, and each removes baseline entries.

Since the tags were generated by a script that reads the map, re-splitting by tag kind should mostly mean filtering that script's output by tag. The "where the map is wrong" adjustments and the call-site fixes would then be replayed into whichever subset they belong to.

@swissspidy

Copy link
Copy Markdown
Member Author

We can do by tag, sure. Up to you :-)

@westonruter

Copy link
Copy Markdown
Member

I also had Opus 5.5 analyze the current state of the annotations in this PR:

Can you generally analyze the correctness of the annotations being proposed? Will they cause new errors to be reported? Do they need correction?

Response:

The annotations were reviewed for correctness in two ways: each added tag was read against the code it describes, and PHPStan was run at level 10 (with bleedingEdge) on this branch and on its merge base, both with a cold result cache.

New errors at level 10

The branch is green under the shipped configuration and baselines. At level 10, the comparison shows 207 errors fixed and 459 introduced, a net of +252. Two tags account for most of the introduced errors:

Tag New errors Cause
@phpstan-param literal-string $query on wpdb::prepare() 242 Nearly every core query interpolates {$wpdb->posts} and similar, and the table properties are plain string, so no such call is literal. prepare() also accepts null.
non-falsy-string on register_rest_route() $route_namespace and $route 88 Every controller passes $this->namespace, typed string. The body already handles empty values with _doing_it_wrong().

The remaining ~130 errors are spread thinly. Each is a tag that is narrower than what core itself passes or returns.

Plugins are barely affected, since the stubs already carry these types. The concern is core's docblocks stating contracts that core's own code breaks.

Contradicted by the code

  • The HTTP response shape on wp_remote_*(), wp_safe_remote_*() and WP_Http::get()/post()/head(), repeated 11 times, omits non-blocking requests. Those return headers => array(), code/message => false, no filename key and http_response => null, and the cron spawn in core makes exactly such a request. PHPStan reports return.type for these functions. The shape should be defined once, on WP_Http::request() or as a type alias.
  • urlencode_deep(), rawurlencode_deep() and urldecode_deep() declare T → T, but they return transformed strings. map_deep()'s conditional return type fits instead.
  • sanitize_title_with_dashes() declares 'display'|'save', but sanitize_title_for_query() passes 'query'.
  • redirect_this_site() declares '' $deprecated, but it is hooked on allowed_redirect_hosts, which passes an array.
  • WP_Widget::update_callback() declares 1 $deprecated, but core registers the callback with array( 'number' => -1 ).
  • WP_Widget_Factory::$widgets gained a second @phpstan-var array<int, WP_Widget> after the existing array<non-decimal-int-string, WP_Widget>. The keys are never ints, so this looks like a merge leftover.
  • WP_Theme's @phpstan-property-read tags: screenshot, version, description and author can be false. tags is not a list, because array_filter() keeps keys, and it can also be false.
  • The WP_Widget::widget() $args shape makes all 13 keys required, but the_widget() passes only 4.
  • wp_widget_rss_form() declares number: int, but the widget template passes '__i__'.
  • WP_Block_Supports::apply_block_supports() can still return '' when $extra_attributes is non-empty. Its $extra_attributes param should also allow numbers, since the body casts them.
  • get_shortcode_tags_in_content() declares list<non-empty-string>, but it yields '' when no shortcodes are registered.
  • get_bookmark_field() declares int<1, max> values, but absint() can produce 0.
  • addslashes_gpc() is marked @phpstan-pure, but it calls _deprecated_function().
  • WP_Locale::$word_count_type is missing |null; the property is unset until init() runs.

Narrower than core or common callers pass

  • -1|string $action on the nonce functions: check_admin_referer() and check_ajax_referer() still pass int|string.
  • wp_hash()'s closed union for $scheme: the auth-cookie functions pass string, and wp_salt() supports any scheme.
  • wp_is_uuid(): the TUuid of string template rejects the mixed its callers pass, although the body checks is_string(). The @phpstan-assert-if-true tag alone is enough.
  • callable(bool): void on the activation, deactivation and uninstall hooks rejects callbacks that return a value. : mixed fixes it.
  • ''|callable on add_menu_page() and its 12 siblings rejects null, which plugins commonly pass. The same form on the Custom_Background and Custom_Image_Header constructors misses the null the Customizer passes. None of these ''|… tags is a subtype of its plain @param callable.
  • WP_List_Table::set_pagination_args(): the shape rejects numeric-string totals, the documented infinite_scroll key, and query strings.
  • Custom_Image_Header::set_header_image(): the body accepts objects and missing keys, and core passes both.
  • list<mixed> $args in the cron functions: the stored cron-array shapes in the same file still say array<mixed>, so wp-cron.php gets errors.
  • string|true attributes on wp_get_script_tag() and wp_get_inline_script_tag(): this drops the false that the wp_print_*() wrappers forward.
  • The WP_Widget generics: $old_instance and form()'s $instance can be array(), $new_instance is raw $_POST, and update() can return false.
  • @final on display_callback(): at least one popular plugin overrides it. Only update_callback() and form_callback() are documented as not to be overridden.
  • Deprecated-literal params that break existing calls:
    • wp_install() receives null from core's own test bootstrap.
    • install_search_form( false ) is a legacy call that would now be flagged.
    • wp_count_terms() still documents its legacy argument form.
    • WP_Translations::translate_plural()'s int $count contradicts its own int|float documentation.

Minor

  • Several standalone @phpstan-return void tags could be plain @return void.
  • wp_http_validate_url() and rest_sanitize_boolean() declare templates that are never used.
  • A few @phpstan-param tags follow @phpstan-return instead of preceding it.

Takeaway

The narrowed return types and the deprecated-literal params are mostly sound. Most of the problems are in narrowed parameters, templates, shapes and property types. That supports splitting the PR by kind of tag, so the sound subsets can land while the others are corrected.

literal-string on prepare() is the one policy decision. As written, it cannot land in core unless the $wpdb table properties are also typed as literal-string.

@westonruter

Copy link
Copy Markdown
Member

We can do by tag, sure. Up to you :-)

I think if we extract subsets of the changes into a new PRs like #13614 did for conditional return types, this will make it much easier to review and apply corrections identified by #13530 (comment).

@swissspidy

Copy link
Copy Markdown
Member Author

@westonruter Had Claude split them up into 8 smaller PRs now.

@swissspidy

Copy link
Copy Markdown
Member Author

Closing in favor of the smaller PRs.

@swissspidy swissspidy closed this Oct 7, 2026
@swissspidy
swissspidy deleted the try/phpstan-function-map branch October 8, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants