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
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/elementor/elementor-v3-widget-development
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
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(), orrender_plain_content(). - Add widget frontend JavaScript through
frontend/element_ready/{widget-name}.default. - Decide whether
is_dynamic_content()may returnfalse. - Diagnose a widget visible in PHP but missing/broken in the editor or frontend.
- Migrate
_register_controls()orelementor/widgets/widgets_registeredto 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
- Declare
Requires Plugins: elementorin the plugin header on supported WordPress versions. - Run compatibility checks after plugins load. Verify
did_action( 'elementor/loaded' ),ELEMENTOR_VERSION, and the addon's actual PHP minimum. - Do not include a file that extends
Widget_Baseuntil Elementor is loaded; otherwise a missing/inactive Elementor causes a fatal before a notice can run. - 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_registeredis deprecated since 3.5.0. - Give
get_name()a stable, globally unique, prefixed lowercase identifier. It becomeswidgetType, 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_registeredwith$elements_manager->add_category(). Keep a fallback category such asgeneral; 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
- 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 asparse_text_editor()ordo_shortcode()when the widget intentionally supports them. - Validate enumerations again at output time. Saved Elementor JSON, REST/import operations, filters, and dynamic tags can bypass the editor's option list.
- Escape at the final output context:
esc_html(),wp_kses_post(),esc_url(), or an explicitwp_kses()allowlist. Control registration is not an output sanitizer. - Build attributes through
add_render_attribute()andprint_render_attribute_string(). Build URL-control links throughadd_link_attributes(). - Use
add_inline_editing_attributes()only on text nodes intended for editor editing. For repeaters, derive a unique key withget_repeater_setting_key(). - Return early for empty optional content rather than emitting empty semantic elements.
- Emit valid semantic HTML and accessible names/states. Do not use a clickable
divwhere 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_Basesubclass 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
falsefromis_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-controlsfor built-in control schemas, values, selectors, conditions, and repeaters. - Run
elementor-experiments-and-markupwhen rendering icons or depending on wrapper markup. - Run
elementor-deprecationswhile 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.phpincludes/managers/widgets.phpincludes/managers/elements.phpincludes/base/widget-base.phpincludes/base/element-base.phpincludes/base/controls-stack.phpincludes/widgets/heading.phpassets/js/frontend.js
- Atomic/V4 boundary verified in
modules/atomic-widgets/andmodules/atomic-widgets/elements/base/atomic-widget-base.php.
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.
Reviews (0)
No reviews yet.
No comments yet.