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.