Shortcodes

Register shortcodes with attributes, return safe output, and know when a block is the better choice.

  • Intermediate
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Register a shortcode
  • Choose a block over a shortcode
  • Handle attributes and enclosed content safely
  • Load assets only when the shortcode is used

Before you start

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
<?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:

ArgumentContains
$attsThe attributes as an array, or an empty string when there are none
$contentThe text between opening and closing tags, or null for a self-closing tag
$tagThe tag name that triggered the call
PHP
<?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:

PHP
// 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
<?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
<?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

WhereWorks?
Post and page content, in any editorYes
The Shortcode blockYes
Classic text widgetsYes, in current versions of WordPress
Excerpts, titles, custom fieldsNo, unless you call do_shortcode() yourself
Theme templatesCall 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:

ShortcodeBlock
EditingType the tag and attributes from memoryVisual preview with settings in the sidebar
ValidationTypos fail silentlyControls only allow valid values
DiscoverabilityUsers must read the docsAppears in the block inserter with a search
AssetsEnqueued late, from the callbackDeclared in block.json, loaded only when the block is on the page
Classic editor and widgetsWorksNeeds 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 init with a prefixed tag of lowercase letters, numbers and underscores.
  • The callback receives $atts, $content and $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.

Further reading