WordPress the_content Filter Stack: wpautop, do_blocks, do_shortcode Order and Customisation

When the_content() is called in a WordPress template, the raw post content string from the database does not reach the browser unchanged — it passes through a chain of filters attached to the the_content hook. Understanding this filter stack is essential for: adding content before/after every post, processing shortcodes in REST API responses, controlling auto-paragraph conversion (wpautop), preventing specific filters from running on certain post types, and debugging content output issues. The default filters on the_content (in order) are: do_blocks (Gutenberg block rendering, priority 9), wptexturize (smart quotes, priority 10), convert_smilies (priority 20), wpautop (auto-paragraphs, priority 10), shortcode_unautop (priority 10), do_shortcode (priority 11), and capital_P_dangit (priority 11). Each of these can be selectively removed or reordered.

Problem: A plugin needs to: (1) append a "Related Posts" block to all posts in the "News" category, (2) remove wpautop from a custom post type (landing_page) that uses manually structured HTML, and (3) ensure do_shortcode also runs on content displayed via the REST API (which uses apply_filters('the_content', $content) internally).

Solution: Use add_filter('the_content', ...) with high priority for the append, remove_filter inside a filter to conditionally disable wpautop, and confirm that REST API content filtering uses the_content hook.

<?php
// ── 1. Append Related Posts to News category posts ────────────────────
// Priority 99 = runs after all default filters have processed the content
add_filter( 'the_content', function ( string $content ): string {
    // Only on singular news posts (not in loops, REST, etc.)
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    // Only for posts in the 'news' category
    if ( ! has_category( 'news' ) ) {
        return $content;
    }

    $related_html = render_related_posts( get_the_ID() );
    return $content . $related_html;
}, 99 );

function render_related_posts( int $post_id ): string {
    $related = new WP_Query( [
        'category__in'   => wp_get_post_categories( $post_id ),
        'post__not_in'   => [ $post_id ],
        'posts_per_page' => 3,
        'orderby'        => 'date',
    ] );
    if ( ! $related->have_posts() ) return '';

    $out = '<section class="related-posts"><h3>' . __( 'Related Posts', 'textdomain' ) . '</h3><ul>';
    while ( $related->have_posts() ) {
        $related->the_post();
        $out .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
    }
    wp_reset_postdata();
    return $out . '</ul></section>';
}

// ── 2. Remove wpautop for landing_page CPT ────────────────────────────
// Hook into 'the_post' to remove wpautop before content is rendered,
// then restore it so other post types are not affected
add_action( 'the_post', function ( WP_Post $post ) {
    if ( 'landing_page' === $post->post_type ) {
        remove_filter( 'the_content', 'wpautop' );
        // Also remove shortcode_unautop which depends on wpautop
        remove_filter( 'the_content', 'shortcode_unautop' );
    } else {
        // Restore if previously removed
        add_filter( 'the_content', 'wpautop', 10 );
        add_filter( 'the_content', 'shortcode_unautop', 10 );
    }
} );

// ── 3. Full default the_content filter order (for reference) ──────────
// Priority  9: do_blocks        — render Gutenberg blocks
// Priority 10: wptexturize      — smart quotes, dashes
// Priority 10: convert_smilies  — :) → emoji image
// Priority 10: wpautop          — <p> wrapping
// Priority 10: shortcode_unautop — unwrap <p> around shortcodes
// Priority 11: do_shortcode     — execute [shortcode] tags
// Priority 11: capital_P_dangit — WordPress → WordPress (capital P)

// ── Inspect all current filters on the_content ───────────────────────
// Useful for debugging unexpected content transformations:
function debug_the_content_filters(): void {
    global $wp_filter;
    if ( isset( $wp_filter['the_content'] ) ) {
        foreach ( $wp_filter['the_content']->callbacks as $prio => $callbacks ) {
            foreach ( $callbacks as $cb ) {
                $name = is_array( $cb['function'] )
                    ? get_class( $cb['function'][0] ) . '::' . $cb['function'][1]
                    : ( is_string( $cb['function'] ) ? $cb['function'] : '{closure}' );
                echo "Priority {$prio}: {$name}
";
            }
        }
    }
}

NOTE: The is_main_query() and in_the_loop() checks in the append filter are critical — without them, the filter would also run when a theme calls get_the_content() in a sidebar widget, in a REST API response, or in a custom query. get_the_content() does not apply the_content filters — only the_content() does. The WordPress REST API applies the_content filters when generating the rendered field: apply_filters('the_content', $post->post_content) — so any filter you add to the_content also affects REST API output. If you only want a filter to run in templates and not in the REST API, check defined('REST_REQUEST') && REST_REQUEST inside the filter callback.