A shortcode is a tag in square brackets, such as [myplugin_events limit="3"], that WordPress replaces with your plugin's output when the content is displayed. Shortcodes predate the block editor, and blocks are now the better choice for most new features. They are still worth knowing well: they work in any editor, in classic widgets and in the Shortcode block, and thousands of existing sites depend on them.
This lesson adds an [myplugin_events] shortcode that lists upcoming events.
How shortcodes run
When a post is displayed, its content passes through the the_content filter. At priority 11, after paragraphs are added, WordPress runs do_shortcode(), which finds registered tags and replaces each with the return value of its callback. Unregistered tags are left as plain text, which is why a shortcode in a theme leaves [tag] littered through posts after a theme change. Shortcodes belong in plugins.
Registering a shortcode
<?php
// wp-content/plugins/myplugin/includes/shortcodes.php
defined( 'ABSPATH' ) || exit;
add_action( 'init', 'myplugin_register_shortcodes' );
function myplugin_register_shortcodes(): void {
add_shortcode( 'myplugin_events', 'myplugin_events_shortcode' );
}
The tag is global like everything else, so prefix it. Use lowercase letters, numbers and underscores. Avoid dashes in tag names: WordPress can confuse [myplugin-events] with a shorter tag such as [myplugin] if both exist.
The callback
The callback receives three arguments and returns a string:
| Argument | Contains |
|---|---|
$atts | The attributes as an array, or an empty string when there are none |
$content | The text between opening and closing tags, or null for a self-closing tag |
$tag | The tag name that triggered the call |
<?php
// wp-content/plugins/myplugin/includes/shortcodes.php
/**
* [myplugin_events limit="5" type="workshop" show_price="no"]
*
* @param array|string $atts Shortcode attributes.
* @param string|null $content Enclosed content, unused.
* @param string $tag Shortcode tag.
* @return string HTML.
*/
function myplugin_events_shortcode( $atts, $content = null, $tag = '' ): string {
$settings = myplugin_get_settings();
$atts = shortcode_atts(
array(
'limit' => $settings['events_per_page'],
'type' => '',
'show_price' => 'yes',
),
$atts,
$tag
);
// Attributes are user input: validate every one.
$limit = min( 50, max( 1, absint( $atts['limit'] ) ) );
$type = sanitize_key( $atts['type'] );
$show_price = in_array( strtolower( (string) $atts['show_price'] ), array( 'yes', 'true', '1' ), true );
$args = array(
'post_type' => 'myplugin_event',
'post_status' => 'publish',
'posts_per_page' => $limit,
'no_found_rows' => true,
'meta_query' => array(
'event_date' => array(
'key' => 'myplugin_event_date',
'value' => wp_date( 'Y-m-d' ),
'compare' => '>=',
'type' => 'DATE',
),
),
'orderby' => 'event_date',
'order' => 'ASC',
);
if ( '' !== $type ) {
$args['tax_query'] = array(
array(
'taxonomy' => 'myplugin_event_type',
'field' => 'slug',
'terms' => $type,
),
);
}
$events = ( new WP_Query( $args ) )->posts;
if ( ! $events ) {
return '<p class="myplugin-events-empty">' . esc_html__( 'No upcoming events.', 'myplugin' ) . '</p>';
}
wp_enqueue_style( 'myplugin-events' );
ob_start();
echo '<div class="myplugin-events">';
foreach ( $events as $event ) {
myplugin_get_template(
'event-card',
array(
'event' => $event,
'show_price' => $show_price,
)
);
}
echo '</div>';
return (string) ob_get_clean();
}
shortcode_atts
shortcode_atts() does two jobs. It fills in defaults for attributes the editor left out, and it drops any attribute you did not list, so unexpected keys never reach your code. WordPress lower-cases attribute names, so Limit="3" arrives as limit.
Pass the tag as the third argument. That fires a shortcode_atts_myplugin_events filter, which lets other developers change the defaults without editing your plugin:
// In a site's own plugin: show three events by default.
add_filter(
'shortcode_atts_myplugin_events',
static function ( array $out ): array {
$out['limit'] = 3;
return $out;
}
);
Return, never echo
WordPress puts the return value where the tag was. If your callback echoes, the HTML is printed immediately, while the content is still being processed, so it appears above the post content instead of in place. When the markup comes from a template file that prints, wrap it in ob_start() and ob_get_clean() as above.
Treat attributes as untrusted input
Anyone who can write a post can type a shortcode, and on many sites that includes contributors, who are not trusted with unfiltered HTML. An attribute such as type='"><script>...' must never reach the page unescaped. In the callback above, every attribute is validated, and the template escapes everything it prints. That is the same rule as for form input: sanitise early, escape late.
Notice also limit: without the min( 50, ... ) cap, an editor could type limit="100000" and make every page view load every event.
Loading assets only when used
The stylesheet is registered on every page but only enqueued when the shortcode runs:
<?php
// wp-content/plugins/myplugin/includes/shortcodes.php
add_action( 'wp_enqueue_scripts', 'myplugin_register_shortcode_assets' );
function myplugin_register_shortcode_assets(): void {
wp_register_style( 'myplugin-events', MYPLUGIN_URL . 'assets/css/events.css', array(), MYPLUGIN_VERSION );
}
Because the shortcode runs after the <head> has been printed, the style ends up in the footer. As the WordPress course's enqueuing lesson explained, that can cause a brief flash of unstyled content, so keep shortcode styles small.
Enclosing shortcodes
A shortcode can wrap content: [myplugin_note]Doors open at 6pm.[/myplugin_note]. The text arrives in $content:
<?php
// wp-content/plugins/myplugin/includes/shortcodes.php
add_action( 'init', static function () {
add_shortcode( 'myplugin_note', 'myplugin_note_shortcode' );
} );
function myplugin_note_shortcode( $atts, $content = null ): string {
if ( null === $content || '' === trim( $content ) ) {
return '';
}
// Allow other shortcodes inside, then keep only safe HTML.
$inner = wp_kses_post( do_shortcode( $content ) );
return '<aside class="myplugin-note">' . $inner . '</aside>';
}
WordPress does not process shortcodes nested inside yours unless you call do_shortcode() on the content. Filter the result with wp_kses_post(), because the content is still author input.
Shortcodes outside post content
| Where | Works? |
|---|---|
| Post and page content, in any editor | Yes |
| The Shortcode block | Yes |
| Classic text widgets | Yes, in current versions of WordPress |
| Excerpts, titles, custom fields | No, unless you call do_shortcode() yourself |
| Theme templates | Call echo do_shortcode( '[myplugin_events]' );, but a direct function call is clearer |
To check whether a post uses your shortcode, for example to decide whether to preload something, use has_shortcode( $post->post_content, 'myplugin_events' ).
When a block is better
Blocks and shortcodes can produce the same HTML, but they give editors very different experiences:
| Shortcode | Block | |
|---|---|---|
| Editing | Type the tag and attributes from memory | Visual preview with settings in the sidebar |
| Validation | Typos fail silently | Controls only allow valid values |
| Discoverability | Users must read the docs | Appears in the block inserter with a search |
| Assets | Enqueued late, from the callback | Declared in block.json, loaded only when the block is on the page |
| Classic editor and widgets | Works | Needs the block editor |
Choose a block for anything new that editors place in content. Keep a shortcode when you must support classic editor sites, when other systems already insert the tag, or when the plugin ships both: a dynamic block and a shortcode can share the same PHP render function, so the output stays identical. The block editor integration lesson shows how a plugin registers and ships blocks, and the Gutenberg course teaches block development in depth.
Summary
- Register shortcodes on
initwith a prefixed tag of lowercase letters, numbers and underscores. - The callback receives
$atts,$contentand$tag, and must return its output; use output buffering for templates. shortcode_atts()with the tag as the third argument applies defaults, drops unknown attributes and makes defaults filterable.- Attributes and enclosed content are untrusted: validate, cap limits and escape.
- Enqueue assets from the callback; prefer a block for new editor features, and share render code when you need both.