WordPress 6.5 refined the post-save hook sequence and introduced changes to wp_after_insert_post, the Interactivity API hooks, and the new template_include improvements. Understanding hook order is critical to avoid infinite loops and double-processing.
Problem: Code hooked into save_post reads post meta or taxonomy terms that are saved in the same request — but because save_post fires before meta and terms are committed, the hook reads stale data.
Solution: Switch to wp_after_insert_post (introduced in WordPress 5.6) — it fires after meta, terms, and taxonomies have all been saved in the same request, so your hook always reads the final committed state. Pass $update as the second parameter to distinguish inserts from updates.
The examples below demonstrate correct usage of wp_after_insert_post for post-save side effects, the wp_after_insert_post $update parameter to distinguish creates from updates, and new 6.5 filter hooks.
// wp_after_insert_post fires AFTER all meta and terms are saved
// (unlike save_post which fires before meta is fully committed)
// Signature: do_action( 'wp_after_insert_post', $post_id, $post, $update, $post_before )
add_action( 'wp_after_insert_post', function( int $post_id, WP_Post $post, bool $update, ?WP_Post $post_before ) {
// Skip auto-saves, revisions, and non-published posts
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) return;
if ( 'publish' !== $post->post_status ) return;
if ( ! $update ) {
// New post — trigger one-time actions (e.g., send welcome notification)
myplugin_notify_subscribers( $post );
} else {
// Update — check if the post was previously a draft
if ( $post_before && 'draft' === $post_before->post_status ) {
myplugin_notify_subscribers( $post ); // first publish from draft
}
}
// Safe to read meta here — it's fully committed
$featured_image_id = get_post_thumbnail_id( $post_id );
if ( $featured_image_id ) {
myplugin_generate_og_image( $featured_image_id );
}
}, 10, 4 );
// WordPress 6.5: new filter to modify the_content lazily (applied during REST render)
add_filter( 'render_block_core/paragraph', function( string $block_content, array $block ) {
// Modify only paragraphs inside a Query Loop
if ( ! empty( $block['attrs']['isQueryLoop'] ) ) {
return '' . $block_content . '';
}
return $block_content;
}, 10, 2 );
Post save hook order reference:
// WordPress post save hook execution order:
// 1. pre_post_update (before DB write, update only)
// 2. wp_insert_post_data (filter — modify data before DB write)
// 3. wp_before_insert_post (action — just before INSERT/UPDATE)
// 4. DB write happens
// 5. save_post_{post_type} (action — post is in DB, meta not yet saved)
// 6. save_post (action)
// 7. wp_insert_post (action)
// 8. [meta boxes / REST meta saved here]
// 9. wp_after_insert_post (action — EVERYTHING is committed) ← safest hook
// Prevent infinite loop when updating post from within a hook:
add_action( 'wp_after_insert_post', function( $post_id, $post ) {
// Remove the action before programmatic update, re-add after
remove_action( 'wp_after_insert_post', __FUNCTION__, 10 );
wp_update_post( [ 'ID' => $post_id, 'post_excerpt' => wp_trim_words( $post->post_content, 30 ) ] );
add_action( 'wp_after_insert_post', __FUNCTION__, 10, 2 );
}, 10, 2 );
NOTE: Use wp_after_insert_post instead of save_post any time your hook reads or writes post meta — it's the only hook where meta is guaranteed to be fully committed to the database.