the_content is the filter for changing post body HTML. The catch is that core already has several callbacks attached to it, and those callbacks genuinely transform the markup. Which number you stand on decides what string arrives in your hands.
What core already has attached
In short: attach at priority 1 and you see something close to the raw post_content — blocks are still HTML comments, shortcodes are still literal text, and there is not a single <p> anywhere. Attach at priority 12 or later and you get finished HTML with paragraphs wrapped, shortcodes executed and image attributes filled in.
Where “my regex never matches” comes from
This is the most common failure by far. Trying to insert something after the first paragraph by matching </p> at priority 1 can never match, because no paragraph tags exist yet. Conversely, trying to replace a shortcode string at priority 12 finds nothing, because it has already been executed.
The rule is simple: go early for raw markup, late for finished HTML. And remember that HTML you insert early is itself fed to wpautop, so a block-level element added there can pick up a stray closing paragraph tag.
// Sees the original — block comments and shortcodes still intact
add_filter( 'the_content', 'wper_early', 1 );
// Sees finished HTML — paragraphs wrapped, image attributes filled
add_filter( 'the_content', 'wper_late', 12 );
The article pages in this repository use the late side. We parse the body’s h2 elements to add anchors and build a table of contents from the same parse, and that has to run against finished HTML so that block-authored headings and classic markup come out the same shape. Hence priority 12.
This filter does not only run on the post page
Another easy miss: the_content also runs while excerpts are generated. When a post has no hand-written excerpt, WordPress passes the body through this filter to build one.
So an unguarded filter that appends a call to action ends up printing that call to action inside every card excerpt on your listing pages, and in feeds too. Guard it.
function wper_late( $html ) {
if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
return $html; // leave excerpts, feeds and secondary loops alone
}
return $html . '<aside class="wper-cta">…</aside>';
}
in_the_loop() earns its place because even on a single post page a secondary loop — related posts, say — can push a body through the filter again. All three checks together are what let you claim “this is the body of the post being viewed”.
More on content rendering and its performance cost sits in the development workflow archive, and sorting out content processing alongside the caching layers is part of our optimization program.
Next part
Sometimes removing a callback is the right answer rather than painting over it. Part six covers taking somebody else’s hook down, and the three ways that quietly fails.