Filtering Post Content in WordPress
Appending a block of text here is four lines and a recursion bug waiting to happen. The filter runs on archives, in widgets and in the REST response, so content added without a guard ends up in places nobody was shown a preview of.
The dangerous version of this filter is four lines long and looks correct. the_content lets you modify post content before it is printed, which makes it the obvious place to append a disclaimer, a signature or a related-posts block. It also runs on archives, in widgets, inside feeds and in the REST response, and it can re-enter itself. Both problems have the same cure: decide explicitly where the addition belongs.
The minimal safe form
add_filter( 'the_content', 'ti_append_note' );
function ti_append_note( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$note = '<p class="ti-note">' . esc_html__( 'Written by the editorial team.', 'your-textdomain' ) . '</p>';
return $content . $note;
}
Each of the three conditions removes a specific misfire:
is_singular( 'post' )keeps the note off pages, attachments and custom post types, and off archives entirely.in_the_loop()keeps it out of calls made outside the loop, which is where widgets and theme fragments run.is_main_query()keeps it out of secondary queries that happen to run on a singular view, such as a related-posts block rendering three more posts through the same filter.
Dropping any one of them produces a report that reads as random: the note appears twice, or appears in the sidebar, or appears on the category page under every excerpt.
Recursion is the failure that takes the site down
The filter must never cause itself to run. The obvious version of the mistake is explicit:
// Do not do this.
return $content . apply_filters( 'the_content', get_post_field( 'post_content', $related_id ) );
The subtler version is calling a helper that does it internally — a shortcode that renders a post, a template part that prints content, an embed routine. Each pass enters the filter again, and the stack grows until PHP runs out of memory. The result is a white screen with a memory exhaustion line in the log, and the log line points at whatever function happened to be executing when the limit hit, which is rarely the callback at fault.
If content from another post genuinely has to be rendered, remove the filter first and add it back afterwards:
remove_filter( 'the_content', 'ti_append_note' );
$rendered = apply_filters( 'the_content', $other_content );
add_filter( 'the_content', 'ti_append_note' );
Priority relative to core
Core attaches its own callbacks to this filter, and where you sit relative to them changes what you receive:
| Priority | Core callback | Effect |
|---|---|---|
| 10 | wpautop |
Turns blank lines into paragraphs |
| 11 | do_shortcode |
Expands shortcodes |
A callback at priority 9 receives content before paragraphs are created, which is the right place to work with raw markup and the wrong place to parse HTML structure. A callback at 12 receives fully expanded output. Appending a block of markup is safe at the default 10 because it does not depend on either, but anything that inspects the content should choose deliberately.
Where the addition ends up that you did not expect
Content appended here travels further than the page:
- The feed. RSS renders through this filter unless guarded, so subscribers receive the addition too.
is_feed()is the guard. - The REST response. A headless front end or the editor's own preview reads rendered content from the API, and the addition is in it.
- Search engine descriptions. Plugins that generate a description from content may pick up appended text, which is how a disclaimer becomes a meta description. That interacts badly with the discipline described in the WordPress SEO checklist.
- Excerpt generation. An auto-generated excerpt comes from content, so a long appended block can push out the actual opening.
None of these are bugs in the filter. They are the consequence of content being a single value used in several places.
Block themes change the surroundings, not the mechanism
A block theme renders the post through the post content block, which applies the same filter. Callbacks keep working. What changes is that the markup around the content comes from a block template rather than a PHP template, so anything relying on a theme wrapper class may not find it. The structure is described under what a block is in WordPress.
Verify in six places
Check a single post, a page, a category archive, the search results, the feed, and a sidebar widget that prints post content. The addition should appear in exactly one of them.
If it appears in more, the missing guard is identifiable from which ones: archives mean is_singular is absent, sidebars mean in_the_loop is absent, and a doubled note on the same page means is_main_query is absent. If the page fails to load at all, the cause is recursion rather than a guard, and the first thing to check is whether anything in the callback renders another post — starting from enabling debug mode to get the real error.
Frequently asked
- Because the callback applies the_content to something inside itself, directly or through a function that does. Each pass re-enters the filter and the stack grows until memory runs out. Never call apply_filters with the_content from inside a the_content callback.
- Because archives run the same filter for each post in the loop. Guarding with is_singular and in_the_loop limits it to the post being viewed on its own page.
- Yes. The post content block renders through the same filter, so callbacks continue to apply. What changes is the surrounding markup, not whether the filter fires.