From 593ef9e304245ebcc39c0a5d02a0663c8a936911 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Tue, 11 Aug 2026 23:28:21 +0100 Subject: [PATCH 1/4] Narrow the accepted and returned types for `esc_sql()`. --- src/wp-includes/class-wpdb.php | 8 ++++++-- src/wp-includes/formatting.php | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index e9d7f986d5801..2ef44892cc0ac 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -1295,8 +1295,12 @@ public function _real_escape( $data ) { * * @uses wpdb::_real_escape() * - * @param string|array $data Data to escape. - * @return string|array Escaped data, in the same type as supplied. + * @phpstan-template T of string|string[] + * @phpstan-param T $data + * @phpstan-return T + * + * @param string|string[] $data Data to escape. + * @return string|string[] Escaped data, in the same type as supplied. */ public function _escape( $data ) { if ( is_array( $data ) ) { diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 74a28109b6536..269ca0637ea81 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -4521,8 +4521,12 @@ function _deep_replace( $search, $subject ) { * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string|array $data Unescaped data. - * @return string|array Escaped data, in the same type as supplied. + * @phpstan-template T of string|string[] + * @phpstan-param T $data + * @phpstan-return T + * + * @param string|string[] $data Unescaped data. + * @return string|string[] Escaped data, in the same type as supplied. */ function esc_sql( $data ) { global $wpdb; From 63ca83bbffe668157aaef5b415c1cd367d408e64 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 11 Aug 2026 17:26:16 -0700 Subject: [PATCH 2/4] Move PHPStan tags to end of docblock --- src/wp-includes/class-wpdb.php | 6 +++--- src/wp-includes/formatting.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 2ef44892cc0ac..282d111b08f9d 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -1295,12 +1295,12 @@ public function _real_escape( $data ) { * * @uses wpdb::_real_escape() * + * @param string|string[] $data Data to escape. + * @return string|string[] Escaped data, in the same type as supplied. + * * @phpstan-template T of string|string[] * @phpstan-param T $data * @phpstan-return T - * - * @param string|string[] $data Data to escape. - * @return string|string[] Escaped data, in the same type as supplied. */ public function _escape( $data ) { if ( is_array( $data ) ) { diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 269ca0637ea81..eab6b5019744d 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -4521,12 +4521,12 @@ function _deep_replace( $search, $subject ) { * * @global wpdb $wpdb WordPress database abstraction object. * + * @param string|string[] $data Unescaped data. + * @return string|string[] Escaped data, in the same type as supplied. + * * @phpstan-template T of string|string[] * @phpstan-param T $data * @phpstan-return T - * - * @param string|string[] $data Unescaped data. - * @return string|string[] Escaped data, in the same type as supplied. */ function esc_sql( $data ) { global $wpdb; From 4667349bd4e77f28c73f3cbfc38edaf857ad6f36 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 11 Aug 2026 22:06:00 -0700 Subject: [PATCH 3/4] Replace the `esc_sql()` generic with a conditional return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `@phpstan-template` cannot express what `_escape()` does. PHPStan is unable to prove that a value the method rebuilds (`$data[ $k ] = …`) or reassigns is still the caller's exact `T`, so `@phpstan-return T` fails even when the recursive call is removed entirely. Templates only carry through pass-through functions, which is why `esc_sql()` itself reported no error while `_escape()` did. The recursive branch caused two further errors. Under `treatPhpDocTypesAsCertain: false` the `is_array( $v )` check is still analyzed even though `$v` is a `string` per the narrowed contract, and `$v` narrows to `never` inside it. A template parameter cannot be inferred from `never`, producing both `argument.unresolvableType` and `method.unresolvableReturnType`. Use a conditional return type instead. The `mixed[]` branch absorbs the widening that the recursive call introduces, so the method body is left untouched and nested arrays keep behaving exactly as before. A truly recursive type is not an option: PHPStan rejects recursive type aliases as circular, and any bounded depth is off by one because the recursive call produces one level more than declared. Callers keep the inference the generic was intended to provide: `string` in yields `string` out, and `string[]` in yields `string[]` out. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wpdb.php | 14 +++++++++++--- src/wp-includes/formatting.php | 4 +--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 282d111b08f9d..205546c6c955f 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -1291,6 +1291,12 @@ public function _real_escape( $data ) { /** * Escapes data. Works on arrays. * + * The final `mixed[]` branch is not reachable via the documented parameter type. It exists + * because the nested `is_array()` check below still recurses on nested arrays, which is + * outside the documented `string[]` contract. PHPStan does not treat the documented types + * as certain, so it analyzes that branch and widens `$data` accordingly; without the + * `mixed[]` branch the widened value would contradict a flat `string[]` return type. + * * @since 2.8.0 * * @uses wpdb::_real_escape() @@ -1298,9 +1304,11 @@ public function _real_escape( $data ) { * @param string|string[] $data Data to escape. * @return string|string[] Escaped data, in the same type as supplied. * - * @phpstan-template T of string|string[] - * @phpstan-param T $data - * @phpstan-return T + * @phpstan-return ( + * $data is string ? string : ( + * $data is string[] ? string[] : mixed[] + * ) + * ) */ public function _escape( $data ) { if ( is_array( $data ) ) { diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index eab6b5019744d..bdce5f7764f8e 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -4524,9 +4524,7 @@ function _deep_replace( $search, $subject ) { * @param string|string[] $data Unescaped data. * @return string|string[] Escaped data, in the same type as supplied. * - * @phpstan-template T of string|string[] - * @phpstan-param T $data - * @phpstan-return T + * @phpstan-return ( $data is array ? string[] : string ) */ function esc_sql( $data ) { global $wpdb; From b7dd933f8ef932d9ee3b1db7f86d7814396eb3eb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 11 Aug 2026 23:04:53 -0700 Subject: [PATCH 4/4] Preserve array keys and drop the unreachable `mixed[]` branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mixed[]` branch was not what allowed the method body to type check. The ordering of the conditional cases was. PHPStan narrows `$v` to `never` inside the nested `is_array()` check, and a `never` argument satisfies whichever case is tested first. Testing for `string` first resolves the recursive call to `string`, so nothing widens `$data`. The earlier `( $data is array ? string[] : string )` tested the array case first, resolved the recursive call to an array, and widened `$data` — which is what the `mixed[]` branch was papering over. Removing it also restores verification. Because `mixed[]` accepts any array, PHPStan could not reject a wrong array return type; changing that branch to `int[]` produced no error. With a concrete `array` return, both a wrong value type and a wrong string case are reported. Add a key template to both functions so the keys of the supplied array survive into the return type. This resolves eight `implode expects array, array given` errors in `WP_Site_Query` and `WP_Network_Query`, which call `_escape()` directly. Six `argument.templateType` errors appear in `WP_Comment_Query`, `WP_User_Query`, and `WP_Date_Query`, where the value passed to `esc_sql()` is typed as `mixed` and no key type can be inferred. Those lines already report the argument type for the same reason, so the fix belongs at the call sites rather than in a looser annotation here. Co-Authored-By: Claude Opus 5 --- src/wp-includes/class-wpdb.php | 19 +++++++++---------- src/wp-includes/formatting.php | 4 +++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 205546c6c955f..a05e8e352dc53 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -1291,11 +1291,12 @@ public function _real_escape( $data ) { /** * Escapes data. Works on arrays. * - * The final `mixed[]` branch is not reachable via the documented parameter type. It exists - * because the nested `is_array()` check below still recurses on nested arrays, which is - * outside the documented `string[]` contract. PHPStan does not treat the documented types - * as certain, so it analyzes that branch and widens `$data` accordingly; without the - * `mixed[]` branch the widened value would contradict a flat `string[]` return type. + * The `$data is string` case must come first in the conditional return type below. PHPStan + * does not treat the documented types as certain, so it still analyzes the nested + * `is_array()` check even though `$v` is a `string`, narrowing `$v` to `never` there. A + * `never` argument satisfies whichever case is tested first, so testing for `string` first + * makes the recursive call resolve to `string`. Testing for the array case first would + * instead resolve it to an array, widening `$data` and contradicting the return type. * * @since 2.8.0 * @@ -1304,11 +1305,9 @@ public function _real_escape( $data ) { * @param string|string[] $data Data to escape. * @return string|string[] Escaped data, in the same type as supplied. * - * @phpstan-return ( - * $data is string ? string : ( - * $data is string[] ? string[] : mixed[] - * ) - * ) + * @phpstan-template TKey of array-key + * @phpstan-param string|array $data + * @phpstan-return ( $data is string ? string : array ) */ public function _escape( $data ) { if ( is_array( $data ) ) { diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index bdce5f7764f8e..b20c08e380e71 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -4524,7 +4524,9 @@ function _deep_replace( $search, $subject ) { * @param string|string[] $data Unescaped data. * @return string|string[] Escaped data, in the same type as supplied. * - * @phpstan-return ( $data is array ? string[] : string ) + * @phpstan-template TKey of array-key + * @phpstan-param string|array $data + * @phpstan-return ( $data is string ? string : array ) */ function esc_sql( $data ) { global $wpdb;