Shortcodes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A shortcode is a bracketed tag such as [gallery] that an author types into content and that a plugin replaces with generated HTML when the content is displayed. They arrived in WordPress 2.5 so that content could call PHP code without containing PHP. Blocks have replaced most of their uses, but many plugins and years of content still rely on them. This page, for plugin developers, adds [lumen_books] and [lumen_callout] to the lumen-catalog plugin of the Lumen Press site and explains the parser behind them.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0 and WPCS 3.4.1, and checked on 2026-10-28. The API’s last behaviour change was in 6.5 (attributes always an array). The examples extend lumen-catalog from Plugin Fundamentals and Custom Post Types and Taxonomies, and were run on the Docker Compose stack of Local Development Environments.

How WordPress processes shortcodes

add_shortcode( $tag, $callback ) stores the callback in the global $shortcode_tags array. Nothing happens until a piece of text goes through do_shortcode() (or its alias apply_shortcodes(), 5.4). That function finds the registered tag names in the text, builds one regular expression from them (get_shortcode_regex()), and replaces each match with the return value of its callback. Text that contains no [ is returned untouched, and an unregistered tag, for example one left by a deactivated plugin, is printed literally.

WordPress calls do_shortcode() in these places:

Where Details

the_content filter, priority 11

After do_blocks (9) and wpautop (10), before wp_filter_content_tags (12). Covers posts, pages, custom post types and the REST API content.rendered field.

widget_text_content, widget_block_content, priority 11

Text widget and block widgets; no global $post is set up.

Block templates

get_the_block_template_html() runs do_shortcode() over the whole template, so a shortcode in a block theme template, template part or pattern works.

The [embed] pass, priority 8

WP_Embed->run_shortcode() processes only [embed], before wpautop, by temporarily unregistering every other shortcode.

Titles, options and meta values are not processed (code that prints them calls do_shortcode() itself), and automatic excerpts call strip_shortcodes(). The content filter order is in Hooks: Actions and Filters.

Shortcode syntax

Form Meaning

[lumen_books]

Self-closing. The callback receives an empty attribute array and $content set to null.

[lumen_books genre="fantasy" limit="3"]

Attributes in double quotes, single quotes or unquoted (limit=3).

[lumen_books fantasy]

A value without a name becomes a numerically indexed attribute ($atts[0]).

[lumen_callout]text[/lumen_callout]

Enclosing. The text between the tags is passed as $content.

[[lumen_books]]

Escaped: prints [lumen_books] literally, for documentation.

The parser lowercases attribute names (Genre="x" arrives as genre) and empties any value containing an unclosed HTML tag. Since 6.5 the callback always receives an array; before, a tag without attributes passed an empty string. Tag names may not contain spaces or & / < > [ ] =; since 4.4 add_shortcode() refuses them with a notice.

The [lumen_books] shortcode

[lumen_books genre="..." limit="..."] lists published books, optionally from one lp_genre term. The class below registers it and the [lumen_callout] enclosing shortcode, together with a small stylesheet. Shortcodes are registered on init, in a plugin: a theme that registers them takes the content hostage, because the tags print literally once the theme is switched.

<?php
/**
 * The [lumen_books] and [lumen_callout] shortcodes.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Registers and renders the catalogue shortcodes. */
final class Shortcodes {

	/** Registers the shortcodes and their stylesheet. Runs on init. */
	public static function register(): void {
		add_shortcode( 'lumen_books', array( self::class, 'books' ) );
		add_shortcode( 'lumen_callout', array( self::class, 'callout' ) );

		$url = plugins_url( 'assets/shortcodes.css', LUMEN_CATALOG_FILE );
		wp_register_style( 'lumen-catalog-shortcodes', $url, array(), LUMEN_CATALOG_VERSION );
	}

	/** Enqueues the stylesheet in <head> when the queried post uses a shortcode. Runs on wp_enqueue_scripts. */
	public static function enqueue_for_content(): void {
		$content = is_singular() ? (string) get_post_field( 'post_content', get_queried_object_id() ) : '';
		if ( has_shortcode( $content, 'lumen_books' ) || has_shortcode( $content, 'lumen_callout' ) ) {
			wp_enqueue_style( 'lumen-catalog-shortcodes' );
		}
	}

	/**
	 * Renders [lumen_books genre="..." limit="..."]. Returns HTML; never echoes.
	 *
	 * @param array $atts Attributes as the author typed them.
	 */
	public static function books( $atts ): string {
		$atts = shortcode_atts(
			array(
				'genre' => '',
				'limit' => 5,
			),
			$atts,
			'lumen_books'
		);

		$limit = min( max( absint( $atts['limit'] ), 1 ), 20 );
		$args  = array(
			'post_type'              => 'lp_book',
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
		);

		$genre = sanitize_title( $atts['genre'] );
		if ( '' !== $genre ) {
			$args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
				array(
					'taxonomy' => 'lp_genre',
					'field'    => 'slug',
					'terms'    => $genre,
				),
			);
		}

		$books = get_posts( $args );
		if ( ! $books ) {
			return '<p class="lumen-books lumen-books--empty">' . esc_html__( 'No books found.', 'lumen-catalog' ) . '</p>';
		}

		wp_enqueue_style( 'lumen-catalog-shortcodes' );

		$items = '';
		foreach ( $books as $book ) {
			$items .= sprintf(
				'<li><a href="%1$s">%2$s</a></li>',
				esc_url( get_permalink( $book ) ),
				esc_html( get_the_title( $book ) )
			);
		}

		return sprintf( '<ul class="lumen-books" data-genre="%1$s">%2$s</ul>', esc_attr( $genre ), $items );
	}

	/**
	 * Renders [lumen_callout title="..."]content[/lumen_callout].
	 *
	 * @param array       $atts    Attributes as the author typed them.
	 * @param string|null $content Enclosed content; null for the self-closing form.
	 */
	public static function callout( $atts, $content = null ): string {
		$atts = shortcode_atts( array( 'title' => '' ), $atts, 'lumen_callout' );

		wp_enqueue_style( 'lumen-catalog-shortcodes' );

		$title = '' === $atts['title'] ? '' : '<p class="lumen-callout__title">' . esc_html( $atts['title'] ) . '</p>';

		// Drop the line breaks and stray paragraph tags that wpautop() leaves at both ends.
		$content = preg_replace( '#\A(?:\s|<br\s*/?>|</p>)+|(?:\s|<br\s*/?>|<p>)+\z#i', '', (string) $content );

		// Allow only post-safe HTML in what the author wrote, then expand nested shortcodes.
		$body = do_shortcode( wp_kses_post( $content ) );

		return '<aside class="lumen-callout">' . $title . $body . '</aside>';
	}
}

The file is src/Shortcodes.php, autoloaded through the plugin’s PSR-4 map; Plugin::boot() hooks register on init and enqueue_for_content on wp_enqueue_scripts, and assets/shortcodes.css styles .lumen-books and .lumen-callout. The code passes php -l and the plugin’s WPCS 3.4.1 ruleset. With three fantasy titles and one crime title in the catalogue, wp eval shows the output:

wp eval 'echo do_shortcode( "[lumen_books genre=\"fantasy\" limit=\"2\"]" );'
wp eval 'echo do_shortcode( "[lumen_books genre=\"poetry\"]" );'
wp eval 'echo do_shortcode( "[[lumen_books]] and [unknown_tag x=1]" );'
<ul class="lumen-books" data-genre="fantasy"><li><a href="https://lumenpress.test/books/salt-and-lanterns/">Salt and Lanterns</a></li><li><a href="https://lumenpress.test/books/a-map-of-small-hours/">A Map of Small Hours</a></li></ul>
<p class="lumen-books lumen-books--empty">No books found.</p>
[lumen_books] and [unknown_tag x=1]

shortcode_atts() and its filter

shortcode_atts( $pairs, $atts, $shortcode ) returns exactly the keys of $pairs: missing attributes get the default, unknown ones ([lumen_books colour="red"]) are dropped. Values are still author-typed strings, so limit is capped (limit="500" returns at most 20 books) and genre goes through sanitize_title(). With the third argument, the result passes through the shortcode_atts_{$shortcode} filter (3.6), so a site can change defaults without touching the plugin. A Lumen Press must-use plugin lowers the cap to three and defaults the noir page to crime:

<?php
/**
 * Plugin Name: Lumen Shortcode Tweaks
 * Description: Site-specific defaults for the lumen-catalog shortcodes.
 *
 * @package Lumen\Catalog
 */

defined( 'ABSPATH' ) || exit;

add_filter(
	'shortcode_atts_lumen_books',
	static function ( $out, $pairs, $atts ) {
		// Default to crime on the Noir landing page, but respect an explicit genre="...".
		if ( ! isset( $atts['genre'] ) && is_page( 'noir' ) ) {
			$out['genre'] = 'crime';
		}
		$out['limit'] = min( (int) $out['limit'], 3 );
		return $out;
	},
	10,
	3
);

With it installed, [lumen_books limit="10"] returned three books. $atts holds what the author typed, so the filter can tell an explicit value from a default.

Return, never echo

A shortcode callback is a filter on the content string: whatever it returns replaces the tag. Echoed output goes straight to the response, ahead of the content being filtered: a test callback that echoed <b>Echoed</b>, run through the_content on Before [lumen_bad] after, produced <b>Echoed</b><p>Before after</p>. Echoing also corrupts REST API responses. Code that needs a template file wraps it in ob_start() and returns ob_get_clean(), as the Plugin Handbook recommends.

Escaping and sanitizing

The callback is responsible for the safety of its output; WordPress does not escape it. Any user who can edit a post, including a Contributor, can write any registered shortcode with any attributes, so attributes are untrusted input. lumen-catalog validates numbers with absint() and a clamp, slugs with sanitize_title() (passed as a query argument, never interpolated into SQL), and escapes at output: esc_html() for titles, esc_attr() for data-genre, esc_url() for permalinks, and wp_kses_post() for enclosed author HTML. A shortcode checks no capabilities by itself, so [lumen_books] queries only published posts and can never show drafts to a visitor. The general rules are in Security for Developers.

Enclosing and nested shortcodes

[lumen_callout title="New in spring"]...[/lumen_callout] wraps author text, received as $content (null for the self-closing form), in an <aside>. The parser makes a single pass, so a nested shortcode is expanded only because callout() calls do_shortcode(). With a title containing HTML, a nested shortcode and a script tag:

wp eval 'echo do_shortcode( "[lumen_callout title=\"New <b>in</b> spring\"]Read <em>these</em>: [lumen_books genre=crime]<script>alert(1)</script>[/lumen_callout]" );'
<aside class="lumen-callout"><p class="lumen-callout__title">New &lt;b&gt;in&lt;/b&gt; spring</p>Read <em>these</em>: <ul class="lumen-books" data-genre="crime"><li><a href="https://lumenpress.test/books/night-ferry/">Night Ferry</a></li></ul>alert(1)</aside>

The order matters: wp_kses_post() runs on the author’s text first, then do_shortcode() expands nested shortcodes, whose callbacks escape their own output. Filtering after the expansion would also strip markup that a nested shortcode legitimately produces.

The parser has three limits, each confirmed with wp eval:

  • A shortcode cannot be nested inside itself ([lumen_callout][lumen_callout]...[/lumen_callout][/lumen_callout]); the first closing tag ends the outer one.

  • Mixing the self-closing and enclosing forms of one tag in the same text confuses the parser. A self-closing tag followed later by a closing tag of the same name is read as one enclosing shortcode spanning everything between.

  • wpautop runs before shortcodes, so line breaks inside an enclosing shortcode arrive as <br /> and <p> tags. The preg_replace() in callout() trims them at both ends; without it the Shortcode block below rendered <aside ...><br /> with a trailing <br />.

The Shortcode block

The block editor stores a shortcode in a Shortcode block (core/shortcode, 5.0; Widgets category, or /shortcode). Its only attribute is the raw text; its render callback, render_block_core_shortcode(), runs wpautop(), and the_content expands the shortcode later at priority 11. A Paragraph block also works, but the Shortcode block keeps the text out of rich-text formatting. A Lumen Press page using both shortcodes:

<!-- wp:paragraph -->
<p>Our fantasy list:</p>
<!-- /wp:paragraph -->

<!-- wp:shortcode -->
[lumen_books genre="fantasy" limit="10"]
<!-- /wp:shortcode -->

<!-- wp:shortcode -->
[lumen_callout title="Coming soon"]
Two new crime titles this winter.
[/lumen_callout]
<!-- /wp:shortcode -->

Created with wp post create and requested on Twenty Twenty-Five, the page linked the stylesheet in <head>:

curl -s https://lumenpress.test/new-books/ | grep -E 'lumen-catalog-shortcodes|lumen-books|lumen-callout'
<link rel='stylesheet' id='lumen-catalog-shortcodes-css' href='https://lumenpress.test/wp-content/plugins/lumen-catalog/assets/shortcodes.css?ver=0.1.0' media='all' />
<ul class="lumen-books" data-genre="fantasy"><li><a href="https://lumenpress.test/books/salt-and-lanterns/">Salt and Lanterns</a></li><li><a href="https://lumenpress.test/books/a-map-of-small-hours/">A Map of Small Hours</a></li><li><a href="https://lumenpress.test/books/the-glass-orchard/">The Glass Orchard</a></li></ul>
<aside class="lumen-callout"><p class="lumen-callout__title">Coming soon</p>Two new crime titles this winter.</aside>

The must-use filter capped limit="10" at three, and content.rendered of /wp/v2/pages/<id> held the same list. In classic-editor content, Convert to blocks turns [gallery], [audio], [video], [caption] and [embed] into core blocks through their shortcode transforms.

Built-in shortcodes

WordPress 7.1.3 registers seven tags, all but [embed] in wp-includes/media.php, each with an output filter.

Shortcode Purpose and main attributes Block equivalent

[gallery]

Image grid from ids, or the post’s attached images; columns (3), size (thumbnail), link (file, none, attachment page), orderby, order. Filter: post_gallery.

Gallery

[caption], [wp_caption]

Wraps an image with a caption; id, caption_id, align, width, caption, class. Filter: img_caption_shortcode.

Image (caption field)

[audio]

HTML5 audio player; src or a format attribute (mp3, ogg, flac, m4a, wav), loop, autoplay, preload, muted (6.8). Filter: wp_audio_shortcode_override.

Audio

[video]

HTML5 video player; src or a format attribute (mp4, m4v, webm, ogv, flv), poster, width, height, loop, autoplay, muted, preload. Filter: wp_video_shortcode_override.

Video

[playlist]

Audio or video playlist from ids; type, tracklist, tracknumbers, images, artists. Filter: post_playlist.

Playlist (new in 7.1, no shortcode transform)

[embed]

oEmbed for the enclosed URL, [embed width="640"]https://...[/embed]; processed in its own pass at priority 8. A bare URL on its own line is auto-embedded without it.

Embed

Loading assets only when needed

A shortcode’s assets should load only on pages that use it; lumen-catalog combines two techniques:

  • Enqueue from the callback. This always works. A block theme renders the template before wp_head (template-canvas.php), so the stylesheet lands in <head>; in a classic theme the callback runs after wp_head and the stylesheet is printed in the footer, which can flash unstyled content.

  • Detect with has_shortcode(). enqueue_for_content() checks the queried post’s content on wp_enqueue_scripts, before <head> is printed. has_shortcode() (3.6) returns true only for a registered tag and also finds tags nested inside enclosing shortcodes and inside block markup.

has_shortcode() misses shortcodes in widgets, template parts, patterns and other posts shown in a loop, hence the callback’s own enqueue. shortcode_exists() (3.6) checks registration; get_shortcode_tags_in_content() (6.3.2):

wp eval 'var_dump( has_shortcode( "[lumen_callout][lumen_books][/lumen_callout]", "lumen_books" ) );'
wp eval 'echo implode( ",", get_shortcode_tags_in_content( "[audio src=x.mp3][/audio] [foo] [gallery ids=1,2]" ) );'
bool(true)
audio,gallery

Removing, stripping and overriding

strip_shortcodes() removes every registered shortcode from a string and is what automatic excerpts use. It removes an enclosing shortcode together with its content, keeps unregistered tags, and honours [[escaped]] tags:

wp eval 'echo strip_shortcodes( "Intro [lumen_books genre=fantasy] middle [lumen_callout]callout text[/lumen_callout] [unknown]" );'
Intro  middle  [unknown]

The strip_shortcodes_tagnames filter (4.7) narrows the list of tags removed. remove_shortcode( $tag ) unregisters a tag (after the registering plugin’s init callback ran). The pre_do_shortcode_tag filter (4.7) short-circuits a callback when it returns anything but false, for example to serve cached HTML, and do_shortcode_tag (4.7) alters any shortcode’s output after its callback. do_shortcode( $content, true ) skips shortcodes inside HTML tags.

Security and performance

Since 4.2.3, a shortcode inside a quoted HTML attribute (<a href="[lumen_link]">) is still expanded, but its output goes through wp_kses_one_attr() and is dropped if unsafe; a callback that builds the whole element is the supported pattern. Every author can use every shortcode, so a callback never writes data, reveals private data or fetches author-supplied URLs. It runs on every view, so queries are bounded (no_found_rows, a limit) and costly HTML cached; see Options, Transients and Object Cache.

When a block is better

Need Shortcode Block

Author sees the result while editing

No: the editor shows the bracketed text

Yes, rendered in the editor

Typed, validated settings

Strings parsed by regex; typos silently ignored

Attributes with types and defaults in block.json, edited through controls

Styles and scripts

Manual enqueue tricks

Loaded only when the block is present (style, viewScript in block.json)

Survives deactivating the plugin

Literal [lumen_books] in the page

A static block keeps its saved HTML; a dynamic one renders nothing

Classic editor, widgets, templates, legacy content

Works everywhere content is filtered

Requires the block editor or block markup

New Lumen Press features are blocks: lumen/latest-books does what [lumen_books] does, with a live preview (see Static and Dynamic Blocks). Both can share one PHP rendering function. A shortcode remains right for plugins that also serve classic-editor sites and for one-line placeholders.

Pitfalls and corrections to older books

Older advice or common mistake Current practice

Teaching shortcodes as part of classic theme development (Pro WordPress) and registering them in functions.php

Plugin territory; the WordPress.org theme review rejects add_shortcode() in themes.

Callbacks that echo their HTML

Return a string; use output buffering for templates.

Interpolating attributes into HTML or SQL unescaped

Validate each attribute, escape at output, query through WP_Query or $wpdb->prepare().

Unprefixed names such as [books]

Prefix tags (lumen_) to avoid collisions with other plugins.

Shortcode-based page builders and layout shortcodes

Content becomes unreadable bracket soup when the plugin is removed; see Page Builders and Commercial Themes.