Skip to content

Code Quality: Add PHPStan conditional return types to the slashing and unslashing functions#12455

Closed
westonruter wants to merge 3 commits into
WordPress:trunkfrom
westonruter:add/phpstan-types-for-slashing-functions
Closed

Code Quality: Add PHPStan conditional return types to the slashing and unslashing functions#12455
westonruter wants to merge 3 commits into
WordPress:trunkfrom
westonruter:add/phpstan-types-for-slashing-functions

Conversation

@westonruter

@westonruter westonruter commented Jul 9, 2026

Copy link
Copy Markdown
Member

✅ Committed in r62672 (fb532ba)


Adds @phpstan-prefixed generics and conditional return types to the six functions in the slashing/unslashing family, so that static analysis knows a string in yields a string out and an array in yields an array out.

No runtime code changes. This PR only touches docblocks. The plain @param/@return tags that the Code Reference parses are left exactly as they were; all new tags are @phpstan- prefixed and sit below them, per the convention already used elsewhere in core.

What's wrong today

wp_unslash(), wp_slash(), and stripslashes_deep() all promise "in the same type as supplied", but @return string|array cannot express that. Passing a string gets you back array|string, which then poisons every downstream call:

// src/wp-admin/includes/ajax-actions.php:2324
if ( is_array( $_POST['sidebars'] ) ) {           // caller narrows correctly
	foreach ( wp_unslash( $_POST['sidebars'] ) as $key => $val ) {

PHPStan reports Argument of an invalid type array|string supplied for foreach, only iterables are supported. — because wp_unslash() is declared as possibly returning a string, even though the caller just proved the input is an array.

There is also a pre-existing error on trunk inside wp_slash() itself:

src/wp-includes/formatting.php:5817
Parameter #1 $callback of function array_map expects (callable(mixed): mixed)|null, 'wp_slash' given.

wp_slash() recurses via array_map( 'wp_slash', $value ), which hands each element to wp_slash() as mixed. The narrow @param string|array denies that, so PHPStan rejects the function's own recursion. Note the docblock already says @since 5.5.0 Non-string values are left untouched., so accepting mixed is the documented behavior.

Two soundness bugs this fixes

An earlier iteration of this branch used a bare @phpstan-return T ("returns its input unchanged"). That is not what these functions do, and it made PHPStan believe two false things:

map_deep( array( '1', '2' ), 'intval' );
// inferred: array{'1', '2'}      actual: array{1, 2}
// map_deep()'s callback is arbitrary and may change the type of every leaf.

wp_unslash( "O\'Brien" );
// inferred: 'O\'Brien'           actual: 'O'Brien'
// String literals must widen — the function rewrites string contents.

Both are fixed by the conditional return types below. stripslashes_from_strings_only() demonstrates why the conditional is required rather than a plain T: with @phpstan-return T, PHPStan correctly rejects the body with should return T but returns string|T of mixed, because T could be a literal-string subtype while stripslashes() returns plain string.

The changes

Function Return type Rationale
map_deep() arrayarray, objectT, else mixed The callback decides the leaf type. The object branch returns T because map_deep() mutates in place and returns the same instance.
stripslashes_from_strings_only() (T is string ? string : T) Only strings are altered.
stripslashes_deep() string-preserving, array values mapped Its callback only alters strings, so non-strings pass through as T.
wp_slash() same Also fixes the array_map error above.
wp_unslash() same
add_magic_quotes() T of array, array values mapped Lets a precisely-typed superglobal survive wp_magic_quotes().

The shared array-value shape is:

array<key-of<T>, ( value-of<T> is string ? string : value-of<T> )>

On key-of<T> and value-of<T>

key-of<T> is currently a no-op when T is a template type bound to an array — see phpstan/phpstan#14571 (open, unfixed as of 2.2.5). It is retained because it accurately describes the runtime behavior: map_deep() and friends write back to $value[ $index ], so keys are preserved exactly. It will start paying off when that issue is resolved.

value-of<T> resolves today but yields mixed for superglobal data, because PHPStan models $_GET/$_POST as array<mixed>. PHP guarantees their leaves are always string (?input[post][id]=1 produces string("1"), never int), so this too will sharpen if superglobals ever carry precise types.

Impact on PHPStan error counts

Measured with level: 10 locally (the committed phpstan.neon.dist runs at level: 0, so CI totals are unaffected). Whole-repo counts:

trunk this branch net
wp_unslash()/wp_slash() parameter checks 294 0 −294
Everything else 33,180 33,060 −120
Total 33,474 33,060 −414

The −414 headline is misleading and should not be read as "414 bugs fixed." Breaking it down honestly:

1. Genuinely fixed — the narrowing case from the top of this description. foreach.nonIterable on array|string drops from 11 to 9:

if ( is_array( $_POST['sidebars'] ) ) {
	foreach ( wp_unslash( $_POST['sidebars'] ) as $key => $val ) {   // trunk: foreach.nonIterable

2. Silenced, not fixed (294 errors) — adding @phpstan-param T $value overrides the plain @param string|array, so PHPStan stops checking the argument entirely:

// src/wp-admin/admin.php:142
$plugin_page = wp_unslash( $_GET['page'] );
// trunk:  Parameter #1 $value of function wp_unslash expects array|string, mixed given.
// branch: (no error)

These 294 are an artifact of PHPStan typing superglobals as array<mixed>; if $_POST['key'] were string|array<…> it would satisfy @param string|array and they would disappear on their own, with or without this PR.

3. Reworded, still present — a naive multiset diff double-counts these as one fixed and one introduced:

// src/wp-activate.php:23
list( $activate_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) );
// trunk:  explode expects string, array|string given.
// branch: explode expects string, mixed given.

4. Newly reported (53 errors, 29 of them Cannot access offset … on mixed) — these flag call sites that do not narrow, and are arguably an improvement:

// src/wp-admin/includes/ajax-actions.php:3323
$attachment = wp_unslash( $_POST['attachment'] );   // no is_array() guard → mixed
$id         = (int) $attachment['id'];              // Cannot access offset 'id' on mixed.

So the defensible summary is: a small number of real fixes, 294 checks deliberately traded away, and 53 new errors that mark unnarrowed call sites — not a −414 improvement.

Verification

  • All six function bodies validate at PHPStan level 10 with no return.type errors.
  • composer lint (PHPCS / WordPress Coding Standards) is clean on both changed files.
  • Inference spot-checks:
Expression trunk this branch
wp_unslash( $string ) array|string string
wp_unslash( $_POST['k'] ) after is_array() array|string array<mixed>
wp_slash( array<int, int> ) array|string array<int>
wp_slash( $dateTimeImmutable ) array|string DateTimeImmutable
map_deep( ['1','2'], 'intval' ) mixed array<mixed>
wp_unslash( "O\'Brien" ) array|string string

Not included

  • esc_sql() / wpdb::_escape() have the same recursive shape and the same @return string|array … in the same type as supplied promise, but that promise is false: _real_escape() returns '' for non-scalars and string otherwise, so esc_sql( array( 1, 2 ) ) returns array( '1', '2' ). It needs a different (string-producing) conditional and a docblock correction. Worth a separate ticket.
  • urlencode_deep(), rawurlencode_deep(), urldecode_deep(), wp_kses_post_deep() are also string-producing rather than string-preserving.
  • The deprecated wp_slash_strings_only(), addslashes_strings_only(), and addslashes_gpc() are exact analogues, left alone intentionally.

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

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Drafting the conditional return types and verifying them against PHPStan (including the map_deep()/wp_unslash() soundness repros), plus computing and decomposing the error-count deltas. Every claim in this description was checked by running PHPStan and PHP directly; the design decisions, the scope of the change, and this final text were reviewed and directed 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_01LFXAppq3f99RVSAHaHHMB6

westonruter and others added 3 commits July 8, 2026 17:54
A bare `@phpstan-return T` asserts that a function returns its input
unchanged. That is not what these functions do, and PHPStan believed it:

* `map_deep( array( '1', '2' ), 'intval' )` was inferred as
  `array{'1', '2'}` rather than `array{int, int}`, because the callback is
  arbitrary and may change the type of every leaf.
* `wp_unslash( "O\'Brien" )` kept the literal type `'O\'Brien'`, even
  though the function returns `'O'Brien'`. String literals must widen.

Replace them with conditional return types that describe the actual
behavior:

* `map_deep()` returns an array for an array, the same instance for an
  object (it mutates in place and returns it), and `mixed` for a scalar,
  since only the callback decides the leaf type.
* `stripslashes_deep()`, `wp_slash()`, and `wp_unslash()` alter strings
  and nothing else, so every other type passes through as `T`, and array
  values are mapped by the same string-to-string rule.
* `add_magic_quotes()` gains the same treatment, so a precisely typed
  superglobal survives `wp_magic_quotes()` instead of being flattened.

`wp_slash()` needs the template for a second reason: its own
`array_map( 'wp_slash', $value )` recursion requires a parameter that
accepts `mixed`, which the narrow `@param string|array` denied. That was
already reported on trunk as `Parameter #1 $callback of function
array_map expects (callable(mixed): mixed)|null, 'wp_slash' given.`

`key-of<T>` is not yet enforced when `T` is a template type of array
(phpstan/phpstan#14571), and `value-of<T>` will only sharpen once
superglobals carry precise types. Both accurately describe the runtime
behavior today: keys are preserved, and `$_GET`/`$_POST` leaves are
always strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 9, 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.

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 Jul 9, 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.

pento pushed a commit that referenced this pull request Jul 9, 2026
…nctions.

Adds `@phpstan-` prefixed generics and conditional return types to `map_deep()`, `stripslashes_from_strings_only()`, `stripslashes_deep()`, `wp_slash()`, `wp_unslash()`, and `add_magic_quotes()`. Static analysis now knows that a `string` passed to the slashing functions yields a `string`, and an `array` yields an `array`; this is something the plain `@return` tags could not express, and which caused `wp_unslash()` to widen its callers' types to `array|string` even after an `is_array()` guard.

The returns are conditional rather than a bare `T` for two reasons: `map_deep()`'s callback may change the type of every leaf, and the slashing functions rewrite string contents, so a literal-string type cannot survive.

Developed in #12455.

See #64898.


git-svn-id: https://develop.svn.wordpress.org/trunk@62672 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Jul 9, 2026
…nctions.

Adds `@phpstan-` prefixed generics and conditional return types to `map_deep()`, `stripslashes_from_strings_only()`, `stripslashes_deep()`, `wp_slash()`, `wp_unslash()`, and `add_magic_quotes()`. Static analysis now knows that a `string` passed to the slashing functions yields a `string`, and an `array` yields an `array`; this is something the plain `@return` tags could not express, and which caused `wp_unslash()` to widen its callers' types to `array|string` even after an `is_array()` guard.

The returns are conditional rather than a bare `T` for two reasons: `map_deep()`'s callback may change the type of every leaf, and the slashing functions rewrite string contents, so a literal-string type cannot survive.

Developed in WordPress/wordpress-develop#12455.

See #64898.

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


git-svn-id: http://core.svn.wordpress.org/trunk@61957 1a063a9b-81f0-0310-95a4-ce76da25c4cd
@westonruter westonruter closed this Jul 9, 2026
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.

1 participant