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 |
|---|---|
|
After |
|
Text widget and block widgets; no global |
Block templates |
|
The |
|
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 |
|---|---|
|
Self-closing. The callback receives an empty attribute array and |
|
Attributes in double quotes, single quotes or unquoted ( |
|
A value without a name becomes a numerically indexed attribute ( |
|
Enclosing. The text between the tags is passed as |
|
Escaped: prints |
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]
Source: WP-CLI Commands: wp eval
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 <b>in</b> 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.
-
wpautopruns before shortcodes, so line breaks inside an enclosing shortcode arrive as<br />and<p>tags. Thepreg_replace()incallout()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>
Source: REST API Handbook: Pages
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 |
|---|---|---|
|
Image grid from |
Gallery |
|
Wraps an image with a caption; |
Image (caption field) |
|
HTML5 audio player; |
Audio |
|
HTML5 video player; |
Video |
|
Audio or video playlist from |
Playlist (new in 7.1, no shortcode transform) |
|
oEmbed for the enclosed URL, |
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 afterwp_headand 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 onwp_enqueue_scripts, before<head>is printed.has_shortcode()(3.6) returnstrueonly 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
Source: Code Reference: has_shortcode()
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 |
Styles and scripts |
Manual enqueue tricks |
Loaded only when the block is present ( |
Survives deactivating the plugin |
Literal |
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 |
Plugin territory; the WordPress.org theme review rejects |
Callbacks that |
Return a string; use output buffering for templates. |
Interpolating attributes into HTML or SQL unescaped |
Validate each attribute, escape at output, query through |
Unprefixed names such as |
Prefix tags ( |
Shortcode-based page builders and layout shortcodes |
Content becomes unreadable bracket soup when the plugin is removed; see Page Builders and Commercial Themes. |