Skip to content

Docs: Add PHPStan purity annotations - #14095

Open
swissspidy wants to merge 4 commits into
WordPress:trunkfrom
swissspidy:claude/amazing-planck-fe1pgy
Open

swissspidy wants to merge 4 commits into
WordPress:trunkfrom
swissspidy:claude/amazing-planck-fe1pgy

Conversation

@swissspidy

@swissspidy swissspidy commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Split out of #13896, which in turn was split out of #13530 (subset 9).

@phpstan-pure is added to 75 functions whose result depends only on their arguments.

From #13896: add_cssclass(), backslashit(), trailingslashit(), untrailingslashit(), wp_slash(), bool_from_yn(), wp_is_numeric_array(), rest_sanitize_boolean() and get_shortcode_atts_regex().

New in this PR (66 functions that core calls directly):

  • Widely used helpers: absint(), wp_basename(), _wp_array_get(), wp_array_slice_assoc(), wp_parse_list(), wp_parse_id_list(), zeroise(), wp_validate_boolean(), is_php_version_compatible(), _n_noop(), _nx_noop(), before_last_bar(), add_magic_quotes(), url_shorten(), wp_fuzzy_number_match(), plugin_dir_path(), translate_level_to_role(), calendar_week_mod(), default_topic_count_scale()
  • Formatting and KSES: _split_str_by_whitespace(), _wp_emoji_list(), _get_wptexturize_shortcode_regex(), unescape_invalid_shortcodes(), wp_kses_array_lc(), valid_unicode(), wp_kses_version(), wp_js_dataset_name(), wp_html_custom_data_attribute_name()
  • REST API and JSON Schema: rest_default_additional_properties_to_false(), rest_is_boolean(), rest_get_allowed_schema_keywords(), _rest_array_intersect_key_recursive(), _wp_prepare_json_schema_for_client_with_allowed_keywords()
  • HTTP: _get_component_from_parsed_url_array(), _wp_translate_php_url_constant_to_key()
  • Media: image_hwstring(), _wp_get_image_size_from_meta(), wp_sizes_attribute_includes_valid_auto(), wp_is_heic_image_mime_type(), wp_exif_frac2dec(), _image_get_preview_ratio(), wp_caption_input_textarea()
  • Blocks and layout: wp_get_first_block(), wp_normalize_remote_block_pattern(), remove_serialized_parent_block(), extract_serialized_parent_block(), make_before_block_visitor(), make_after_block_visitor(), get_query_pagination_arrow(), get_comments_pagination_arrow(), wp_get_layout_definitions(), wp_get_layout_child_values(), wp_get_layout_container_values(), wp_get_root_state_style(), wp_get_state_declarations_with_fallback_border_styles(), wp_get_state_declarations_with_background_resets(), wp_get_state_style_with_fallback_dimension_styles(), wp_is_explicit_aspect_ratio_value(), wp_should_add_elements_class_name(), wp_get_entity_view_config_hook_name()
  • Other: _wp_get_abilities_match_meta(), _wp_connectors_mask_api_key(), wp_connectors_parse_application_password_credentials(), _wp_reset_invalid_menu_item_parent(), _wp_privacy_action_request_types(), wp_make_theme_file_tree()

make_before_block_visitor() and make_after_block_visitor() return closures that have side effects, but creating a closure has none. PHPStan already treats calling the returned callable as possibly impure.

Why @phpstan-pure is still useful

By default (rememberPossiblyImpureFunctionValues: true), PHPStan already remembers the return values of functions not marked impure, so @phpstan-pure doesn't change that (see Remembering and forgetting returned values). This is why it was dropped from #13896. The tag still has two effects at the level 5 that core runs:

  • PHPStan checks that the function body has no side effects (impure.*), so these functions can't silently become impure later.
  • A call to one of these functions whose result is unused is reported (function.resultUnused), because such a call has no effect.

For anyone analyzing with rememberPossiblyImpureFunctionValues: false, it also lets PHPStan keep remembering these functions' return values.

How the functions were chosen

  1. Every documented top-level function in wp-includes and wp-admin/includes was marked @phpstan-pure. PHPStan was then run repeatedly, removing the tag from every function it reported as impure or as called for its side effects, until nothing changed.
  2. With thousands of functions tagged at once, PHPStan let some impure calls through. For example, get_the_ID() survived even though it calls get_post(), which PHPStan does report as possibly impure when get_the_ID() alone is tagged. So the remaining functions were filtered further, dropping any function that, directly or through another WordPress function, does any of the following:
    • reads globals, superglobals or object state other than its parameters
    • calls methods
    • runs hooks or callbacks
    • depends on time or randomness
    • performs I/O
  3. Further candidates from review were added after reading their bodies.
  4. The resulting tags were then checked with composer run phpstan on their own, without the mass tagging, so each function here is accepted by PHPStan's normal analysis.
  5. The following were also left out:
    • Functions that core only uses as hook, sort or array_filter() callbacks, such as __return_true(), _wp_post_thumbnail_class_filter() and _is_valid_nav_menu_item(), where the tag only guards the body.
    • Functions in pluggable.php, the deprecated files and the compat polyfills, since they can be overridden.
    • The cache.php functions, since object-cache drop-ins replace them with versions that have side effects.
    • The generated blocks/ and build/ files, and bundled libraries.
    • Functions that call preg_match(), preg_replace() or similar, such as sanitize_hex_color(), normalize_whitespace() and wp_kses_no_null(). PHPStan treats these PCRE functions as possibly impure, since they update preg_last_error(), so it rejects the tag. _deep_replace() is rejected for the same reason, since it uses str_replace()'s by-reference $count.
    • block_version(), which calls has_blocks() and so can fall back to get_post() for non-string input. Making it string-only would be a code change, better done separately.
    • wp_clone(), since clone runs __clone().
    • Class methods, which were not scanned.

@phpstan-impure is not part of this PR. As discussed in #13896, which functions to mark impure needs agreement first.

Effect on the analysis

composer run phpstan passes with no baseline changes, and no call site in core is reported as function.resultUnused.

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

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Used for: Splitting the @phpstan-pure annotations out of #13896, finding further pure functions with PHPStan and a stricter body filter, comparing the result with the lists from review, checking the result with composer run phpstan, and drafting the commit messages and this description. Reviewed 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T3XYNUvB9sQNDXA14uJQeH

Mark functions whose result depends only on their arguments as `@phpstan-pure`: `add_cssclass()`, `backslashit()`, `trailingslashit()`, `untrailingslashit()`, `wp_slash()`, `bool_from_yn()`, `wp_is_numeric_array()`, `rest_sanitize_boolean()` and `get_shortcode_atts_regex()`.

PHPStan then verifies that these functions have no side effects, and reports calls whose result is unused. It also remembers their return values when `rememberPossiblyImpureFunctionValues` is disabled.

See #65817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3XYNUvB9sQNDXA14uJQeH
Copilot AI balanced review requested due to automatic review settings October 8, 2026 08:36
@github-actions

github-actions Bot commented Oct 8, 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 swissspidy, marian1, westonruter.

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

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

github-actions Bot commented Oct 8, 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.

Add `@phpstan-pure` to 55 more functions whose result depends only on their arguments and which core calls directly, such as `absint()`, `wp_basename()`, `_wp_array_get()`, `wp_array_slice_assoc()`, `_n_noop()` and `wp_validate_boolean()`.

The candidates were found by marking every documented function as pure and removing the tag wherever PHPStan reported a side effect, until none remained. The result was then filtered further to drop any function that reads globals or object state, calls methods or hooks, depends on time or randomness, or performs I/O, directly or through another WordPress function. Callback-only functions, pluggable and drop-in replaceable functions, deprecated functions, polyfills and generated block files are left out.

See #65817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3XYNUvB9sQNDXA14uJQeH
@swissspidy
swissspidy requested a review from westonruter October 8, 2026 12:10
@IanDelMar

IanDelMar commented Oct 8, 2026 •

Copy link
Copy Markdown

@swissspidy Can you ask Claude to check, whether these are included?

Pure functions
  • _autop_newline_preservation_helper
  • _block_template_render_without_post_block_context
  • _cleanup_header_comment
  • _cleanup_image_add_caption
  • _customizer_mobile_viewport_meta
  • _deep_replace
  • _disable_block_editor_for_navigation_post_type
  • _get_template_edit_filename
  • _get_widget_id_base
  • _image_get_preview_ratio
  • _strip_template_file_suffix
  • _wp_add_global_attributes
  • _wp_array_get
  • _wp_connectors_mask_api_key
  • _wp_emoji_list
  • _wp_iso_convert
  • _wp_kses_allow_note_mention_span
  • _wp_kses_decode_entities_chr
  • _wp_kses_decode_entities_chr_hexdec
  • _wp_privacy_action_request_types
  • _wp_register_meta_args_allowed_list
  • _wp_to_kebab_case
  • classnames_for_block_core_search
  • deslash
  • get_color_classes_for_block_core_search
  • get_extended
  • htmlentities2
  • image_attachment_fields_to_edit
  • media_post_single_attachment_fields_to_edit
  • media_single_attachment_fields_to_edit
  • normalize_whitespace
  • post_comment_meta_box_thead
  • rest_is_integer
  • rest_parse_hex_color
  • rest_validate_json_schema_pattern
  • sanitize_hex_color
  • sanitize_sql_orderby
  • stripslashes_from_strings_only
  • taxonomy_meta_box_sanitize_cb_checkboxes
  • wp_add_parent_layout_to_parsed_block
  • wp_caption_input_textarea
  • wp_check_jsonp_callback
  • wp_get_block_style_variation_name_from_class
  • wp_has_noncharacters
  • wp_kses_hair_parse
  • wp_kses_html_error
  • wp_kses_no_null
  • wp_kses_stripslashes
  • wp_make_link_relative
  • wp_make_plugin_file_tree
  • wp_mark_auto_generate_control_attributes
  • wp_maybe_grant_install_languages_cap
  • wp_maybe_grant_resume_extensions_caps
  • wp_nav_menu_remove_menu_item_has_children_class
  • wp_oembed_ensure_format
  • wp_parse_widget_id
  • wp_privacy_anonymize_ip
  • wp_robots_sensitive_page
  • wp_sanitize_block_gap_value
  • wp_throttle_comment_flood
  • wp_untrash_post_set_previous_status
  • wp_widgets_access_body_class
  • xmlrpc_removepostdata
Pure functions excluded on purpose
  • __return_empty_array
  • __return_empty_string
  • __return_false
  • __return_null
  • __return_true
  • __return_zero
  • _filter_block_content_callback
  • _filter_do_shortcode_context
  • _is_valid_nav_menu_item
  • _sort_name_callback
  • _sort_uname_callback
  • _usort_by_first_member
  • _wp_customize_changeset_filter_insert_post_data
  • _wp_filter_taxonomy_base
  • _wp_object_count_sort_cb
  • _wp_object_name_sort_cb
  • _wp_post_thumbnail_context_filter
  • _wp_utf8_encode_fallback
  • addslashes_strings_only
  • block_core_home_link_build_css_colors
  • block_core_navigation_build_css_colors
  • block_core_navigation_build_css_font_sizes
  • block_core_navigation_filter_out_empty_blocks
  • block_core_navigation_get_layout_custom_property_declarations
  • block_core_navigation_get_submenu_visibility
  • block_core_navigation_sort_menu_items_by_parent_id
  • block_core_navigation_submenu_get_submenu_visibility
  • block_core_navigation_typographic_presets_backcompatibility
  • block_core_page_list_get_submenu_visibility
  • block_core_post_excerpt_excerpt_length
  • block_core_post_time_to_read_word_count
  • block_core_shared_navigation_render_submenu_icon
  • default_topic_count_text
  • funky_javascript_callback
  • make_after_block_visitor
  • make_before_block_visitor
  • wp_cache_close
  • wp_cache_supports
  • wp_filter_out_block_nodes
  • wxr_filter_postmeta
Pure functions already included
  • _get_wptexturize_shortcode_regex
  • _n_noop
  • _nx_noop
  • _split_str_by_whitespace
  • _wp_reset_invalid_menu_item_parent
  • _wp_translate_php_url_constant_to_key
  • absint
  • add_cssclass
  • backslashit
  • before_last_bar
  • bool_from_yn
  • calendar_week_mod
  • default_topic_count_scale
  • extract_serialized_parent_block
  • get_comments_pagination_arrow
  • get_query_pagination_arrow
  • get_shortcode_atts_regex
  • image_hwstring
  • is_php_version_compatible
  • remove_serialized_parent_block
  • rest_get_allowed_schema_keywords
  • rest_is_boolean
  • rest_sanitize_boolean
  • translate_level_to_role
  • unescape_invalid_shortcodes
  • untrailingslashit
  • valid_unicode
  • wp_array_slice_assoc
  • wp_basename
  • wp_connectors_parse_application_password_credentials
  • wp_exif_frac2dec
  • wp_fuzzy_number_match
  • wp_get_entity_view_config_hook_name
  • wp_get_layout_child_values
  • wp_get_layout_container_values
  • wp_get_layout_definitions
  • wp_get_root_state_style
  • wp_get_state_declarations_with_background_resets
  • wp_get_state_declarations_with_fallback_border_styles
  • wp_html_custom_data_attribute_name
  • wp_is_explicit_aspect_ratio_value
  • wp_is_heic_image_mime_type
  • wp_is_numeric_array
  • wp_js_dataset_name
  • wp_kses_array_lc
  • wp_kses_version
  • wp_make_theme_file_tree
  • wp_normalize_remote_block_pattern
  • wp_parse_list
  • wp_sizes_attribute_includes_valid_auto
  • wp_validate_boolean
  • zeroise

@IanDelMar

Copy link
Copy Markdown

2. PHPStan's own inference is not strict enough on its own. For example, it treats get_post() as pure even though it reads $GLOBALS['post']

I collected impure points and PHPStan said get_post() is impure.

Add `@phpstan-pure` to `wp_parse_list()`, `wp_parse_id_list()`, `is_php_version_compatible()`, `_get_wptexturize_shortcode_regex()`, `remove_serialized_parent_block()`, `extract_serialized_parent_block()`, `get_query_pagination_arrow()` and `get_comments_pagination_arrow()`.

These were missed before because the filter used to pick candidates also rejected property reads on parameters, `array_map()` and `array_filter()` with pure callbacks, and `version_compare()`.

See #65817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3XYNUvB9sQNDXA14uJQeH
@swissspidy

Copy link
Copy Markdown
Member Author

Most of these I left out on purpose:

  • Callback-only functions like __return_*(), the sort callbacks and the filter callbacks. Nothing calls them directly, so the tag wouldn't catch anything at a call site.
  • Generated block files, like block_core_*(). These should be changed in Gutenberg instead.
  • Functions that can be replaced, like wp_cache_close() and wp_cache_supports(), which object-cache drop-ins override, plus deprecated and compat functions.
  • block_version(), because has_blocks() can fall back to get_post().
  • wp_clone(), because clone runs __clone().
  • make_before_block_visitor() and make_after_block_visitor(), because the closures they return have side effects.

@IanDelMar

Copy link
Copy Markdown

Concerning block_version(): it can only fall back to get_post() if the passed $content is not a string. This raises the question of whether block_version() should reject non-string values rather than pass them down to has_blocks(). If accepting non-string values is intended behaviour, the documented type of $content should be widened accordingly.

@IanDelMar

Copy link
Copy Markdown

Technically, make_before_block_visitor() and make_after_block_visitor() are still pure. The closures they return are impure, but their side effects occur only when they are called. PHPStan treats callable as potentially impure, so it already accounts for possible side effects when the returned closures are called. With an appropriate PHPStan annotation, it would know for certain that those closures are impure.

I updated the list. Added new entries, removed entries already handled by this PR and removed entries that were not included in this PR on purpose. The removed functions are now listed under "Pure functions excluded on purpose" and "Pure functions already included"

Add `@phpstan-pure` to `make_before_block_visitor()`, `make_after_block_visitor()` and `wp_caption_input_textarea()`.

Creating and returning a closure has no side effects; those only happen when the closure is called, and PHPStan already treats calling the returned `callable` as possibly impure.

See #65817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3XYNUvB9sQNDXA14uJQeH
@westonruter

Copy link
Copy Markdown
Member

Another purity annotation is landing in #14037.

I left #14037 (comment) there as well:

I also want to acknowledge that it is not ideal to have to annotate functions and methods with these tags. Ideally we would find a better solution, as they seem to be primarily needed as a PHPStan tooling limitation. Like maybe we could have a separate index built up of which functions/methods are pure/impure that is used by a PHPStan extension to avoid having to annotate. This index could even be maybe built up programmatically on a regular basis so that it doesn't need manual tagging. This is something I'd like to explore in the future.

@IanDelMar

IanDelMar commented Oct 13, 2026 •

Copy link
Copy Markdown

not ideal to have to annotate functions and methods with these tags

Disagree.

Without annotations PHPStan cannot check purity against your expectations. As the PR description notes, functions may silently become impure. See with_annotation vs. without_annotation in this playground example. You can, of course, also review changes to a purity map.

PHPStan annotations also provide information to anyone reading the source. Automatically inferred information is not visible to those developers. So, choosing not to add the annotations is the equivalent of deciding that this information is not relevant to developers reading the source.

Building an index or map solely from PHPStan's own analysis is redundant if its purpose is to feed that information back into PHPStan: it already knows it. See implicitly_impure in the same playground example. These tags are most useful for functions whose purity PHPStan cannot infer without an extension, and for stub files, where it cannot infer purity from the function body. Omitting the annotations then triples the maintenance effort: you have to maintain an extension that builds the map and an extension that applies the map, while others have to maintain the annotations in stub files. What is the benefit of using an extension then?

@IanDelMar

IanDelMar commented Oct 18, 2026 •

Copy link
Copy Markdown
  • str_replace()'s by-reference $count.

@swissspidy As of PHPStan 2.3 (I haven’t checked which patch version introduced this), PHPStan treats str_replace() as pure when $count is not passed.

Can @pure-unless-callable-is-impure be used for mapping functions such as map_deep(), as well as functions whose return value depends on the return value of map_deep()?

Edit: map_deep() itself is a poor example, as it may mutate objects. But functions that call map_deep() without passing an object, as well as other mapping functions.

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