Shortcodes

add_shortcode maps a bracketed tag in post content to a callback that must return its output, never echo it.

Syntaxadd_shortcode( string $tag, callable $callback ) // callback( $atts, $content = null, $tag )

A shortcode is a macro inside post content. [acme_events limit="5"] is replaced by whatever your callback returns, when do_shortcode() runs as a filter on the_content. Shortcodes date from WordPress 2.5 and remain the simplest way to drop dynamic output into content the editor owns.

Registering

add_shortcode( $tag, $callback ) maps a tag to a callback that receives three arguments: $atts, $content and the matched tag name. Tags may not contain spaces or any of & / < > [ ] =; since WordPress 4.4 an invalid tag triggers a _doing_it_wrong() notice and is not registered at all.

Attributes

Attribute names are lowercased during parsing, so [acme limit=5 Limit=6] collapses into one key. shortcode_atts() merges the user's attributes over your defaults and discards anything you did not declare:

$a = shortcode_atts( array( 'limit' => 5 ), $atts, 'acme_events' );
// wp_parse_args( $atts, array( 'limit' => 5 ) ) looks similar but KEEPS
// unknown keys and does not fire the shortcode_atts_acme_events filter.

Since WordPress 6.5 $atts is always an array; on earlier versions it arrived as an empty string when the tag carried no attributes, which is why so much old code starts with a cast.

Enclosing content

[acme_box]text[/acme_box] passes 'text' as $content. The self-closing [acme_box /] and the bare [acme_box] both leave it null. Nested shortcodes inside $content are not expanded for you — call do_shortcode( $content ) yourself.

Example

Example · php
<?php
defined( 'ABSPATH' ) || exit;

/**
 * Renders [acme_events limit="3" category="workshops"].
 *
 * @param array       $atts    Attributes. Always an array as of WordPress 6.5.
 * @param string|null $content Enclosed content; null for a non-enclosing tag.
 * @param string      $tag     The shortcode tag that matched.
 * @return string HTML, already escaped, ready to be printed.
 */
function acme_events_shortcode( $atts, $content = null, $tag = '' ): string {
	$atts = shortcode_atts(
		array(
			'limit'    => 3,
			'category' => '',
		),
		$atts,
		$tag // Third argument enables the shortcode_atts_acme_events filter.
	);

	$query = new WP_Query(
		array(
			'post_type'              => 'acme_event',
			'posts_per_page'         => absint( $atts['limit'] ),
			'category_name'          => sanitize_title( $atts['category'] ),
			'ignore_sticky_posts'    => true,
			'no_found_rows'          => true, // No pagination needed; skips SQL_CALC_FOUND_ROWS.
			'update_post_meta_cache' => false,
		)
	);

	if ( ! $query->have_posts() ) {
		return ''; // Return an empty string, not false - the result is concatenated.
	}

	$out = '<ul class="acme-events">';

	foreach ( $query->posts as $event ) {
		$out .= sprintf(
			'<li><a href="%1$s">%2$s</a></li>',
			esc_url( get_permalink( $event ) ),
			esc_html( get_the_title( $event ) )
		);
	}

	return $out . '</ul>';
}

// Register on init so the tag exists before any content is filtered.
add_action( 'init', static function (): void {
	add_shortcode( 'acme_events', 'acme_events_shortcode' );
} );

When to use it

  • Letting an editor drop a list of upcoming events into any page without touching a template.
  • Wrapping a block of editor-written text in a styled container with [callout]...[/callout].
  • Supporting a tag from a plugin you are replacing so existing posts keep rendering.
  • Rendering a form or a third-party embed inside content, where a block would be more machinery than the job needs.

More examples

An enclosing shortcode with nested content

The pattern for any wrapper shortcode. Two things are easy to get wrong: the null check, because the non-enclosing form of a registered tag passes null rather than an empty string, and the do_shortcode() call, without which a nested tag prints as literal brackets.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

/**
 * [acme_callout type="warning"]Text, possibly with [acme_events] inside.[/acme_callout]
 */
function acme_callout_shortcode( $atts, $content = null, $tag = '' ): string {
	$atts = shortcode_atts( array( 'type' => 'info' ), $atts, $tag );

	// $content is null for [acme_callout] and [acme_callout /].
	if ( null === $content || '' === trim( $content ) ) {
		return '';
	}

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

	// Inner shortcodes are NOT expanded automatically. do_shortcode() runs them,
	// then wp_kses_post() strips anything the post editor would not allow.
	$inner = wp_kses_post( do_shortcode( $content ) );

	return sprintf(
		'<div class="acme-callout acme-callout--%1$s">%2$s</div>',
		esc_attr( $type ),
		$inner
	);
}
add_action( 'init', static fn() => add_shortcode( 'acme_callout', 'acme_callout_shortcode' ) );

Running shortcodes outside post content

Shortcodes only run where something calls do_shortcode(), so a meta field, a widget or a REST response renders raw brackets by default. Prefer apply_filters( 'the_content', ... ) for text a human typed into an editor; use do_shortcode() directly when you want the tags expanded and nothing else touched.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

// A field whose stored value may contain shortcodes.
$raw = get_post_meta( get_the_ID(), 'acme_event_intro', true );

// Option A: expand shortcodes only.
// do_shortcode( string $content, bool $ignore_html = false ): string
echo do_shortcode( $raw );

// Option B: run the whole content pipeline - wpautop, texturize, embeds,
// AND shortcodes, because do_shortcode is registered on the_content at
// priority 11. Usually what you actually want for editor-authored text.
echo apply_filters( 'the_content', $raw );

// $ignore_html = true skips shortcodes that sit inside an HTML tag, so a
// stray [tag] in an attribute value is left alone.
echo do_shortcode( $raw, true );

Deprecating a tag without breaking old posts

Shortcode tags live in one global array, so the last registration wins and there is no error when two plugins claim the same name. Aliasing an old tag to the current callback is the cheapest way to rename a shortcode without editing every post that used it.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

add_action( 'init', static function (): void {
	add_shortcode( 'acme_events', 'acme_events_shortcode' );

	// The old 1.x tag. Same callback, so posts written years ago keep working.
	add_shortcode( 'acme_event_list', 'acme_events_shortcode' );
} );

// Take a tag over from another plugin, or drop one entirely. Priority 20 puts
// this after the default-priority init callbacks that registered them.
add_action( 'init', static function (): void {
	remove_shortcode( 'gallery' );          // remove_shortcode( string $tag )
	add_shortcode( 'gallery', 'acme_gallery_shortcode' );
}, 20 );

// Check before registering if another plugin may own the tag already.
if ( ! shortcode_exists( 'acme_events' ) ) {
	add_shortcode( 'acme_events', 'acme_events_shortcode' );
}

Discussion

  • Be the first to comment on this lesson.