Claude Skill

elementor-v3-widget-development

Builds and reviews production-ready classic Elementor widgets based on `Elementor\Widget_Base`: companion-plugin bootstrap and compatibility gates, `elementor/widgets/register`, widget identity, PHP and editor rendering, render attributes, asset dependencies, frontend handlers, a

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-v3-widget-development-52f6020.zip · 10 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-v3-widget-development
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 V3 widget development

Build companion-plugin widgets on Elementor's established Widget_Base / Controls_Stack architecture. Treat V3 here as the classic editor/widget model, not as an installed Elementor 3.x version: this API remains available and is used by core widgets in Elementor 4.2.3.

Do not mix this model with Atomic Widgets / Editor V4. Atomic widgets extend classes under Elementor\Modules\AtomicWidgets, declare prop types and styles differently, and remain a moving surface. Never translate a V3 control array into an Atomic schema by guesswork.

When to use this skill

  • Create or review a class extending \Elementor\Widget_Base.
  • Register widgets, categories, scripts, or styles from an Elementor addon.
  • Implement register_controls(), render(), content_template(), or render_plain_content().
  • Add widget frontend JavaScript through frontend/element_ready/{widget-name}.default.
  • Decide whether is_dynamic_content() may return false.
  • Diagnose a widget visible in PHP but missing/broken in the editor or frontend.
  • Migrate _register_controls() or elementor/widgets/widgets_registered to current APIs.

For a complete companion-plugin skeleton and test matrix, read references/widget-contract-and-example.md. Load elementor-v3-widget-controls as well when designing or reviewing the control schema.

Architecture boundary

Use the following identity test before editing:

Model Base/signals This skill
Classic V3 widget Elementor\Widget_Base, Controls_Manager, register_controls(), render() In scope
Atomic / Editor V4 Modules\AtomicWidgets, Atomic_Widget_Base, prop types, Atomic controls/styles Out of scope
Elementor plugin version ELEMENTOR_VERSION, currently 4.2.3 in the tested install Independent of the model name

Allow both models to coexist in a plugin only behind separate classes and registration paths. Do not make Atomic feature flags a prerequisite for a classic widget.

Workflow

1. Gate the companion plugin before loading widget classes

  1. Declare Requires Plugins: elementor in the plugin header on supported WordPress versions.
  2. Run compatibility checks after plugins load. Verify did_action( 'elementor/loaded' ), ELEMENTOR_VERSION, and the addon's actual PHP minimum.
  3. Do not include a file that extends Widget_Base until Elementor is loaded; otherwise a missing/inactive Elementor causes a fatal before a notice can run.
  4. Register callbacks only when requirements pass. Keep Pro optional unless the widget genuinely extends a Pro-only API.

Choose and document a real minimum Elementor version. The modern widget registration contract used here is stable since 3.5.0; a tested-up-to value is not a minimum-version claim.

2. Register, do not instantiate early

Hook the manager and pass a widget instance:

add_action(
    'elementor/widgets/register',
    static function ( \Elementor\Widgets_Manager $widgets_manager ): void {
        require_once __DIR__ . '/includes/class-example-widget.php';
        $widgets_manager->register( new Example_Widget() );
    }
);
  • Use elementor/widgets/register; elementor/widgets/widgets_registered is deprecated since 3.5.0.
  • Give get_name() a stable, globally unique, prefixed lowercase identifier. It becomes widgetType, is persisted in Elementor JSON, participates in CSS classes, and selects the frontend-ready hook. Renaming it breaks existing content.
  • Register an optional category on elementor/elements/categories_registered with $elements_manager->add_category(). Keep a fallback category such as general; a category is organization, not authorization.
  • Never unregister or overwrite another widget merely to resolve a name collision.

3. Implement the smallest correct widget contract

Implement these methods deliberately:

public function get_name(): string;
public function get_title(): string;
public function get_icon(): string;
public function get_categories(): array;
public function get_keywords(): array;
protected function register_controls(): void;
protected function render(): void;

get_icon(), categories, and keywords have base defaults, but explicit metadata makes a public widget discoverable and predictable. Translate human-facing strings; do not translate identifiers, control IDs, script handles, or category slugs.

Use register_controls(), never deprecated _register_controls(). Delegate the control array and value-shape work to elementor-v3-widget-controls.

4. Make PHP rendering canonical and safe

  1. Read display values with $this->get_settings_for_display(). It applies active-control conditions and dynamic-tag parsing; get_settings() is raw saved/default data. Process shortcodes only through an explicit renderer such as parse_text_editor() or do_shortcode() when the widget intentionally supports them.
  2. Validate enumerations again at output time. Saved Elementor JSON, REST/import operations, filters, and dynamic tags can bypass the editor's option list.
  3. Escape at the final output context: esc_html(), wp_kses_post(), esc_url(), or an explicit wp_kses() allowlist. Control registration is not an output sanitizer.
  4. Build attributes through add_render_attribute() and print_render_attribute_string(). Build URL-control links through add_link_attributes().
  5. Use add_inline_editing_attributes() only on text nodes intended for editor editing. For repeaters, derive a unique key with get_repeater_setting_key().
  6. Return early for empty optional content rather than emitting empty semantic elements.
  7. Emit valid semantic HTML and accessible names/states. Do not use a clickable div where a button or link is required.

Treat render() as the source of truth. Add content_template() only when immediate Backbone-based editor preview is worth maintaining, then keep its structure, conditions, attributes, and escaping intent in parity with PHP. Never move authorization or sensitive lookup logic into the JS template.

Override render_plain_content() when the default rendered HTML is unsuitable for WordPress search, SEO extraction, feeds, or Elementor deactivation. Return meaningful plain content, a shortcode when appropriate, or an empty string for functionality that must not survive deactivation.

5. Register assets once and declare dependencies

Register handles on a WordPress enqueue hook; do not enqueue globally and do not register them on every render() call:

add_action( 'wp_enqueue_scripts', static function (): void {
    wp_register_style( 'acme-example-widget', plugins_url( 'assets/widget.css', __FILE__ ), [], '1.0.0' );
    wp_register_script( 'acme-example-widget', plugins_url( 'assets/widget.js', __FILE__ ), [ 'elementor-frontend' ], '1.0.0', true );
} );

Return registered handles from get_style_depends() / get_script_depends(). Elementor then loads them for pages containing the widget, including the preview iframe. Use elementor/editor/before_enqueue_scripts or .../after_enqueue_scripts only for code that belongs to the editor panel itself.

For interactive widgets, initialize each instance from:

jQuery( window ).on( 'elementor/frontend/init', () => {
    elementorFrontend.hooks.addAction(
        'frontend/element_ready/acme-example.default',
        ( $scope ) => { /* initialize only inside $scope */ }
    );
} );
  • Make initialization idempotent; editor rerenders can fire the hook repeatedly.
  • Scope queries and event teardown to the current $scope.
  • Use the exact get_name() plus .default; skins use their own suffix.
  • Do not initialize solely on DOM ready: that misses editor rerenders and dynamically inserted elements.

6. Decide output caching from runtime behavior

The base returns true from is_dynamic_content(), so output is not declared cacheable. Override it to false only when output is stable for all users/requests and fully determined by cache-safe settings/dependencies:

protected function is_dynamic_content(): bool {
    return false;
}

Keep the default true when rendering depends on the current user, cookies/session, request, time, randomness, stock/entitlement state, uncached remote data, or mutable external state. Elementor separately detects configured dynamic tags, but that does not prove arbitrary PHP logic is static.

For inner-wrapper compatibility and icon rendering, apply elementor-experiments-and-markup. Do not assume .elementor-widget-container exists on core widgets, and render ICONS values through Icons_Manager::render_icon().

Critical rules

  • Keep classic V3 and Atomic/V4 classes, controls, styles, and registration paths separate.
  • Load a Widget_Base subclass only after Elementor is available.
  • Use the modern manager hook and a stable, prefixed get_name().
  • Treat PHP render() as canonical and escape every value for its output context.
  • Register asset handles once; let widget dependency methods control loading.
  • Initialize frontend JS through the widget-ready hook and make it idempotent.
  • Return false from is_dynamic_content() only after proving cross-user output stability.
  • Test both editor preview and published frontend; they exercise different render and asset paths.

Review checks

  • Bootstrap: inactive/old Elementor produces no fatal and a useful admin state.
  • Registration: one unique widget appears exactly once in its expected category.
  • Persistence: existing instances survive plugin upgrades because names/control IDs remain stable.
  • Rendering: empty, default, rich text, link, media, responsive, repeater, and dynamic-tag states are safe.
  • Assets: absent on pages without the widget; present once on pages with one or many instances.
  • JS: works after editor rerender and does not duplicate listeners.
  • Compatibility: free-only and Pro-active installations; optimized markup on/off where relevant.
  • Performance: no unbounded query in registration/render and no false static-cache declaration.

Cross-references

  • Run elementor-v3-widget-controls for built-in control schemas, values, selectors, conditions, and repeaters.
  • Run elementor-experiments-and-markup when rendering icons or depending on wrapper markup.
  • Run elementor-deprecations while upgrading an older addon or reviewing legacy hooks/methods.

What this skill does NOT cover

  • Atomic Widgets / Editor V4 implementation.
  • Custom Elementor control classes and control-manager registration.
  • Pro-only Forms fields, Theme Builder conditions, nested-element internals, skins, or documents.
  • Business-specific authorization, query, REST, or data-storage design beyond the widget boundary.

References

Files (wp-agent-skills)
  • agents
    • openai.yaml 250 B
      interface:
        display_name: "Elementor V3 Widget Development"
        short_description: "Build production-ready classic Elementor widgets."
        default_prompt: "Use $elementor-v3-widget-development to build or review a classic Widget_Base Elementor widget."
      
  • references
    • widget-contract-and-example.md 15.2 KB
      # Classic Widget_Base contract and example
      
      Read this reference when implementing a complete Elementor companion plugin, adding a frontend handler, or preparing a release test. The code targets the established classic widget API and PHP 7.4+. It does not use Atomic Widgets.
      
      ## Companion-plugin bootstrap
      
      Keep the main file free of top-level references that require Elementor classes. Load the widget subclass only inside the registration callback, after the compatibility gate has passed.
      
      ```php
      <?php
      /**
       * Plugin Name: Acme Elementor Widgets
       * Description: Example classic Elementor widgets.
       * Version: 1.0.0
       * Requires at least: 6.8
       * Requires PHP: 7.4
       * Requires Plugins: elementor
       * Elementor tested up to: 4.2.3
       * Elementor Pro tested up to: 4.2.2
       * Text Domain: acme-elementor-widgets
       */
      
      namespace Acme\ElementorWidgets;
      
      defined( 'ABSPATH' ) || exit;
      
      const VERSION = '1.0.0';
      const MINIMUM_ELEMENTOR_VERSION = '3.5.0';
      
      add_action( 'plugins_loaded', __NAMESPACE__ . '\\bootstrap' );
      
      function bootstrap(): void {
          if ( ! did_action( 'elementor/loaded' ) ) {
              add_action( 'admin_notices', __NAMESPACE__ . '\\missing_elementor_notice' );
              return;
          }
      
          if (
              ! defined( 'ELEMENTOR_VERSION' ) ||
              version_compare( ELEMENTOR_VERSION, MINIMUM_ELEMENTOR_VERSION, '<' )
          ) {
              add_action( 'admin_notices', __NAMESPACE__ . '\\old_elementor_notice' );
              return;
          }
      
          add_action( 'elementor/elements/categories_registered', __NAMESPACE__ . '\\register_category' );
          add_action( 'elementor/widgets/register', __NAMESPACE__ . '\\register_widgets' );
          add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\register_assets' );
      }
      
      function register_category( \Elementor\Elements_Manager $elements_manager ): void {
          $elements_manager->add_category(
              'acme-widgets',
              [ 'title' => esc_html__( 'Acme Widgets', 'acme-elementor-widgets' ) ]
          );
      }
      
      function register_widgets( \Elementor\Widgets_Manager $widgets_manager ): void {
          require_once __DIR__ . '/includes/class-card-widget.php';
          $widgets_manager->register( new Widgets\Card_Widget() );
      }
      
      function register_assets(): void {
          wp_register_style(
              'acme-card-widget',
              plugins_url( 'assets/css/card-widget.css', __FILE__ ),
              [],
              VERSION
          );
      
          wp_register_script(
              'acme-card-widget',
              plugins_url( 'assets/js/card-widget.js', __FILE__ ),
              [ 'elementor-frontend' ],
              VERSION,
              true
          );
      }
      
      function missing_elementor_notice(): void {
          echo '<div class="notice notice-warning"><p>' .
              esc_html__( 'Acme Elementor Widgets requires Elementor.', 'acme-elementor-widgets' ) .
              '</p></div>';
      }
      
      function old_elementor_notice(): void {
          echo '<div class="notice notice-warning"><p>' .
              esc_html__( 'Acme Elementor Widgets requires a newer Elementor version.', 'acme-elementor-widgets' ) .
              '</p></div>';
      }
      ```
      
      Notes:
      
      - Set the minimum to the oldest version actually tested. `3.5.0` is used here because this sample intentionally depends on the modern manager hook.
      - Keep `Elementor tested up to` as the newest verified version, not the minimum.
      - `Requires Plugins` improves dependency handling but does not replace the runtime gate for unusual load orders, older WordPress versions, or programmatic execution.
      - Register assets once. Do not register them in the widget constructor or enqueue them on every page.
      - Add a Pro gate only around features that actually need Pro. A normal `Widget_Base` widget is a free-core integration.
      
      ## Complete classic widget
      
      This example uses only built-in controls. It keeps the PHP renderer canonical, validates the HTML tag independently of the `SELECT`, and escapes content at output.
      
      ```php
      <?php
      
      namespace Acme\ElementorWidgets\Widgets;
      
      use Elementor\Controls_Manager;
      use Elementor\Group_Control_Typography;
      use Elementor\Utils;
      use Elementor\Widget_Base;
      
      defined( 'ABSPATH' ) || exit;
      
      final class Card_Widget extends Widget_Base {
          public function get_name(): string {
              return 'acme-card';
          }
      
          public function get_title(): string {
              return esc_html__( 'Acme Card', 'acme-elementor-widgets' );
          }
      
          public function get_icon(): string {
              return 'eicon-call-to-action';
          }
      
          public function get_categories(): array {
              return [ 'acme-widgets' ];
          }
      
          public function get_keywords(): array {
              return [ 'card', 'content', 'link' ];
          }
      
          public function get_style_depends(): array {
              return [ 'acme-card-widget' ];
          }
      
          public function get_script_depends(): array {
              return [ 'acme-card-widget' ];
          }
      
          protected function is_dynamic_content(): bool {
              // Safe here only because output is settings-derived. Elementor bypasses
              // element caching when a configured dynamic tag is present.
              return false;
          }
      
          protected function register_controls(): void {
              $this->start_controls_section(
                  'section_content',
                  [
                      'label' => esc_html__( 'Content', 'acme-elementor-widgets' ),
                      'tab'   => Controls_Manager::TAB_CONTENT,
                  ]
              );
      
              $this->add_control(
                  'title',
                  [
                      'label'       => esc_html__( 'Title', 'acme-elementor-widgets' ),
                      'type'        => Controls_Manager::TEXT,
                      'default'     => esc_html__( 'A useful card', 'acme-elementor-widgets' ),
                      'label_block' => true,
                      'dynamic'     => [ 'active' => true ],
                  ]
              );
      
              $this->add_control(
                  'title_tag',
                  [
                      'label'   => esc_html__( 'Title HTML tag', 'acme-elementor-widgets' ),
                      'type'    => Controls_Manager::SELECT,
                      'default' => 'h3',
                      'options' => [
                          'h2' => 'H2',
                          'h3' => 'H3',
                          'h4' => 'H4',
                          'p'  => 'p',
                      ],
                  ]
              );
      
              $this->add_control(
                  'description',
                  [
                      'label'   => esc_html__( 'Description', 'acme-elementor-widgets' ),
                      'type'    => Controls_Manager::TEXTAREA,
                      'default' => esc_html__( 'Explain the next action.', 'acme-elementor-widgets' ),
                      'dynamic' => [ 'active' => true ],
                  ]
              );
      
              $this->add_control(
                  'link',
                  [
                      'label'       => esc_html__( 'Link', 'acme-elementor-widgets' ),
                      'type'        => Controls_Manager::URL,
                      'placeholder' => 'https://example.com/',
                      'options'     => [ 'url', 'is_external', 'nofollow', 'custom_attributes' ],
                  ]
              );
      
              $this->end_controls_section();
      
              $this->start_controls_section(
                  'section_style',
                  [
                      'label' => esc_html__( 'Card', 'acme-elementor-widgets' ),
                      'tab'   => Controls_Manager::TAB_STYLE,
                  ]
              );
      
              $this->add_control(
                  'title_color',
                  [
                      'label'     => esc_html__( 'Title color', 'acme-elementor-widgets' ),
                      'type'      => Controls_Manager::COLOR,
                      'selectors' => [
                          '{{WRAPPER}} .acme-card__title' => 'color: {{VALUE}};',
                      ],
                  ]
              );
      
              $this->add_group_control(
                  Group_Control_Typography::get_type(),
                  [
                      'name'     => 'title_typography',
                      'selector' => '{{WRAPPER}} .acme-card__title',
                  ]
              );
      
              $this->end_controls_section();
          }
      
          protected function render(): void {
              $settings = $this->get_settings_for_display();
              $title = trim( (string) ( $settings['title'] ?? '' ) );
      
              if ( '' === $title ) {
                  return;
              }
      
              $tag = Utils::validate_html_tag( (string) ( $settings['title_tag'] ?? 'h3' ) );
      
              $this->add_render_attribute( 'card', 'class', 'acme-card' );
              $this->add_render_attribute( 'title', 'class', 'acme-card__title' );
              $this->add_inline_editing_attributes( 'title', 'none' );
      
              if ( ! empty( $settings['link']['url'] ) ) {
                  $this->add_link_attributes( 'link', $settings['link'] );
              }
              ?>
              <article <?php $this->print_render_attribute_string( 'card' ); ?>>
                  <<?php echo esc_attr( $tag ); ?> <?php $this->print_render_attribute_string( 'title' ); ?>>
                      <?php echo esc_html( $title ); ?>
                  </<?php echo esc_attr( $tag ); ?>>
      
                  <?php if ( '' !== trim( (string) ( $settings['description'] ?? '' ) ) ) : ?>
                      <p class="acme-card__description">
                          <?php echo esc_html( $settings['description'] ); ?>
                      </p>
                  <?php endif; ?>
      
                  <?php if ( ! empty( $settings['link']['url'] ) ) : ?>
                      <a class="acme-card__link" <?php $this->print_render_attribute_string( 'link' ); ?>>
                          <?php echo esc_html__( 'Learn more', 'acme-elementor-widgets' ); ?>
                      </a>
                  <?php endif; ?>
              </article>
              <?php
          }
      
          public function render_plain_content(): void {
              $settings = $this->get_settings_for_display();
              echo esc_html( (string) ( $settings['title'] ?? '' ) );
          }
      
          protected function content_template(): void {
              ?>
              <#
              const allowedTags = [ 'h2', 'h3', 'h4', 'p' ];
              const titleTag = allowedTags.includes( settings.title_tag ) ? settings.title_tag : 'h3';
              const title = _.escape( settings.title || '' );
              const description = _.escape( settings.description || '' );
      
              if ( ! title ) {
                  return;
              }
      
              view.addRenderAttribute( 'card', 'class', 'acme-card' );
              view.addRenderAttribute( 'title', 'class', 'acme-card__title' );
              view.addInlineEditingAttributes( 'title', 'none' );
              #>
              <article {{{ view.getRenderAttributeString( 'card' ) }}}>
                  <{{{ titleTag }}} {{{ view.getRenderAttributeString( 'title' ) }}}>{{{ title }}}</{{{ titleTag }}}>
                  <# if ( description ) { #>
                      <p class="acme-card__description">{{{ description }}}</p>
                  <# } #>
                  <# if ( settings.link && settings.link.url ) { #>
                      <a class="acme-card__link" href="{{ elementor.helpers.sanitizeUrl( settings.link.url ) }}">
                          <?php echo esc_html__( 'Learn more', 'acme-elementor-widgets' ); ?>
                      </a>
                  <# } #>
              </article>
              <?php
          }
      }
      ```
      
      If the widget supports WYSIWYG output, define an HTML policy. Do not replace `esc_html()` with raw output merely because the editor produced the value. For a broad post-content policy use `wp_kses_post()`; for tighter components pass an explicit allowlist to `wp_kses()`.
      
      If the widget runs shortcodes, current-user logic, time-sensitive logic, request-specific logic, or remote queries, remove the `is_dynamic_content(): false` override unless the cache contract has been proven separately.
      
      ## Frontend handler pattern
      
      Use an idempotence marker on each Elementor scope. Namespace events and tear down an earlier binding before adding a new one.
      
      ```js
      ( ( $ ) => {
          class AcmeCardHandler extends elementorModules.frontend.handlers.Base {
              getDefaultSettings() {
                  return { selectors: { link: '.acme-card__link' } };
              }
      
              getDefaultElements() {
                  const selectors = this.getSettings( 'selectors' );
                  return { $link: this.$element.find( selectors.link ) };
              }
      
              bindEvents() {
                  this.elements.$link
                      .off( 'click.acmeCard' )
                      .on( 'click.acmeCard', () => {
                          this.$element.trigger( 'acme:card-activated' );
                      } );
              }
      
              onDestroy() {
                  this.elements.$link.off( '.acmeCard' );
              }
          }
      
          $( window ).on( 'elementor/frontend/init', () => {
              elementorFrontend.hooks.addAction(
                  'frontend/element_ready/acme-card.default',
                  ( $element ) => {
                      elementorFrontend.elementsHandler.addHandler( AcmeCardHandler, { $element } );
                  }
              );
          } );
      } )( jQuery );
      ```
      
      For a small behavior, a scoped callback is acceptable, but keep the same hook and idempotence rules. Do not assume a global DOM-ready callback will run for live editor replacements.
      
      ## Release test matrix
      
      ### Bootstrap and registration
      
      - Elementor inactive: addon does not fatal and does not load the widget subclass.
      - Elementor below minimum: addon does not register hooks and reports the requirement.
      - Elementor Free only: the widget registers and renders.
      - Elementor Pro active: the same classic widget behaves identically unless an explicitly Pro-only feature is enabled.
      - Duplicate activation/load simulation: the widget is registered once.
      
      ### Control and persistence
      
      - New instance defaults match their documented shapes.
      - Save, reload editor, duplicate widget, copy/paste, export/import template, and revision restore preserve settings.
      - Old instances still resolve after an addon update; widget and control IDs were not renamed.
      - Invalid imported enum/tag values fall back through a server allowlist.
      - Dynamic tags are checked in editor and published frontend.
      
      ### Output and security
      
      - Empty and maximal content produce valid HTML.
      - Text containing markup, quotes, URLs, and encoded entities is escaped for the intended context.
      - Link target/rel/custom attributes are produced through `add_link_attributes()`.
      - Media IDs are permission/visibility checked where the widget exposes non-public media.
      - Buttons, links, headings, labels, focus order, keyboard activation, and ARIA state are meaningful.
      
      ### Assets and lifecycle
      
      - A page without the widget does not request widget-only handles.
      - One and multiple instances load each dependency once.
      - The editor preview iframe loads frontend dependencies; editor-panel-only assets do not leak to frontend.
      - JS initializes on first load and after control changes/rerenders without doubled events.
      - Widget removal/re-addition does not leave stale global listeners.
      
      ### Performance and compatibility
      
      - Rendering does not perform unbounded or per-row N+1 queries.
      - A static cache declaration is tested across anonymous/logged-in users and changing requests.
      - Optimized Markup on/off does not break selectors.
      - Inline Font Icons on/off works when icons are in scope.
      - PHP 7.4 and the newest supported PHP both pass lint/runtime tests.
      
      ## Grounding notes
      
      - `Widgets_Manager::register()` stores the instance under its `get_name()` key. A collision can replace the registered object, so prefixing is correctness, not style.
      - `Controls_Stack::get_settings_for_display()` resolves active settings and parsed dynamic settings.
      - `Element_Base::add_link_attributes()` escapes the URL and rejects custom `href` and `on*` attributes through `Utils::parse_custom_attributes()`.
      - `Widget_Base::render_content()` owns the optional inner wrapper and asset/runtime registration around the widget's `render()` output.
      - `Element_Base::is_dynamic_content()` defaults to `true`; the false override opts into output caching eligibility.
      - Elementor frontend fires `frontend/element_ready/{widgetType.skin}` from `assets/js/frontend.js`.
      
  • SKILL.md 12.4 KB
    ---
    name: elementor-v3-widget-development
    description: >-
      Builds and reviews production-ready classic Elementor widgets based on
      `Elementor\Widget_Base`: companion-plugin bootstrap and compatibility gates,
      `elementor/widgets/register`, widget identity, PHP and editor rendering,
      render attributes, asset dependencies, frontend handlers, accessibility,
      output caching, and regression tests. Use when code extends `Widget_Base`,
      implements `register_controls()` or `render()`, registers an Elementor
      widget/category, or must distinguish the established V3 widget API from
      Atomic Widgets / Editor V4. Does not cover custom control types.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "elementor"
      wp-skills-plugin-version-tested: "4.2.3 (free) / 4.2.2 (pro)"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-api-stable-since: "3.5.0"
      wp-skills-last-updated: "2026-08-22"
    ---
    
    # Elementor V3 widget development
    
    Build companion-plugin widgets on Elementor's established `Widget_Base` / `Controls_Stack` architecture. Treat **V3** here as the classic editor/widget model, not as an installed Elementor 3.x version: this API remains available and is used by core widgets in Elementor 4.2.3.
    
    Do not mix this model with Atomic Widgets / Editor V4. Atomic widgets extend classes under `Elementor\Modules\AtomicWidgets`, declare prop types and styles differently, and remain a moving surface. Never translate a V3 control array into an Atomic schema by guesswork.
    
    ## When to use this skill
    
    - Create or review a class extending `\Elementor\Widget_Base`.
    - Register widgets, categories, scripts, or styles from an Elementor addon.
    - Implement `register_controls()`, `render()`, `content_template()`, or `render_plain_content()`.
    - Add widget frontend JavaScript through `frontend/element_ready/{widget-name}.default`.
    - Decide whether `is_dynamic_content()` may return `false`.
    - Diagnose a widget visible in PHP but missing/broken in the editor or frontend.
    - Migrate `_register_controls()` or `elementor/widgets/widgets_registered` to current APIs.
    
    For a complete companion-plugin skeleton and test matrix, read `references/widget-contract-and-example.md`. Load **`elementor-v3-widget-controls`** as well when designing or reviewing the control schema.
    
    ## Architecture boundary
    
    Use the following identity test before editing:
    
    | Model | Base/signals | This skill |
    |---|---|---|
    | Classic V3 widget | `Elementor\Widget_Base`, `Controls_Manager`, `register_controls()`, `render()` | In scope |
    | Atomic / Editor V4 | `Modules\AtomicWidgets`, `Atomic_Widget_Base`, prop types, Atomic controls/styles | Out of scope |
    | Elementor plugin version | `ELEMENTOR_VERSION`, currently 4.2.3 in the tested install | Independent of the model name |
    
    Allow both models to coexist in a plugin only behind separate classes and registration paths. Do not make Atomic feature flags a prerequisite for a classic widget.
    
    ## Workflow
    
    ### 1. Gate the companion plugin before loading widget classes
    
    1. Declare `Requires Plugins: elementor` in the plugin header on supported WordPress versions.
    2. Run compatibility checks after plugins load. Verify `did_action( 'elementor/loaded' )`, `ELEMENTOR_VERSION`, and the addon's actual PHP minimum.
    3. Do not include a file that extends `Widget_Base` until Elementor is loaded; otherwise a missing/inactive Elementor causes a fatal before a notice can run.
    4. Register callbacks only when requirements pass. Keep Pro optional unless the widget genuinely extends a Pro-only API.
    
    Choose and document a real minimum Elementor version. The modern widget registration contract used here is stable since 3.5.0; a tested-up-to value is not a minimum-version claim.
    
    ### 2. Register, do not instantiate early
    
    Hook the manager and pass a widget instance:
    
    ```php
    add_action(
        'elementor/widgets/register',
        static function ( \Elementor\Widgets_Manager $widgets_manager ): void {
            require_once __DIR__ . '/includes/class-example-widget.php';
            $widgets_manager->register( new Example_Widget() );
        }
    );
    ```
    
    - Use `elementor/widgets/register`; `elementor/widgets/widgets_registered` is deprecated since 3.5.0.
    - Give `get_name()` a stable, globally unique, prefixed lowercase identifier. It becomes `widgetType`, is persisted in Elementor JSON, participates in CSS classes, and selects the frontend-ready hook. Renaming it breaks existing content.
    - Register an optional category on `elementor/elements/categories_registered` with `$elements_manager->add_category()`. Keep a fallback category such as `general`; a category is organization, not authorization.
    - Never unregister or overwrite another widget merely to resolve a name collision.
    
    ### 3. Implement the smallest correct widget contract
    
    Implement these methods deliberately:
    
    ```php
    public function get_name(): string;
    public function get_title(): string;
    public function get_icon(): string;
    public function get_categories(): array;
    public function get_keywords(): array;
    protected function register_controls(): void;
    protected function render(): void;
    ```
    
    `get_icon()`, categories, and keywords have base defaults, but explicit metadata makes a public widget discoverable and predictable. Translate human-facing strings; do not translate identifiers, control IDs, script handles, or category slugs.
    
    Use `register_controls()`, never deprecated `_register_controls()`. Delegate the control array and value-shape work to **`elementor-v3-widget-controls`**.
    
    ### 4. Make PHP rendering canonical and safe
    
    1. Read display values with `$this->get_settings_for_display()`. It applies active-control conditions and dynamic-tag parsing; `get_settings()` is raw saved/default data. Process shortcodes only through an explicit renderer such as `parse_text_editor()` or `do_shortcode()` when the widget intentionally supports them.
    2. Validate enumerations again at output time. Saved Elementor JSON, REST/import operations, filters, and dynamic tags can bypass the editor's option list.
    3. Escape at the final output context: `esc_html()`, `wp_kses_post()`, `esc_url()`, or an explicit `wp_kses()` allowlist. Control registration is not an output sanitizer.
    4. Build attributes through `add_render_attribute()` and `print_render_attribute_string()`. Build URL-control links through `add_link_attributes()`.
    5. Use `add_inline_editing_attributes()` only on text nodes intended for editor editing. For repeaters, derive a unique key with `get_repeater_setting_key()`.
    6. Return early for empty optional content rather than emitting empty semantic elements.
    7. Emit valid semantic HTML and accessible names/states. Do not use a clickable `div` where a button or link is required.
    
    Treat `render()` as the source of truth. Add `content_template()` only when immediate Backbone-based editor preview is worth maintaining, then keep its structure, conditions, attributes, and escaping intent in parity with PHP. Never move authorization or sensitive lookup logic into the JS template.
    
    Override `render_plain_content()` when the default rendered HTML is unsuitable for WordPress search, SEO extraction, feeds, or Elementor deactivation. Return meaningful plain content, a shortcode when appropriate, or an empty string for functionality that must not survive deactivation.
    
    ### 5. Register assets once and declare dependencies
    
    Register handles on a WordPress enqueue hook; do not enqueue globally and do not register them on every `render()` call:
    
    ```php
    add_action( 'wp_enqueue_scripts', static function (): void {
        wp_register_style( 'acme-example-widget', plugins_url( 'assets/widget.css', __FILE__ ), [], '1.0.0' );
        wp_register_script( 'acme-example-widget', plugins_url( 'assets/widget.js', __FILE__ ), [ 'elementor-frontend' ], '1.0.0', true );
    } );
    ```
    
    Return registered handles from `get_style_depends()` / `get_script_depends()`. Elementor then loads them for pages containing the widget, including the preview iframe. Use `elementor/editor/before_enqueue_scripts` or `.../after_enqueue_scripts` only for code that belongs to the editor panel itself.
    
    For interactive widgets, initialize each instance from:
    
    ```js
    jQuery( window ).on( 'elementor/frontend/init', () => {
        elementorFrontend.hooks.addAction(
            'frontend/element_ready/acme-example.default',
            ( $scope ) => { /* initialize only inside $scope */ }
        );
    } );
    ```
    
    - Make initialization idempotent; editor rerenders can fire the hook repeatedly.
    - Scope queries and event teardown to the current `$scope`.
    - Use the exact `get_name()` plus `.default`; skins use their own suffix.
    - Do not initialize solely on DOM ready: that misses editor rerenders and dynamically inserted elements.
    
    ### 6. Decide output caching from runtime behavior
    
    The base returns `true` from `is_dynamic_content()`, so output is not declared cacheable. Override it to `false` **only** when output is stable for all users/requests and fully determined by cache-safe settings/dependencies:
    
    ```php
    protected function is_dynamic_content(): bool {
        return false;
    }
    ```
    
    Keep the default `true` when rendering depends on the current user, cookies/session, request, time, randomness, stock/entitlement state, uncached remote data, or mutable external state. Elementor separately detects configured dynamic tags, but that does not prove arbitrary PHP logic is static.
    
    For inner-wrapper compatibility and icon rendering, apply **`elementor-experiments-and-markup`**. Do not assume `.elementor-widget-container` exists on core widgets, and render `ICONS` values through `Icons_Manager::render_icon()`.
    
    ## Critical rules
    
    - Keep classic V3 and Atomic/V4 classes, controls, styles, and registration paths separate.
    - Load a `Widget_Base` subclass only after Elementor is available.
    - Use the modern manager hook and a stable, prefixed `get_name()`.
    - Treat PHP `render()` as canonical and escape every value for its output context.
    - Register asset handles once; let widget dependency methods control loading.
    - Initialize frontend JS through the widget-ready hook and make it idempotent.
    - Return `false` from `is_dynamic_content()` only after proving cross-user output stability.
    - Test both editor preview and published frontend; they exercise different render and asset paths.
    
    ## Review checks
    
    - Bootstrap: inactive/old Elementor produces no fatal and a useful admin state.
    - Registration: one unique widget appears exactly once in its expected category.
    - Persistence: existing instances survive plugin upgrades because names/control IDs remain stable.
    - Rendering: empty, default, rich text, link, media, responsive, repeater, and dynamic-tag states are safe.
    - Assets: absent on pages without the widget; present once on pages with one or many instances.
    - JS: works after editor rerender and does not duplicate listeners.
    - Compatibility: free-only and Pro-active installations; optimized markup on/off where relevant.
    - Performance: no unbounded query in registration/render and no false static-cache declaration.
    
    ## Cross-references
    
    - Run **`elementor-v3-widget-controls`** for built-in control schemas, values, selectors, conditions, and repeaters.
    - Run **`elementor-experiments-and-markup`** when rendering icons or depending on wrapper markup.
    - Run **`elementor-deprecations`** while upgrading an older addon or reviewing legacy hooks/methods.
    
    ## What this skill does NOT cover
    
    - Atomic Widgets / Editor V4 implementation.
    - Custom Elementor control classes and control-manager registration.
    - Pro-only Forms fields, Theme Builder conditions, nested-element internals, skins, or documents.
    - Business-specific authorization, query, REST, or data-storage design beyond the widget boundary.
    
    ## References
    
    - Detailed bootstrap, widget example, frontend handler, and test matrix: `references/widget-contract-and-example.md`.
    - Official widget documentation: <https://developers.elementor.com/docs/widgets/>
    - Official compatibility checks: <https://developers.elementor.com/docs/addons/compatibility/>
    - Official widget dependencies: <https://developers.elementor.com/docs/widgets/widget-dependencies/>
    - Official output caching: <https://developers.elementor.com/docs/widgets/widget-output-caching/>
    - Verified Elementor Free 4.2.3 source paths:
      - `elementor.php`
      - `includes/managers/widgets.php`
      - `includes/managers/elements.php`
      - `includes/base/widget-base.php`
      - `includes/base/element-base.php`
      - `includes/base/controls-stack.php`
      - `includes/widgets/heading.php`
      - `assets/js/frontend.js`
    - Atomic/V4 boundary verified in `modules/atomic-widgets/` and `modules/atomic-widgets/elements/base/atomic-widget-base.php`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related