Claude Skill

elementor-dynamic-tag-fields

Build the body of an Elementor Dynamic Tag — choose Tag

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-elementor_elementor-dynamic-tag-fields-52f6020.zip · 7 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/elementor/elementor-dynamic-tag-fields
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Elementor: Dynamic Tag types, fields & fallback

The body of a dynamic tag has three decisions: which base class (Tag vs Data_Tag), which categories it produces (so the right controls accept it), and which settings fields the editor shows. Plus the easily-missed fallback asymmetry between the two base classes. This skill assumes the tag is already being registered — see elementor-dynamic-tag-register for that.

Decision 1 — Tag vs Data_Tag

Tag Data_Tag
Extend \Elementor\Core\DynamicTags\Tag \Elementor\Core\DynamicTags\Data_Tag
You implement render() — echo the output get_value( array $options = [] ) — return the value
get_content_type() 'ui' (final) 'plain' (final)
Use for rendered text/HTML fragments (price, reading time, a badge) structured values consumed by a control — an image array [ 'id' => …, 'url' => … ], a URL string, a color

Verified: Tag::get_content() does ob_start(); $this->render(); $value = ob_get_clean(); (tag.php:30-37) and get_content_type() returns 'ui' (tag.php:64). Data_Tag declares abstract protected function get_value() (data-tag.php:25), returns it directly from get_content() (data-tag.php:43-45), and get_content_type() returns 'plain' (data-tag.php:31).

Rule of thumb: if the value feeds a MEDIA / IMAGE / URL / COLOR control (the control needs a structured value, not printed markup), use Data_Tag. If it feeds a TEXT context (it's printed inline), use Tag.

// Tag — echoes
class Reading_Time extends Tag {
    public function render(): void {
        echo esc_html( $this->compute() . ' min' );   // print, don't return
    }
}

// Data_Tag — returns
class Featured_Image_Fallback extends Data_Tag {
    protected function get_value( array $options = [] ) {
        $id = get_post_thumbnail_id();
        if ( $id ) {
            return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
        }
        return $this->get_settings( 'fallback' );   // see "Fallback" below
    }
}

Decision 2 — categories (what value the tag produces)

get_categories() returns one or more category constants from Elementor\Modules\DynamicTags\Module. Categories declare the kind of value the tag emits; a control accepts a tag only when their categories overlap, so the editor shows your tag only under compatible controls.

Verified constants (modules/dynamic-tags/module.php:31-76):

Constant Value Typical use
TEXT_CATEGORY 'text' printed strings (most Tags)
URL_CATEGORY 'url' link fields
IMAGE_CATEGORY 'image' image controls
MEDIA_CATEGORY 'media' media (image/video) controls
POST_META_CATEGORY 'post_meta' meta-field contexts
GALLERY_CATEGORY 'gallery' gallery controls
NUMBER_CATEGORY 'number' number controls
COLOR_CATEGORY 'color' color controls
DATETIME_CATEGORY 'datetime' date/time controls
SVG_CATEGORY 'svg' inline-SVG / icon controls

A tag may declare several — Pro's Post_Custom_Field returns [ TEXT, URL, POST_META, COLOR, DATETIME, MEDIA ] because a meta value can drive any of those controls. Reference the constant, never the bare string, so a renamed value can't break you.

use Elementor\Modules\DynamicTags\Module as TagsModule;

public function get_categories(): array {
    return [ TagsModule::TEXT_CATEGORY ];
}

Decision 3 — settings fields via register_controls()

Add the tag's configuration fields in register_controls(). Elementor has already opened a "Settings" controls section around your call (base-tag.php:175-187) — add controls directly; do not wrap them in your own start_controls_section().

use Elementor\Controls_Manager;

protected function register_controls(): void {
    $this->add_control( 'format', [
        'label'   => esc_html__( 'Format', 'myplugin' ),
        'type'    => Controls_Manager::SELECT,
        'default' => 'minutes',
        'options' => [
            'minutes' => esc_html__( 'Minutes', 'myplugin' ),
            'words'   => esc_html__( 'Word count', 'myplugin' ),
        ],
    ] );

    $this->add_control( 'wpm', [
        'label'   => esc_html__( 'Words / minute', 'myplugin' ),
        'type'    => Controls_Manager::NUMBER,
        'default' => 200,
        'min'     => 50,
    ] );
}

Common control types (from Elementor\Controls_Manager, all seen in verified tags/widgets): TEXT, TEXTAREA, NUMBER, SELECT, SELECT2 (add 'multiple' => true for multi), SWITCHER, CHOOSE, COLOR, MEDIA, ICONS, ALERT, REPEATER. Read settings back with $this->get_settings( 'key' ) (raw) or $this->get_settings_for_display() (parsed). For a large-dataset picker (products/posts) use the AJAX query control — see elementor-dynamic-tag-ajax-select; a plain SELECT2 preloaded with thousands of options freezes the editor.

Name your method register_controls(), not _register_controls(). The underscore form is deprecated since 3.1.0 — init_controls() calls it but emits a _doing_it_wrong notice (base-tag.php:179-182). (The reference plugin used the underscore form until its 2026-06-17 migration; older copies and tutorials still show it, so recognise it but don't copy it.)

Two optional panel hints on Base_Tag:

  • is_settings_required() — return true if the tag is useless until configured (Pro's Post_Custom_Field does). Default false (base-tag.php:83).
  • get_panel_template_setting_key() — return a control key to surface in the tag's panel label (e.g. 'key'). Default '' (base-tag.php:75).

The fallback system (the asymmetry that bites)

A Tag gets Before / After / Fallback controls for free and applies the fallback automatically when render() produces empty output. A Data_Tag gets none of this and must do it by hand.

Tag — automatic

Tag::register_advanced_section() adds an "Advanced" section with before, after, and fallback controls (tag.php:84-123), and get_content() applies them: if the rendered value is non-empty it prepends before / appends after; else if a fallback is set it uses wp_kses_post_deep( $settings['fallback'] ) (tag.php:39-55). You write render() and get fallback behaviour automatically — just make sure render() outputs nothing when there's no value (don't echo '0', '—', or an empty wrapper, or the fallback never triggers).

public function render(): void {
    $value = $this->compute();
    if ( '' === $value ) {
        return;   // emit nothing → Elementor's Fallback control takes over
    }
    echo esc_html( $value );
}

Data_Tag — manual

Data_Tag inherits the empty Base_Tag::register_advanced_section() (base-tag.php:166) — no Before/After/Fallback section is added, and get_content() does no fallback logic. To support a fallback you register the control yourself and consult it in get_value(). Pro's Post_Featured_Image is the canonical pattern:

protected function register_controls(): void {
    $this->add_control( 'fallback', [
        'label' => esc_html__( 'Fallback', 'myplugin' ),
        'type'  => Controls_Manager::MEDIA,   // match the control type your value feeds
    ] );
}

protected function get_value( array $options = [] ) {
    $id = get_post_thumbnail_id();
    if ( $id ) {
        return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
    }
    return $this->get_settings( 'fallback' );   // the manual fallback
}

Verified at post-featured-image.php:33-56 — get_value() returns the image array or $this->get_settings( 'fallback' ), and register_controls() adds a single MEDIA fallback control.

Critical rules

  • Tag echoes (render()); Data_Tag returns (get_value()). Mixing them up (returning from render(), or echoing from get_value()) silently produces empty/garbage output.
  • get_categories() returns Module::*_CATEGORY constants, never bare strings, and matches the control kinds your value feeds. Wrong categories → the tag never appears under the intended control.
  • Add controls directly in register_controls() — Elementor already opened the "Settings" section. A self-opened section nests incorrectly.
  • Method is register_controls(), not _register_controls() (deprecated 3.1.0).
  • Tag fallback is automatic but only triggers on empty output — render() must emit nothing when there's no value.
  • Data_Tag has no automatic fallback/before/after — register a fallback control and read it in get_value() yourself, with a type matching the consuming control.
  • Escape on output in Tag::render() (esc_html / wp_kses_post) — it's echoed into the page like any front-end output.

Common mistakes

// WRONG — Data_Tag that echoes (output is captured nowhere; control gets null)
class My_Image extends Data_Tag {
    protected function get_value( array $options = [] ) {
        echo wp_get_attachment_url( $id );   // <-- should RETURN
    }
}

// WRONG — Tag that returns (nothing is printed; tag renders empty)
class My_Text extends Tag {
    public function render() {
        return get_the_title();   // <-- should ECHO
    }
}

// WRONG — render() prints a placeholder, so Elementor's Fallback never fires
public function render() {
    $v = $this->compute();
    echo esc_html( $v ?: '—' );   // <-- non-empty → fallback control is dead
}
// RIGHT — emit nothing on empty
public function render() {
    $v = $this->compute();
    if ( '' === $v ) { return; }
    echo esc_html( $v );
}

// WRONG — Data_Tag expecting an automatic Fallback control (there is none)
class My_Url extends Data_Tag {
    protected function get_value( array $o = [] ) {
        return $this->get_settings( 'fallback' );   // <-- 'fallback' was never registered
    }
}
// RIGHT — register it first
protected function register_controls() {
    $this->add_control( 'fallback', [ 'type' => Controls_Manager::URL ] );
}

// WRONG — wrapping controls in your own section
protected function register_controls() {
    $this->start_controls_section( 'sec', [ 'label' => 'X' ] );  // <-- already inside "Settings"
    $this->add_control( /* … */ );
    $this->end_controls_section();
}

Cross-references

  • Run elementor-dynamic-tag-register for registering the tag, groups, hooks, and bootstrap timing.
  • Run elementor-dynamic-tag-ajax-select when a settings field must pick from a large dataset without a preloaded SELECT2.
  • See reference.md in this skill folder for the full control-type catalog and a complete worked Data_Tag.

What this skill does NOT cover

  • Registering the tag / groups / hooks — elementor-dynamic-tag-register.
  • AJAX/search option fields — elementor-dynamic-tag-ajax-select.
  • Group-control families (Typography, Box Shadow, etc.) — those are for widgets, not tag settings.
  • The full Controls_Manager control reference — see Elementor's controls docs; this skill cites only the types proven in dynamic-tag source.

References

Files (wp-agent-skills)
  • reference.md 7 KB
    # elementor-dynamic-tag-fields — reference
    
    Supporting detail for the `elementor-dynamic-tag-fields` skill: the control-type catalog and a complete end-to-end `Data_Tag`.
    
    ## Control type catalog (`Elementor\Controls_Manager`)
    
    Verified constants in [wp-content/plugins/elementor/includes/managers/controls.php](controls.php). These are the types commonly useful inside a dynamic tag's `register_controls()`:
    
    | Constant | Value | Notes |
    |---|---|---|
    | `Controls_Manager::TEXT` | `'text'` | single-line string |
    | `Controls_Manager::TEXTAREA` | `'textarea'` | multi-line string |
    | `Controls_Manager::NUMBER` | `'number'` | supports `min` / `max` / `step` |
    | `Controls_Manager::SELECT` | `'select'` | single choice; `options` is `[ value => label ]` |
    | `Controls_Manager::SELECT2` | `'select2'` | searchable; add `'multiple' => true` for multi. **Preload only small sets** — see `elementor-dynamic-tag-ajax-select` |
    | `Controls_Manager::SWITCHER` | `'switcher'` | on/off; set `return_value` + `label_on`/`label_off` |
    | `Controls_Manager::CHOOSE` | `'choose'` | icon button group |
    | `Controls_Manager::COLOR` | `'color'` | pairs with `COLOR_CATEGORY` |
    | `Controls_Manager::MEDIA` | `'media'` | image/file; pairs with `IMAGE`/`MEDIA` category |
    | `Controls_Manager::URL` | `'url'` | link; pairs with `URL_CATEGORY` |
    | `Controls_Manager::DATE_TIME` | `'date_time'` | pairs with `DATETIME_CATEGORY` |
    | `Controls_Manager::WYSIWYG` | `'wysiwyg'` | rich-text editor |
    | `Controls_Manager::CODE` | `'code'` | code editor |
    | `Controls_Manager::ICONS` | `'icons'` | icon picker (v2 icon library) |
    | `Controls_Manager::GALLERY` | `'gallery'` | multiple images; pairs with `GALLERY_CATEGORY` |
    | `Controls_Manager::REPEATER` | `'repeater'` | rows of sub-controls (build with `\Elementor\Repeater`) |
    | `Controls_Manager::ALERT` | `'alert'` | static notice in the panel (no value) |
    | `Controls_Manager::HEADING` | `'heading'` | section label (no value) |
    | `Controls_Manager::RAW_HTML` | `'raw_html'` | static markup in the panel (no value) |
    | `Controls_Manager::HIDDEN` | `'hidden'` | stored, not shown |
    
    Line refs: TEXT 54, NUMBER 59, TEXTAREA 64, SELECT 69, SWITCHER 74, HIDDEN 84, HEADING 89, RAW_HTML 94, ALERT 109, COLOR 139, MEDIA 144, CHOOSE 159, WYSIWYG 169, CODE 174, URL 194, REPEATER 199, ICON 204, ICONS 209, GALLERY 214, SELECT2 224, DATE_TIME 229.
    
    ### Reading values back
    
    - `$this->get_settings( 'key' )` — raw stored value.
    - `$this->get_settings_for_display()` — full settings, parsed (use `['key']`); resolves nested dynamic values where applicable.
    - `SWITCHER` returns the `return_value` (e.g. `'yes'`) when on, `''` when off.
    - `SELECT2` multiple returns an array.
    - `MEDIA` returns `[ 'id' => int, 'url' => string ]`; `URL` returns `[ 'url' => string, 'is_external' => '', 'nofollow' => '' ]`.
    
    ## Complete worked `Data_Tag` — author Twitter URL with fallback
    
    A `Data_Tag` feeding a `URL` control, with a manually-wired fallback (Data_Tags get no automatic Before/After/Fallback — see the skill body).
    
    ```php
    namespace MyPlugin\Tags;
    
    use Elementor\Core\DynamicTags\Data_Tag;
    use Elementor\Controls_Manager;
    use Elementor\Modules\DynamicTags\Module as TagsModule;
    
    class Author_Twitter_Url extends Data_Tag {
    
        public function get_name(): string {
            return 'myplugin-author-twitter-url';
        }
    
        public function get_title(): string {
            return esc_html__( 'Author Twitter URL', 'myplugin' );
        }
    
        public function get_group(): string {
            return 'myplugin';   // registered via register_group() — see elementor-dynamic-tag-register
        }
    
        public function get_categories(): array {
            return [ TagsModule::URL_CATEGORY ];   // feeds link controls
        }
    
        protected function register_controls(): void {
            // No automatic fallback on Data_Tag — register one whose type
            // matches the consuming control (URL here).
            $this->add_control( 'fallback', [
                'label' => esc_html__( 'Fallback URL', 'myplugin' ),
                'type'  => Controls_Manager::URL,
            ] );
        }
    
        protected function get_value( array $options = [] ) {
            $author_id = get_the_author_meta( 'ID' );
            $handle    = $author_id ? get_user_meta( $author_id, 'twitter', true ) : '';
    
            if ( $handle ) {
                return [
                    'url'         => 'https://twitter.com/' . ltrim( $handle, '@' ),
                    'is_external' => 'on',
                    'nofollow'    => '',
                ];
            }
    
            // Manual fallback — the URL control value, or empty.
            return $this->get_settings( 'fallback' );
        }
    }
    ```
    
    ## Complete worked `Tag` — reading time with automatic fallback
    
    A `Tag` (echoes) automatically gets Before / After / Fallback in an "Advanced" section, and applies the fallback when `render()` outputs empty. The only contract: emit **nothing** when there's no value.
    
    ```php
    namespace MyPlugin\Tags;
    
    use Elementor\Core\DynamicTags\Tag;
    use Elementor\Controls_Manager;
    use Elementor\Modules\DynamicTags\Module as TagsModule;
    
    class Reading_Time extends Tag {
    
        public function get_name(): string { return 'myplugin-reading-time'; }
        public function get_title(): string { return esc_html__( 'Reading Time', 'myplugin' ); }
        public function get_group(): string { return 'myplugin'; }
        public function get_categories(): array { return [ TagsModule::TEXT_CATEGORY ]; }
    
        protected function register_controls(): void {
            $this->add_control( 'wpm', [
                'label'   => esc_html__( 'Words / minute', 'myplugin' ),
                'type'    => Controls_Manager::NUMBER,
                'default' => 200,
                'min'     => 50,
            ] );
            $this->add_control( 'suffix', [
                'label'   => esc_html__( 'Suffix', 'myplugin' ),
                'type'    => Controls_Manager::TEXT,
                'default' => esc_html__( 'min read', 'myplugin' ),
            ] );
        }
    
        public function render(): void {
            $content = get_the_content();
            if ( '' === trim( $content ) ) {
                return;   // emit nothing → Elementor's Fallback control takes over
            }
    
            $wpm     = max( 50, (int) $this->get_settings( 'wpm' ) );
            $words   = str_word_count( wp_strip_all_tags( $content ) );
            $minutes = max( 1, (int) ceil( $words / $wpm ) );
    
            echo esc_html( $minutes . ' ' . $this->get_settings( 'suffix' ) );
        }
    }
    ```
    
    ## Conditional controls
    
    Use `'condition'` to show a control only for certain settings (same engine as widgets):
    
    ```php
    $this->add_control( 'type', [
        'type'    => Controls_Manager::SELECT,
        'options' => [ 'billing' => 'Billing', 'shipping' => 'Shipping' ],
        'default' => 'billing',
    ] );
    $this->add_control( 'billing_field', [
        'type'      => Controls_Manager::SELECT,
        'options'   => [ /* … */ ],
        'condition' => [ 'type' => 'billing' ],   // hidden unless type === billing
    ] );
    ```
    
    Verified shape in `CustomerDetails` ([wp-content/plugins/dynamic-elementor-extension-main/dynamic-tags/woo-tags/CustomerDetails.php](CustomerDetails.php)) and `Internal_URL` (`condition` per query control).
    
  • SKILL.md 13.8 KB
    ---
    name: elementor-dynamic-tag-fields
    description: Build the body of an Elementor Dynamic Tag — choose Tag
      (echoes via render(), content_type 'ui') vs Data_Tag (returns a
      value via get_value(), content_type 'plain'); declare what kind of
      value it produces via get_categories() using the Module constants
      (TEXT_CATEGORY, URL_CATEGORY, IMAGE_CATEGORY, MEDIA_CATEGORY,
      POST_META_CATEGORY, GALLERY_CATEGORY, NUMBER_CATEGORY, COLOR_CATEGORY,
      DATETIME_CATEGORY, SVG_CATEGORY); add settings fields in
      register_controls() (NOT the deprecated _register_controls()) with
      Controls_Manager types (TEXT, NUMBER, SELECT, SELECT2, SWITCHER,
      MEDIA, …); and wire the fallback system. Important — a Tag gets
      Before / After / Fallback controls automatically and applies the
      fallback when render() outputs empty, but a Data_Tag gets NONE of
      that and must register its own fallback control and consult it in
      get_value(). Use when implementing or reviewing a dynamic tag's
      render/get_value, controls, categories, or fallback behaviour.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "elementor"
      wp-skills-plugin-version-tested: "4.0.7 (free) / 4.0.4 (pro)"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-06-17"
    ---
    
    # Elementor: Dynamic Tag types, fields & fallback
    
    The body of a dynamic tag has three decisions: **which base class** (`Tag` vs `Data_Tag`), **which categories** it produces (so the right controls accept it), and **which settings fields** the editor shows. Plus the easily-missed **fallback** asymmetry between the two base classes. This skill assumes the tag is already being registered — see `elementor-dynamic-tag-register` for that.
    
    ## Decision 1 — `Tag` vs `Data_Tag`
    
    | | `Tag` | `Data_Tag` |
    |---|---|---|
    | Extend | `\Elementor\Core\DynamicTags\Tag` | `\Elementor\Core\DynamicTags\Data_Tag` |
    | You implement | `render()` — **echo** the output | `get_value( array $options = [] )` — **return** the value |
    | `get_content_type()` | `'ui'` (final) | `'plain'` (final) |
    | Use for | rendered text/HTML fragments (price, reading time, a badge) | structured values consumed by a control — an image array `[ 'id' => …, 'url' => … ]`, a URL string, a color |
    
    Verified: `Tag::get_content()` does `ob_start(); $this->render(); $value = ob_get_clean();` ([tag.php:30-37](tag.php)) and `get_content_type()` returns `'ui'` ([tag.php:64](tag.php)). `Data_Tag` declares `abstract protected function get_value()` ([data-tag.php:25](data-tag.php)), returns it directly from `get_content()` ([data-tag.php:43-45](data-tag.php)), and `get_content_type()` returns `'plain'` ([data-tag.php:31](data-tag.php)).
    
    Rule of thumb: if the value feeds a **MEDIA / IMAGE / URL / COLOR** control (the control needs a structured value, not printed markup), use `Data_Tag`. If it feeds a **TEXT** context (it's printed inline), use `Tag`.
    
    ```php
    // Tag — echoes
    class Reading_Time extends Tag {
        public function render(): void {
            echo esc_html( $this->compute() . ' min' );   // print, don't return
        }
    }
    
    // Data_Tag — returns
    class Featured_Image_Fallback extends Data_Tag {
        protected function get_value( array $options = [] ) {
            $id = get_post_thumbnail_id();
            if ( $id ) {
                return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
            }
            return $this->get_settings( 'fallback' );   // see "Fallback" below
        }
    }
    ```
    
    ## Decision 2 — categories (what value the tag produces)
    
    `get_categories()` returns one or more **category constants** from `Elementor\Modules\DynamicTags\Module`. Categories declare the *kind* of value the tag emits; a control accepts a tag only when their categories overlap, so the editor shows your tag only under compatible controls.
    
    Verified constants ([modules/dynamic-tags/module.php:31-76](module.php)):
    
    | Constant | Value | Typical use |
    |---|---|---|
    | `TEXT_CATEGORY` | `'text'` | printed strings (most `Tag`s) |
    | `URL_CATEGORY` | `'url'` | link fields |
    | `IMAGE_CATEGORY` | `'image'` | image controls |
    | `MEDIA_CATEGORY` | `'media'` | media (image/video) controls |
    | `POST_META_CATEGORY` | `'post_meta'` | meta-field contexts |
    | `GALLERY_CATEGORY` | `'gallery'` | gallery controls |
    | `NUMBER_CATEGORY` | `'number'` | number controls |
    | `COLOR_CATEGORY` | `'color'` | color controls |
    | `DATETIME_CATEGORY` | `'datetime'` | date/time controls |
    | `SVG_CATEGORY` | `'svg'` | inline-SVG / icon controls |
    
    A tag may declare several — Pro's `Post_Custom_Field` returns `[ TEXT, URL, POST_META, COLOR, DATETIME, MEDIA ]` because a meta value can drive any of those controls. Reference the constant, never the bare string, so a renamed value can't break you.
    
    ```php
    use Elementor\Modules\DynamicTags\Module as TagsModule;
    
    public function get_categories(): array {
        return [ TagsModule::TEXT_CATEGORY ];
    }
    ```
    
    ## Decision 3 — settings fields via `register_controls()`
    
    Add the tag's configuration fields in `register_controls()`. Elementor has **already opened a "Settings" controls section** around your call ([base-tag.php:175-187](base-tag.php)) — add controls directly; do **not** wrap them in your own `start_controls_section()`.
    
    ```php
    use Elementor\Controls_Manager;
    
    protected function register_controls(): void {
        $this->add_control( 'format', [
            'label'   => esc_html__( 'Format', 'myplugin' ),
            'type'    => Controls_Manager::SELECT,
            'default' => 'minutes',
            'options' => [
                'minutes' => esc_html__( 'Minutes', 'myplugin' ),
                'words'   => esc_html__( 'Word count', 'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'wpm', [
            'label'   => esc_html__( 'Words / minute', 'myplugin' ),
            'type'    => Controls_Manager::NUMBER,
            'default' => 200,
            'min'     => 50,
        ] );
    }
    ```
    
    Common control `type`s (from `Elementor\Controls_Manager`, all seen in verified tags/widgets): `TEXT`, `TEXTAREA`, `NUMBER`, `SELECT`, `SELECT2` (add `'multiple' => true` for multi), `SWITCHER`, `CHOOSE`, `COLOR`, `MEDIA`, `ICONS`, `ALERT`, `REPEATER`. Read settings back with `$this->get_settings( 'key' )` (raw) or `$this->get_settings_for_display()` (parsed). For a large-dataset picker (products/posts) use the AJAX query control — see `elementor-dynamic-tag-ajax-select`; a plain `SELECT2` preloaded with thousands of options freezes the editor.
    
    **Name your method `register_controls()`, not `_register_controls()`.** The underscore form is deprecated since 3.1.0 — `init_controls()` calls it but emits a `_doing_it_wrong` notice ([base-tag.php:179-182](base-tag.php)). (The reference plugin used the underscore form until its 2026-06-17 migration; older copies and tutorials still show it, so recognise it but don't copy it.)
    
    Two optional panel hints on `Base_Tag`:
    
    - `is_settings_required()` — return `true` if the tag is useless until configured (Pro's `Post_Custom_Field` does). Default `false` ([base-tag.php:83](base-tag.php)).
    - `get_panel_template_setting_key()` — return a control key to surface in the tag's panel label (e.g. `'key'`). Default `''` ([base-tag.php:75](base-tag.php)).
    
    ## The fallback system (the asymmetry that bites)
    
    A `Tag` gets **Before / After / Fallback** controls **for free** and applies the fallback automatically when `render()` produces empty output. A `Data_Tag` gets **none of this** and must do it by hand.
    
    ### `Tag` — automatic
    
    `Tag::register_advanced_section()` adds an "Advanced" section with `before`, `after`, and `fallback` controls ([tag.php:84-123](tag.php)), and `get_content()` applies them: if the rendered value is non-empty it prepends `before` / appends `after`; **else if** a `fallback` is set it uses `wp_kses_post_deep( $settings['fallback'] )` ([tag.php:39-55](tag.php)). You write `render()` and get fallback behaviour automatically — just make sure `render()` outputs **nothing** when there's no value (don't echo `'0'`, `'—'`, or an empty wrapper, or the fallback never triggers).
    
    ```php
    public function render(): void {
        $value = $this->compute();
        if ( '' === $value ) {
            return;   // emit nothing → Elementor's Fallback control takes over
        }
        echo esc_html( $value );
    }
    ```
    
    ### `Data_Tag` — manual
    
    `Data_Tag` inherits the **empty** `Base_Tag::register_advanced_section()` ([base-tag.php:166](base-tag.php)) — no Before/After/Fallback section is added, and `get_content()` does **no** fallback logic. To support a fallback you register the control yourself and consult it in `get_value()`. Pro's `Post_Featured_Image` is the canonical pattern:
    
    ```php
    protected function register_controls(): void {
        $this->add_control( 'fallback', [
            'label' => esc_html__( 'Fallback', 'myplugin' ),
            'type'  => Controls_Manager::MEDIA,   // match the control type your value feeds
        ] );
    }
    
    protected function get_value( array $options = [] ) {
        $id = get_post_thumbnail_id();
        if ( $id ) {
            return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
        }
        return $this->get_settings( 'fallback' );   // the manual fallback
    }
    ```
    
    Verified at [post-featured-image.php:33-56](post-featured-image.php) — `get_value()` returns the image array or `$this->get_settings( 'fallback' )`, and `register_controls()` adds a single `MEDIA` fallback control.
    
    ## Critical rules
    
    - **`Tag` echoes (`render()`); `Data_Tag` returns (`get_value()`).** Mixing them up (returning from `render()`, or echoing from `get_value()`) silently produces empty/garbage output.
    - **`get_categories()` returns `Module::*_CATEGORY` constants**, never bare strings, and matches the control kinds your value feeds. Wrong categories → the tag never appears under the intended control.
    - **Add controls directly in `register_controls()`** — Elementor already opened the "Settings" section. A self-opened section nests incorrectly.
    - **Method is `register_controls()`**, not `_register_controls()` (deprecated 3.1.0).
    - **`Tag` fallback is automatic** but only triggers on **empty** output — `render()` must emit nothing when there's no value.
    - **`Data_Tag` has no automatic fallback/before/after** — register a `fallback` control and read it in `get_value()` yourself, with a `type` matching the consuming control.
    - **Escape on output in `Tag::render()`** (`esc_html` / `wp_kses_post`) — it's echoed into the page like any front-end output.
    
    ## Common mistakes
    
    ```php
    // WRONG — Data_Tag that echoes (output is captured nowhere; control gets null)
    class My_Image extends Data_Tag {
        protected function get_value( array $options = [] ) {
            echo wp_get_attachment_url( $id );   // <-- should RETURN
        }
    }
    
    // WRONG — Tag that returns (nothing is printed; tag renders empty)
    class My_Text extends Tag {
        public function render() {
            return get_the_title();   // <-- should ECHO
        }
    }
    
    // WRONG — render() prints a placeholder, so Elementor's Fallback never fires
    public function render() {
        $v = $this->compute();
        echo esc_html( $v ?: '—' );   // <-- non-empty → fallback control is dead
    }
    // RIGHT — emit nothing on empty
    public function render() {
        $v = $this->compute();
        if ( '' === $v ) { return; }
        echo esc_html( $v );
    }
    
    // WRONG — Data_Tag expecting an automatic Fallback control (there is none)
    class My_Url extends Data_Tag {
        protected function get_value( array $o = [] ) {
            return $this->get_settings( 'fallback' );   // <-- 'fallback' was never registered
        }
    }
    // RIGHT — register it first
    protected function register_controls() {
        $this->add_control( 'fallback', [ 'type' => Controls_Manager::URL ] );
    }
    
    // WRONG — wrapping controls in your own section
    protected function register_controls() {
        $this->start_controls_section( 'sec', [ 'label' => 'X' ] );  // <-- already inside "Settings"
        $this->add_control( /* … */ );
        $this->end_controls_section();
    }
    ```
    
    ## Cross-references
    
    - Run **`elementor-dynamic-tag-register`** for registering the tag, groups, hooks, and bootstrap timing.
    - Run **`elementor-dynamic-tag-ajax-select`** when a settings field must pick from a large dataset without a preloaded `SELECT2`.
    - See `reference.md` in this skill folder for the full control-type catalog and a complete worked `Data_Tag`.
    
    ## What this skill does NOT cover
    
    - **Registering the tag / groups / hooks** — `elementor-dynamic-tag-register`.
    - **AJAX/search option fields** — `elementor-dynamic-tag-ajax-select`.
    - **Group-control families** (Typography, Box Shadow, etc.) — those are for widgets, not tag settings.
    - **The full `Controls_Manager` control reference** — see Elementor's controls docs; this skill cites only the types proven in dynamic-tag source.
    
    ## References
    
    - `Tag`: [wp-content/plugins/elementor/core/dynamic-tags/tag.php](tag.php) — `get_content()` ob/render/fallback (30-58), `'ui'` type (64), advanced section with before/after/fallback (84-123).
    - `Data_Tag`: [wp-content/plugins/elementor/core/dynamic-tags/data-tag.php](data-tag.php) — abstract `get_value()` (25), `'plain'` type (31), `get_content()` returns value (43-45).
    - `Base_Tag`: [wp-content/plugins/elementor/core/dynamic-tags/base-tag.php](base-tag.php) — `init_controls()` opens Settings + `_register_controls` deprecation (172-187), empty `register_advanced_section()` (166), `is_settings_required()` (83), `get_panel_template_setting_key()` (75).
    - Categories: [wp-content/plugins/elementor/modules/dynamic-tags/module.php:31-76](module.php).
    - Manual-fallback Data_Tag: [wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/post-featured-image.php:33-56](post-featured-image.php).
    - Tag with SELECT + TEXT controls + `is_settings_required()`: [wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/post-custom-field.php:46-105](post-custom-field.php).
    - Official documentation: <https://developers.elementor.com/docs/dynamic-tags/>
    - Official documentation: <https://developers.elementor.com/docs/dynamic-tags/dynamic-tag-data/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related