Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 10 / 18 25 MIN

The public side

Shortcodes, and When Not to Use Them

The shortcode callback contract, attribute handling, the wpautop interaction, and an honest answer to when a shortcode still beats a block.

A client asks for a badge on a handful of old posts. You write a shortcode, it works, and the request is done in twenty minutes. Six months later you are maintaining a shortcode that has grown attributes, a nested shortcode inside it, and a paragraph wrapper it did not ask for, and nobody can remember why it behaves differently on the archive page than it does in the editor.

None of that is a reason to avoid shortcodes. It is a reason to know exactly what the shortcode pipeline guarantees, because the guarantees are narrower than most plugins assume. A shortcode is a regular expression over post content that calls a function, and almost every surprise comes from one of three places: what the callback does with its return value, what the content has already been through by the time the callback runs, and where in the request the expansion happens.

The callback returns a string, and echoing is not a slower way to do that

In WordPress 7.0.7, add_shortcode( $tag, $callback ) stores the callback in the global $shortcode_tags array. The name is validated: an empty tag, or a tag containing a space or one of & / < > [ ] =, is rejected with a _doing_it_wrong() notice and the callback is never registered. That is the whole of registration. There is no object, no priority, and no way to register two callbacks for one tag.

When content is processed, do_shortcode_tag() finds the match and calls your function with three arguments — the parsed attributes, the enclosed content, and the tag name — then concatenates whatever it returns into the string it is building. The return value is the output. A callback that prints instead of returning does not place its output where the shortcode is; it prints into whatever output buffer is open, which during a normal front-end request means the top of the page, before the loop has even started. This is not theoretical. Register a callback that echoes and run it:

add_shortcode( 'wpsifero_badge', 'wpsifero_badge_shortcode' );

function wpsifero_badge_shortcode( array $atts, ?string $content = null, string $tag = '' ): string {
	$atts = shortcode_atts(
		array(
			'label' => 'New',
			'tone'  => 'info',
		),
		$atts,
		$tag
	);

	$tone = in_array( $atts['tone'], array( 'info', 'warn' ), true ) ? $atts['tone'] : 'info';

	$inner = ( null === $content || '' === $content )
		? esc_html( $atts['label'] )
		: do_shortcode( $content );

	return sprintf(
		'<span class="wpsifero-badge wpsifero-badge--%s">%s</span>',
		esc_attr( $tone ),
		$inner
	);
}

I ran that against a WordPress 7.0.7 install and captured the buffer while do_shortcode() was called. A callback that echoed LEAKED returned the empty string and left LEAKED in the output buffer. The same call to a callback that returns produced the span and left the buffer empty.

Two habits fall out of that. Escape at the point of output, as above, because a label is user input from the post body. And if your callback genuinely needs a template, use ob_start() with ob_get_clean() and return the result; do not let the buffer escape.

shortcode_atts fills defaults, drops unknowns, and fires a filter if you name the tag

Attribute parsing happens in two steps. shortcode_parse_atts() turns the raw attribute string into an array. It lowercases attribute names, strips slashes, treats a bare word as a positional value rather than a pair, and collapses unclosed HTML in a value to the empty string. Everything else is your responsibility, which is what shortcode_atts() is for:

$atts = shortcode_atts(
	array( 'label' => 'New', 'tone' => 'info' ),
	$atts,
	$tag
);

The first argument is the complete list of attributes you support, with defaults. The second is what the author wrote. The result contains only keys from the first argument, so an author's typo is dropped rather than passed to your code as a surprise. I supplied array( 'label' => 'Sale', 'bogus' => 'x' ) and got back {"label":"Sale","tone":"info"}: the unknown key gone, the missing default filled in.

The third argument is the metadata that makes the function worth calling instead of writing the merge yourself. When it is non-empty, WordPress applies a filter named shortcode_atts_{$shortcode} to the result, with four arguments: the merged attributes, your defaults, the author's original input, and the tag name. Name the tag and a site can rewrite your defaults without patching your file. Leave it out, as a surprising number of plugins do, and the same merge runs but the filter never fires, so the site's customisation silently does nothing.

One detail that catches people who assume the filter is a normalisation hook: it can add keys that were never in your defaults, and it can remove them. After hooking it, I got {"label":"New","tone":"info","injected":"by the filter"} back from an empty input. Read attributes with a null-safe accessor and re-validate anything with a closed set of legal values, exactly as the $tone check above does.

Enclosing shortcodes hand you the inner content unprocessed

An enclosing shortcode — [wpsifero_badge]50% off[/wpsifero_badge] — arrives with its inner text in the second argument, or null when the tag closes itself. Bare shortcode tags are also supported: since WordPress 6.5 the third argument to the filter is always an array, and the second is always a string or null, so you can declare the signature without guessing.

The content has not been through the content filters, so a shortcode nested inside it is still literal text. If you want nesting to work you call do_shortcode( $content ) on it yourself, as the example does. That is a deliberate step, and it is also the step that lets nested content break your layout.

Nested tags are not the only thing do_shortcode() expands. An attribute such as title="[wpsifero_badge]" holds a literal tag by default, and I watched it come back as escaped markup the browser then read as an attribute value. If your plugin prints documentation that quotes its own shortcodes, write [[wpsifero_badge]] in the source, which do_shortcode_tag() unwraps to the literal tag, or pass true as the second argument to do_shortcode() so brackets inside HTML elements are encoded rather than expanded.

Inside a block editor post, a shortcode is a compatibility path

A shortcode in the post content of a block editor post does render on the front end, because the front end runs the same the_content filters it always did. What it does not get is any participation in the editor. The author sees a short text tag, not the badge; a mistyped attribute is dropped at render time instead of being flagged as you type; and the saved markup does not represent the output, which is what makes pattern previews and any later move to a server-rendered block awkward. None of that is fatal for a site with thousands of posts already using the shortcode. It is a strong argument for a block when you are building the feature today.

wpautop wraps shortcode paragraphs, and the order of the two filters decides the rest

On the_content, WordPress 7.0.7 registers three filters in this order: wpautop at priority 10, shortcode_unautop at priority 10 after it, and do_shortcode at priority 11. I confirmed those priorities with has_filter() on a live install.

Read the consequence carefully. Paragraphs are added before shortcodes are expanded, so a shortcode's own output never passes through wpautop in the default pipeline. That is why a callback can return block-level markup with newlines in it and survive: I ran a shortcode returning a two-line value through apply_filters( 'the_content', ... ) and the two lines came out untouched, while calling wpautop( do_shortcode( $text ) ) — the other order — turned them into two separate paragraphs. If you call do_shortcode() yourself at a priority before 10, or in a widget, you have chosen that second order and you own the consequences.

The other half is shortcode_unautop(), which exists for one case: a paragraph whose only content is the shortcode. wpautop has already wrapped [wpsifero_badge] in <p>; unautop removes that wrapper. It only recognises registered tag names, and only when nothing else is in the paragraph.

raw:                    [wpsifero_badge]
after wpautop:          <p>[wpsifero_badge]</p>
after shortcode_unautop: [wpsifero_badge]
after do_shortcode:      <span class="wpsifero-badge ...">New</span>

raw:                    Text [wpsifero_wide] more text.
after wpautop:          <p>Text [wpsifero_wide] more text.</p>
after do_shortcode:      <p>Text <figure class="wide">...</figure> more text.</p>

The second example is the one to remember: a block-level shortcode written in the middle of a sentence produces a figure inside a p, which no browser will render the way the author imagined. If a shortcode emits block-level markup, document that it belongs on its own line.

A shortcode is still right when the content lives outside the editor

The honest answer is not that blocks replaced shortcodes. The two serve different authoring surfaces.

A shortcode is right when the content is authored in a classic editor, a widget, a text field, or a template written in PHP. It is right when a site already has the tag in thousands of posts. It is right when the same output has to be callable from PHP, because do_shortcode() is a plain function and a block is not. And it is right when the consumer is another plugin that has agreed to call your tag by name.

A block is right when a human inserts the thing in the editor and needs to see it, when its attributes deserve real controls, or when it belongs in the inserter and in patterns. Those are authoring concerns. The cost of choosing wrongly is not symmetric: a block on a site whose authors never open the block editor buys nothing and adds a build step, while a shortcode for something editors insert daily leaves a permanent gap in the editing experience.

Lab 30 minutes

Write a shortcode [wpsifero_note tone="warn"]Text[/wpsifero_note] that renders a div with a tone class, defaults to info, rejects any other tone, and escapes the inner text. The tag name must be passed to shortcode_atts().

Then answer four questions. What does the shortcode output when the author omits the closing tag? What happens to a nested [wpsifero_badge] inside the note if you do not call do_shortcode() on the content, and what happens if you do? What does the post look like when the note is written on the same line as a sentence, given that the note returns a div? And which of the two filter orders — the_content's, or your own do_shortcode() call at priority 9 — puts a paragraph around the note's output?

Show the solution and the reasoning

Omit the closing tag and the shortcode is self-closing: the second argument is null, so there is no inner text and the callback has to decide what to render. The version below falls back to the attribute, which is the same choice [wpsifero_badge] makes earlier in the lesson, and it is the reason the signature declares ?string $content = null. Treating null and '' as the same case is also what the core filter does since 6.5, so you are not working around an inconsistency.

add_shortcode( 'wpsifero_note', 'wpsifero_note_shortcode' );

function wpsifero_note_shortcode( array $atts, ?string $content = null, string $tag = '' ): string {
	$atts = shortcode_atts(
		array(
			'tone' => 'info',
			'text' => '',
		),
		$atts,
		$tag
	);

	$tone = in_array( $atts['tone'], array( 'info', 'warn' ), true ) ? $atts['tone'] : 'info';
	$body = ( null === $content || '' === $content ) ? $atts['text'] : do_shortcode( $content );

	return sprintf(
		'<div class="wpsifero-note wpsifero-note--%s">%s</div>',
		esc_attr( $tone ),
		wp_kses_post( $body )
	);
}

Leaving the do_shortcode() call out means a nested [wpsifero_badge] reaches the output as the literal text [wpsifero_badge], because the outer match consumed the whole span and nothing re-scans the inner content. Putting it in means the badge renders inside the note. Nesting is opt-in, and it is worth opting in: a note that cannot contain the site's own inline components is a note authors will work around.

The div on the same line as a sentence is the case the earlier trace showed with figure. wpautop sees a paragraph containing text and a shortcode, shortcode_unautop refuses to touch it because the paragraph is not shortcode-only, and do_shortcode then drops a block element into the middle of a p. The parser closes the paragraph early and the result is not what the author drew. Either return inline markup, or say in the plugin's readme that the tag occupies its own line.

On filter order: the_content runs wpautop at 10 and do_shortcode at 11, so the note's own newlines are never paragraph-wrapped by that filter. Add your own do_shortcode at priority 9 and you have expanded the shortcode first, so wpautop at 10 now sees the note's div — and any blank line inside it — and wraps what it finds. If a plugin has a reason to expand shortcodes early, the honest fix is to run shortcode_unautop() immediately afterwards, which strips only the paragraph wrappers that enclose a registered shortcode and nothing else.

All four behaviours described here were executed against WordPress 7.0.7 with PHP 8.5 rather than read from the source; the priorities were read from wp-includes/default-filters.php, which is the file that decides them.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.