From 2df9558d8894700937f2f4017ea3633dbc68caa2 Mon Sep 17 00:00:00 2001 From: Igor Rozum Date: Fri, 23 Oct 2026 11:12:45 -0400 Subject: [PATCH 1/2] Posts, Post Types: Pass the original array of arguments to hooks that fire within wp_insert_post() --- .../class-wp-customize-manager.php | 10 +- src/wp-includes/comment.php | 4 +- src/wp-includes/post.php | 128 +++++++++------ .../tests/comment/wpUpdateCommentCountNow.php | 20 +++ tests/phpunit/tests/customize/manager.php | 29 ++++ tests/phpunit/tests/post/attachments.php | 39 +++++ tests/phpunit/tests/post/wpInsertPost.php | 152 ++++++++++++++++++ tests/phpunit/tests/post/wpPublishPost.php | 26 +++ 8 files changed, 356 insertions(+), 52 deletions(-) diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index 412f575e3afd0..58a7b3b746b1d 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -3109,19 +3109,19 @@ public function trash_changeset_post( $post ) { wp_transition_post_status( $new_status, $previous_status, $post ); /** This action is documented in wp-includes/post.php */ - do_action( "edit_post_{$post->post_type}", $post->ID, $post ); + do_action( "edit_post_{$post->post_type}", $post->ID, $post, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'edit_post', $post->ID, $post ); + do_action( 'edit_post', $post->ID, $post, array() ); /** This action is documented in wp-includes/post.php */ - do_action( "save_post_{$post->post_type}", $post->ID, $post, true ); + do_action( "save_post_{$post->post_type}", $post->ID, $post, true, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'save_post', $post->ID, $post, true ); + do_action( 'save_post', $post->ID, $post, true, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'wp_insert_post', $post->ID, $post, true ); + do_action( 'wp_insert_post', $post->ID, $post, true, array() ); wp_after_insert_post( get_post( $post_id ), true, $post ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b6847266eb162..e685ff308d974 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3276,10 +3276,10 @@ function wp_update_comment_count_now( $post_id ) { do_action( 'wp_update_comment_count', $post_id, $new, $old ); /** This action is documented in wp-includes/post.php */ - do_action( "edit_post_{$post->post_type}", $post_id, $post ); + do_action( "edit_post_{$post->post_type}", $post_id, $post, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'edit_post', $post_id, $post ); + do_action( 'edit_post', $post_id, $post, array() ); return true; } diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 78fa26a1b0422..b8ee1a7b88696 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -5116,12 +5116,15 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * Filters whether or not to add a `__trashed` suffix to the name of trashed posts that match the name of the updated post. * * @since 5.4.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param bool $add_trashed_suffix Whether to attempt to add the suffix. - * @param string $post_name The name of the post being updated. - * @param int $post_id Post ID. + * @param bool $add_trashed_suffix Whether to attempt to add the suffix. + * @param string $post_name The name of the post being updated. + * @param int $post_id Post ID. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). */ - $add_trashed_suffix = apply_filters( 'add_trashed_suffix_to_trashed_posts', true, $post_name, $post_id ); + $add_trashed_suffix = apply_filters( 'add_trashed_suffix_to_trashed_posts', true, $post_name, $post_id, $unsanitized_postarr ); if ( $add_trashed_suffix ) { wp_add_trashed_suffix_to_post_name_for_trashed_posts( $post_name, $post_id ); @@ -5216,11 +5219,14 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * Fires immediately before an existing post is updated in the database. * * @since 2.5.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Post ID. - * @param array $data Array of unslashed post data. + * @param int $post_id Post ID. + * @param array $data Array of unslashed post data. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). */ - do_action( 'pre_post_update', $post_id, $data ); + do_action( 'pre_post_update', $post_id, $data, $unsanitized_postarr ); if ( false === $wpdb->update( $wpdb->posts, $data, $where ) ) { if ( $wp_error ) { @@ -5410,10 +5416,13 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * Fires once an existing attachment has been updated. * * @since 2.0.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Attachment ID. + * @param int $post_id Attachment ID. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data + * as originally passed to wp_insert_post(). */ - do_action( 'edit_attachment', $post_id ); + do_action( 'edit_attachment', $post_id, $unsanitized_postarr ); $post_after = get_post( $post_id ); @@ -5421,22 +5430,28 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * Fires once an existing attachment has been updated. * * @since 4.4.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Post ID. - * @param WP_Post $post_after Post object following the update. - * @param WP_Post $post_before Post object before the update. + * @param int $post_id Post ID. + * @param WP_Post $post_after Post object following the update. + * @param WP_Post $post_before Post object before the update. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data + * as originally passed to wp_insert_post(). */ - do_action( 'attachment_updated', $post_id, $post_after, $post_before ); + do_action( 'attachment_updated', $post_id, $post_after, $post_before, $unsanitized_postarr ); } else { /** * Fires once an attachment has been added. * * @since 2.0.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Attachment ID. + * @param int $post_id Attachment ID. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data + * as originally passed to wp_insert_post(). */ - do_action( 'add_attachment', $post_id ); + do_action( 'add_attachment', $post_id, $unsanitized_postarr ); } return $post_id; @@ -5455,21 +5470,29 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * - `edit_post_page` * * @since 5.1.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). + * Empty array when the hook fires outside of wp_insert_post(). */ - do_action( "edit_post_{$post->post_type}", $post_id, $post ); + do_action( "edit_post_{$post->post_type}", $post_id, $post, $unsanitized_postarr ); /** * Fires once an existing post has been updated. * * @since 1.2.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). + * Empty array when the hook fires outside of wp_insert_post(). */ - do_action( 'edit_post', $post_id, $post ); + do_action( 'edit_post', $post_id, $post, $unsanitized_postarr ); $post_after = get_post( $post_id ); @@ -5477,12 +5500,15 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * Fires once an existing post has been updated. * * @since 3.0.0 + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. * - * @param int $post_id Post ID. - * @param WP_Post $post_after Post object following the update. - * @param WP_Post $post_before Post object before the update. + * @param int $post_id Post ID. + * @param WP_Post $post_after Post object following the update. + * @param WP_Post $post_before Post object before the update. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). */ - do_action( 'post_updated', $post_id, $post_after, $post_before ); + do_action( 'post_updated', $post_id, $post_after, $post_before, $unsanitized_postarr ); } /** @@ -5497,34 +5523,46 @@ function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) * - `save_post_page` * * @since 3.7.0 - * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. - * @param bool $update Whether this is an existing post being updated. + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). + * Empty array when the hook fires outside of wp_insert_post(). */ - do_action( "save_post_{$post->post_type}", $post_id, $post, $update ); + do_action( "save_post_{$post->post_type}", $post_id, $post, $update, $unsanitized_postarr ); /** * Fires once a post has been saved. * * @since 1.5.0 - * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. - * @param bool $update Whether this is an existing post being updated. + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). + * Empty array when the hook fires outside of wp_insert_post(). */ - do_action( 'save_post', $post_id, $post, $update ); + do_action( 'save_post', $post_id, $post, $update, $unsanitized_postarr ); /** * Fires once a post has been saved. * * @since 2.0.0 - * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. - * @param bool $update Whether this is an existing post being updated. + * @since 7.3.0 The `$unsanitized_postarr` parameter was added. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as + * originally passed to wp_insert_post(). + * Empty array when the hook fires outside of wp_insert_post(). */ - do_action( 'wp_insert_post', $post_id, $post, $update ); + do_action( 'wp_insert_post', $post_id, $post, $update, $unsanitized_postarr ); if ( $fire_after_hooks ) { wp_after_insert_post( $post, $update, $post_before ); @@ -5682,19 +5720,19 @@ function wp_publish_post( $post ) { wp_transition_post_status( 'publish', $old_status, $post ); /** This action is documented in wp-includes/post.php */ - do_action( "edit_post_{$post->post_type}", $post->ID, $post ); + do_action( "edit_post_{$post->post_type}", $post->ID, $post, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'edit_post', $post->ID, $post ); + do_action( 'edit_post', $post->ID, $post, array() ); /** This action is documented in wp-includes/post.php */ - do_action( "save_post_{$post->post_type}", $post->ID, $post, true ); + do_action( "save_post_{$post->post_type}", $post->ID, $post, true, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'save_post', $post->ID, $post, true ); + do_action( 'save_post', $post->ID, $post, true, array() ); /** This action is documented in wp-includes/post.php */ - do_action( 'wp_insert_post', $post->ID, $post, true ); + do_action( 'wp_insert_post', $post->ID, $post, true, array() ); wp_after_insert_post( $post, true, $post_before ); } diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 34ec540400ab4..a390e249e9bb0 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -100,4 +100,24 @@ public function test_only_approved_regular_comments_are_counted() { public function _return_100() { return 100; } + + /** + * @ticket 60038 + */ + public function test_edit_post_receives_empty_unsanitized_postarr() { + $post_id = self::factory()->post->create(); + $captured = null; + add_action( + 'edit_post', + static function ( $post_id, $post, $unsanitized_postarr = null ) use ( &$captured ) { + $captured = $unsanitized_postarr; + }, + 10, + 3 + ); + + wp_update_comment_count_now( $post_id ); + + $this->assertSame( array(), $captured ); + } } diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index ed0aaaf85cd59..1d13b048216e6 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -3693,6 +3693,35 @@ public function test_sanitize_external_header_video_trim() { $this->assertSame( $video_url, $sanitized ); } } + + /** + * @ticket 60038 + */ + public function test_trash_changeset_post_passes_empty_unsanitized_postarr() { + $args = array( + 'post_type' => 'customize_changeset', + 'post_content' => wp_json_encode( array( 'blogname' => array( 'value' => 'Test' ) ) ), + 'post_name' => wp_generate_uuid4(), + 'post_status' => 'draft', + ); + + $post_id = wp_insert_post( $args ); + $manager = $this->create_test_manager( $args['post_name'] ); + + $captured = null; + add_action( + 'save_post', + static function ( $post_id, $post, $update, $unsanitized_postarr = null ) use ( &$captured ) { + $captured = $unsanitized_postarr; + }, + 10, + 4 + ); + + $manager->trash_changeset_post( $post_id ); + + $this->assertSame( array(), $captured ); + } } require_once ABSPATH . WPINC . '/class-wp-customize-setting.php'; diff --git a/tests/phpunit/tests/post/attachments.php b/tests/phpunit/tests/post/attachments.php index 76315dcd7c954..99deb84c39da1 100644 --- a/tests/phpunit/tests/post/attachments.php +++ b/tests/phpunit/tests/post/attachments.php @@ -552,4 +552,43 @@ public function test_wp_mime_type_icon_video_with_preferred_ext() { $this->assertStringContainsString( 'images/media/video.png', $icon1, 'Mime type icon should be correctly returned with ".png" argument.' ); $this->assertStringContainsString( 'images/media/video.png', $icon2, 'Mime type icon should be correctly returned with "png" argument.' ); } + + /** + * @ticket 60038 + */ + public function test_attachment_hooks_receive_unsanitized_postarr() { + $captured = array(); + $callback = static function () use ( &$captured ) { + $args = func_get_args(); + $captured[ current_action() ] = end( $args ); + }; + + add_action( 'add_attachment', $callback, 10, 2 ); + add_action( 'edit_attachment', $callback, 10, 2 ); + add_action( 'attachment_updated', $callback, 10, 4 ); + + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Attachment', + 'post_mime_type' => 'image/jpeg', + 'my_custom_arg' => 'abc', + ) + ); + + $this->assertIsArray( $captured['add_attachment'] ); + $this->assertSame( 'abc', $captured['add_attachment']['my_custom_arg'] ); + + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_title' => 'Updated attachment', + 'my_custom_arg' => 'abc', + ) + ); + + $this->assertIsArray( $captured['edit_attachment'] ); + $this->assertSame( 'abc', $captured['edit_attachment']['my_custom_arg'] ); + $this->assertIsArray( $captured['attachment_updated'] ); + $this->assertSame( 'abc', $captured['attachment_updated']['my_custom_arg'] ); + } } diff --git a/tests/phpunit/tests/post/wpInsertPost.php b/tests/phpunit/tests/post/wpInsertPost.php index 187ec7ceb0ab9..1d5fcb150b526 100644 --- a/tests/phpunit/tests/post/wpInsertPost.php +++ b/tests/phpunit/tests/post/wpInsertPost.php @@ -1548,4 +1548,156 @@ public function test_scheduled_post_with_a_past_date_should_be_published() { $this->assertSame( 'future', get_post_status( $post_id ) ); } + + /** + * @ticket 60038 + * + * @dataProvider data_save_post_hooks + * + * @param string $hook Hook name. + */ + public function test_save_post_hooks_receive_unsanitized_postarr( $hook ) { + $captured = null; + add_action( + $hook, + static function ( $post_id, $post, $update, $unsanitized_postarr = null ) use ( &$captured ) { + $captured = $unsanitized_postarr; + }, + 10, + 4 + ); + + wp_insert_post( + array( + 'post_title' => 'Original title', + 'post_status' => 'publish', + 'my_custom_arg' => 'abc', + ) + ); + + $this->assertIsArray( $captured ); + $this->assertSame( 'abc', $captured['my_custom_arg'] ); + $this->assertSame( 'Original title', $captured['post_title'] ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_save_post_hooks() { + return array( + 'save_post' => array( 'save_post' ), + 'save_post_post' => array( 'save_post_post' ), + 'wp_insert_post' => array( 'wp_insert_post' ), + ); + } + + /** + * @ticket 60038 + * + * @dataProvider data_update_hooks + * + * @param string $hook Hook name. + * @param int $accepted_args Number of arguments the callback accepts. + */ + public function test_update_hooks_receive_unsanitized_postarr( $hook, $accepted_args ) { + $post_id = self::factory()->post->create(); + $captured = null; + add_action( + $hook, + static function () use ( &$captured ) { + $captured = func_get_args()[ func_num_args() - 1 ]; + }, + 10, + $accepted_args + ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated title', + 'my_custom_arg' => 'abc', + ) + ); + + $this->assertIsArray( $captured ); + $this->assertSame( 'abc', $captured['my_custom_arg'] ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_update_hooks() { + return array( + 'pre_post_update' => array( 'pre_post_update', 3 ), + 'edit_post' => array( 'edit_post', 3 ), + 'edit_post_post' => array( 'edit_post_post', 3 ), + 'post_updated' => array( 'post_updated', 4 ), + ); + } + + /** + * @ticket 60038 + */ + public function test_unsanitized_postarr_is_not_sanitized() { + $captured = null; + add_action( + 'save_post', + static function ( $post_id, $post, $update, $unsanitized_postarr = null ) use ( &$captured ) { + $captured = $unsanitized_postarr; + }, + 10, + 4 + ); + + $post_id = wp_insert_post( + array( + 'post_title' => 'Title', + 'post_name' => 'My Slug', + 'post_status' => 'publish', + ) + ); + + $this->assertSame( 'my-slug', get_post_field( 'post_name', $post_id ) ); + $this->assertSame( 'My Slug', $captured['post_name'] ); + } + + /** + * @ticket 60038 + */ + public function test_add_trashed_suffix_filter_receives_unsanitized_postarr() { + $trashed_id = self::factory()->post->create( + array( + 'post_name' => 'foo', + 'post_status' => 'publish', + ) + ); + wp_trash_post( $trashed_id ); + + $captured = null; + add_filter( + 'add_trashed_suffix_to_trashed_posts', + static function ( $add_suffix, $post_name, $post_id, $unsanitized_postarr = null ) use ( &$captured ) { + $captured = $unsanitized_postarr; + return $add_suffix; + }, + 10, + 4 + ); + + wp_insert_post( + array( + 'post_title' => 'Foo', + 'post_name' => 'foo', + 'post_status' => 'publish', + 'my_custom_arg' => 'abc', + ) + ); + + $this->assertIsArray( $captured ); + $this->assertSame( 'abc', $captured['my_custom_arg'] ); + } } diff --git a/tests/phpunit/tests/post/wpPublishPost.php b/tests/phpunit/tests/post/wpPublishPost.php index 2f0818afae9c6..ad0129b9b27b9 100644 --- a/tests/phpunit/tests/post/wpPublishPost.php +++ b/tests/phpunit/tests/post/wpPublishPost.php @@ -264,4 +264,30 @@ public function test_wp_publish_post_adds_default_term() { 'wp_publish_post failed to add default term for custom taxonomy.' ); } + + /** + * @ticket 60038 + */ + public function test_wp_publish_post_passes_empty_unsanitized_postarr() { + $draft_id = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + $captured = array(); + $callback = static function ( $post_id ) use ( $draft_id, &$captured ) { + if ( $post_id !== $draft_id ) { + return; + } + + $args = func_get_args(); + $captured[ current_action() ] = end( $args ); + }; + + add_action( 'edit_post', $callback, 10, 3 ); + add_action( 'save_post', $callback, 10, 4 ); + add_action( 'wp_insert_post', $callback, 10, 4 ); + + wp_publish_post( $draft_id ); + + $this->assertSame( array(), $captured['edit_post'] ); + $this->assertSame( array(), $captured['save_post'] ); + $this->assertSame( array(), $captured['wp_insert_post'] ); + } } From 3a80cfa29fb3f5483f03417249347c3d0a8a902a Mon Sep 17 00:00:00 2001 From: Igor Rozum Date: Fri, 23 Oct 2026 11:14:18 -0400 Subject: [PATCH 2/2] Build/Test Tools: Ignore PHPStan hook parameter count mismatch caused by the edit_post_{$field} filter --- phpstan.neon.dist | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3363599ff05fe..dc7a3673b1346 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -101,6 +101,17 @@ parameters: path: src/wp-includes/canonical.php count: 1 + # Both the `edit_post_{$field}` filter in sanitize_post_field() and the `edit_post_{$post->post_type}` action + # normalize to the hook name "edit_post_{...}", so PHPStan compares every call of the action against the + # filter's two documented parameters. The two are different hooks with different signatures. + - + message: '#^do_action\(\) for hook "edit_post_\{\.\.\.\}" provides 3 arguments, but the hook is documented with 2 parameters\.$#' + identifier: wordpress.hookParamCountMismatch + paths: + - src/wp-includes/class-wp-customize-manager.php + - src/wp-includes/comment.php + - src/wp-includes/post.php + # In core, @internal is advisory for extenders: it marks a symbol that plugins should not rely on. Core itself is # one codebase and its own uses of these symbols are intended. PHPStan scopes @internal by root namespace, and # since core symbols live in the global namespace, it reports every use outside the declaring class or file.