How to create shortcodes that accept nested content in PHP in WordPress

Contents

Introduction — what nested shortcodes means and why it matters

Shortcodes are a key WordPress feature for transforming small inline tokens into structured HTML, widgets, or interactive components. A common requirement is to build shortcodes that accept inner content which itself may contain HTML, text, or other shortcodes — for example a container shortcode [tabs] that contains multiple [tab] child shortcodes, or a layout [row] with [column] children, or a generic wrapper that may accept nested interactive shortcodes like [button].

This article gives a complete, practical, and careful guide to creating shortcodes that accept nested content in PHP for WordPress. It covers basic and advanced patterns, registration, parsing child shortcodes, security and sanitization, dealing with WP auto-formatting, how to handle shortcodes used inside attributes, performance, recursion limits, and many real-world examples (all code blocks are clearly labeled).

Shortcode fundamentals — callback signature and registration

Every shortcode callback has the same basic signature and is registered with add_shortcode. The canonical pattern is:

// Registering shortcodes on init
add_action(init, my_shortcodes_init)
function my_shortcodes_init() {
    add_shortcode(box, my_box_shortcode)
    add_shortcode(button, my_button_shortcode)
}

// Callback signature: attributes array, content (string) or null, and the shortcode name (optional third param)
function my_box_shortcode(atts, content = null) {
    // normalize attributes with defaults
    atts = shortcode_atts(array(
        class =gt ,
    ), atts, box)

    // Process nested shortcodes and remove unwanted auto-paragraphs
    content = do_shortcode(shortcode_unautop(content))

    // Sanitize output: escape attributes and allow safe HTML in content
    output = ltdiv class=box  . esc_attr(atts[class]) . gt
    output .= wp_kses_post(content) // allow typical HTML but not unsafe tags
    output .= lt/divgt

    return output
}

function my_button_shortcode(atts, content = null) {
    atts = shortcode_atts(array(
        url =gt #,
        type =gt primary,
    ), atts, button)

    // If attributes themselves include shortcodes, parse them
    url = do_shortcode(atts[url])

    return lta class=btn  . esc_attr(atts[type]) .  href= . esc_url(url) . gt . esc_html(do_shortcode(content)) . lt/agt
}

Key notes

  • do_shortcode(content) is necessary when you want nested shortcodes inside the content to be expanded.
  • shortcode_unautop(content) helps prevent WordPress inserting ltpgt and ltbr /gt tags that break layout inside shortcode containers.
  • Sanitize content after expansion: run do_shortcode first, then run wp_kses_post or other sanitizers as appropriate.

Simple use example and usage

Usage in post content:

[box class=panel]
  Welcome! Here is a nested button:
  [button url=https://example.com type=success]Click me[/button]
[/box]

The box wrapper processed the inner [button] because the box callback ran do_shortcode() on its content. The button callback itself can call do_shortcode on its content too (if it might contain other shortcodes).

Shortcodes inside attributes

Shortcode content is passed to the callback in the content variable. Attributes are parsed into strings and passed as values in atts. If attribute values themselves contain shortcodes (for example url=[my_url_shortcode]), you need to explicitly call do_shortcode() on that attribute value before using it:

atts = shortcode_atts(array(url =gt #), atts, button)
// parse nested shortcodes inside attribute values
url = do_shortcode(atts[url])
url = esc_url(url) // then sanitize properly

Always treat attribute content as raw user content until sanitized or escaped. Using do_shortcode on an attribute is safe — just remember to escape (esc_url, esc_attr) before emitting HTML.

Parent-child shortcodes: when the parent needs to inspect child shortcodes

Some patterns require the parent to control rendering of children, rather than simply rely on the children to render themselves. A typical example is tabs, where the parent must build a tab header list from the titles given in the child [tab] shortcodes. To accomplish that robustly you should parse child shortcodes from the parents raw content using get_shortcode_regex(), not rely on the global parsing order.

Tabs example (parent parses child shortcodes)

This example demonstrates:

  • How to find child shortcodes and their attributes using get_shortcode_regex().
  • How to assemble a structured output based on child attributes.
  • How to safely process nested shortcodes within each childs content.
/
  Register shortcodes on init
 /
add_action(init, function() {
    add_shortcode(tabs, my_tabs_shortcode)
    add_shortcode(tab, my_tab_shortcode) // optional: allow direct rendering if needed
})

/
  Child shortcode: returns its content (used if rendered standalone)
 /
function my_tab_shortcode(atts, content = null) {
    atts = shortcode_atts(array(
        title =gt Tab,
        id =gt ,
    ), atts, tab)

    id = !empty(atts[id]) ? esc_attr(atts[id]) : 
    content = do_shortcode(shortcode_unautop(content))
    return ltdiv id= . id .  class=tab-contentgt . wp_kses_post(content) . lt/divgt
}

/
  Parent shortcode: parse all [tab] children and build a tabs widget
 /
function my_tabs_shortcode(atts, content = null) {
    atts = shortcode_atts(array(
        style =gt default,
    ), atts, tabs)

    if (empty(content)) {
        return 
    }

    // Collect tab children using get_shortcode_regex
    pattern = get_shortcode_regex(array(tab))
    preg_match_all(/ . pattern . /s, content, matches, PREG_SET_ORDER)

    if (empty(matches)) {
        // Fallback: treat the whole content as a single tab
        single_content = do_shortcode(shortcode_unautop(content))
        return ltdiv class=tabs  . esc_attr(atts[style]) . gtltdiv class=tab-contentgt . wp_kses_post(single_content) . lt/divgtlt/divgt
    }

    tabs = array()
    panes = array()
    index = 0

    foreach (matches as m) {
        // m structure per get_shortcode_regex:
        // [0] full match, [2] shortcode name, [3] attr string, [5] content for enclosing shortcodes
        shortcode_name = m[2]
        attr_string     = isset(m[3]) ? m[3] : 
        inner_content   = isset(m[5]) ? m[5] : 

        // Parse attribute string into array
        child_atts = shortcode_parse_atts(attr_string)
        title = isset(child_atts[title]) ? child_atts[title] : Tab  . (index   1)
        id = isset(child_atts[id]) ? child_atts[id] : tab- . (index   1)

        // Process nested shortcodes inside the childs content
        processed_content = do_shortcode(shortcode_unautop(inner_content))

        tabs[] = ltli class=tab-title data-target= . esc_attr(id) .  . gt . esc_html(title) . lt/ligt
        panes[] = ltdiv id= . esc_attr(id) .  class=tab-panegt . wp_kses_post(processed_content) . lt/divgt

        index  
    }

    output  = ltdiv class=tabs  . esc_attr(atts[style]) . gt
    output .= ltul class=tab-titlesgt . implode(, tabs) . lt/ulgt
    output .= ltdiv class=tab-panesgt . implode(, panes) . lt/divgt
    output .= lt/divgt

    return output
}

Why parse children yourself?

  • It allows the parent to read child attributes (for example tab titles) and to build a single, consistent structure (headers panes).
  • It removes ambiguity about render order and gives full control in one place.
  • It enables richer validation and rearrangement of child items before rendering.

Handling WP auto-formatting (wpautop) and nested shortcodes

WordPress automatic paragraphing (wpautop) can add ltpgt and ltbr /gt tags around content, which often breaks containers that expect raw HTML or shortcodes. Best practice:

  • Call shortcode_unautop(content) inside your callback before do_shortcode.
  • Call do_shortcode afterwards so nested shortcodes are expanded in the expected raw content.
  • Then sanitize the final string (wp_kses_post, esc_html, esc_attr as appropriate) before returning HTML to the page.

Security and sanitization

Follow this order and practice for safe output:

  1. Parse attributes with shortcode_atts and treat values as raw.
  2. If attributes require nested shortcode expansion, run do_shortcode on attribute values.
  3. For content: run shortcode_unautop then do_shortcode(content) so inner shortcodes are expanded.
  4. Sanitize: use esc_url for URLs, esc_attr for attributes, esc_html or wp_kses_post for content depending on accepted HTML tags.

Preventing recursion and caps on nesting depth

A malicious or buggy shortcode could attempt to call itself repeatedly leading to infinite loops. Implement a safe guard by tracking depth in a static variable or global:

function safe_wrapper_shortcode(atts, content = null) {
    static nesting = 0
    nesting  
    if (nesting gt= 20) { // arbitrary safe cap
        nesting--
        return lt!-- shortcode recursion limit reached --gt
    }

    content = do_shortcode(shortcode_unautop(content))
    output = ltdiv class=safe-wrappergt . wp_kses_post(content) . lt/divgt

    nesting--
    return output
}

Performance considerations and caching

If shortcode rendering is heavy (database queries, complex transforms), consider caching the rendered output for a given set of attributes/content. Use unique cache keys (for example a hash of attributes content), and use WordPress object caching (wp_cache_get/wp_cache_set) or transients if persistent between page loads is desired.

function cached_shortcode(atts, content = null) {
    key = sc_cache_ . md5(serialize(atts) .  . content)
    cached = wp_cache_get(key, shortcodes)
    if (cached !== false) {
        return cached
    }

    output = ...generate expensive markup...
    wp_cache_set(key, output, shortcodes, HOUR_IN_SECONDS)
    return output
}

Advanced: nested shortcodes several levels deep and recursion-aware parsing

When child shortcodes have their own child shortcodes you must think about which callback should own the parsing and processing at each level. General guidance:

  • Prefer modular shortcodes: children do their own rendering when possible.
  • When the parent must know about the childs internal structure it should parse only that childs top-level tokens using get_shortcode_regex and then call do_shortcode on child content for inner processing.
  • Avoid re-running do_shortcode on a whole area more than necessary to prevent repeated processing and performance cost.

Example: grid and column shortcodes

Typical layout shortcodes: [row][column width=6]…[/column][/row]

add_action(init, function() {
    add_shortcode(row, sc_row)
    add_shortcode(column, sc_column)
})

function sc_row(atts, content = null) {
    content = do_shortcode(shortcode_unautop(content))
    return ltdiv class=rowgt . wp_kses_post(content) . lt/divgt
}

function sc_column(atts, content = null) {
    atts = shortcode_atts(array(
        width =gt 12,
    ), atts, column)

    width_class = col- . intval(atts[width])
    content = do_shortcode(shortcode_unautop(content))

    return ltdiv class= . esc_attr(width_class) . gt . wp_kses_post(content) . lt/divgt
}

This pattern delegates rendering of each column to the column shortcode itself while the row wrapper merely surrounds the children and ensures nested shortcodes in each column are expanded.

Testing and debugging tips

  • Test with simple nested shortcodes first, then more complicated nested content.
  • Use error_log() or var_dump() (in development only) to inspect the incoming content and atts inside callbacks.
  • Temporarily return raw debugging output (wrapped in HTML comments) to inspect what was parsed by get_shortcode_regex.
  • Remember that admin visual editors and content filters can alter whitespace and newlines rely on shortcode_unautop for predictable results.

Common pitfalls and how to avoid them

  • Double processing: Avoid calling do_shortcode multiple times on the same content unless intentional, or nested shortcodes may be executed twice.
  • Improper sanitization order: Always run do_shortcode first, then sanitize the returned HTML string for output. If you sanitize before do_shortcode you may break shortcodes that rely on raw markup.
  • Shortcodes inside attributes: If you expect nested shortcodes in attributes, call do_shortcode on that attribute and then sanitize (esc_url, esc_attr).
  • Auto-paragraph issues: Use shortcode_unautop() to remove undesired ltpgt tags inserted by WP.
  • Infinite recursion: Guard against recursion with a nesting counter or limit.

Attribute reference table (example)

Shortcode Attribute Description
[box] class CSS class applied to wrapper sanitized with esc_attr()
[button] url, type URL is sanitized with esc_url() type used for classes and sanitized with esc_attr()
[tab] title, id title is used for tab label and escaped with esc_html() id used for pane id and escaped with esc_attr()

Small CSS snippet for visualizing tabs (optional)

/ Minimal styling for the tabs produced by the earlier example /
.tabs { border: 1px solid #ddd }
.tab-titles { list-style: none margin: 0 padding: 0 display: flex }
.tab-titles .tab-title { padding: 8px 12px cursor: pointer background: #f7f7f7 border-right: 1px solid #e1e1e1 }
.tab-panes .tab-pane { display: none padding: 12px }
.tab-panes .tab-pane.active { display: block }

Best practices summary

  • Always use shortcode_atts to define defaults and normalize attributes.
  • Run shortcode_unautop then do_shortcode on content before sanitizing and echoing it.
  • Use get_shortcode_regex when the parent needs to parse child shortcodes and read their attributes.
  • Sanitize user input: esc_url, esc_attr, esc_html, wp_kses_post as appropriate.
  • Guard against recursion and consider caching heavy operations.
  • Register shortcodes on the init hook and avoid running registration repeatedly.
  • Document expected nested structure in plugin/theme docs so users know how to nest elements.

Further reading

For complete reference material see the WordPress Shortcode API documentation and get_shortcode_regex() usage. A concise, official reference is available on the WordPress developer site: https://developer.wordpress.org/plugins/shortcodes/



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *