
🛠️ Custom Elementor widgets: full cycle from plugin to controls
The theme update broke the custom block that displayed promotions on the homepage. Sound familiar? Code lives in functions.php, styles are smeared across style.css, and the JS handler is nailed to footer.php. Switch themes or do a major Elementor update, and you have to reassemble all of this from scratch.
The problem isn't that "the code is bad." The problem is that it lives in the wrong place. Custom modifications crammed into the theme are tied to it permanently: transferring to another site is impossible, debugging conflicts is painful, and conditional loading of assets is nonexistent.
The proper approach is to package the custom widget as a separate plugin. That's exactly what the official Elementor documentation recommends for any nontrivial customizations. Code isolation, theme independence, proper versioning, and JS loading only on pages with the widget. In this guide, the complete cycle: from plugin structure to a live widget with inline editing, on the current Elementor API (3.x/4.x).

💡 Quick overview:
- Register a separate plugin for the widget, isolate code from the theme once and for all.
- Build the main class with compatibility checks: Elementor active, version no lower than 3.5, PHP from 7.4.
- Create the widget class: extend
Widget_Base, defineregister_controls()and the render method. - Wire up inline text editing through
add_inline_editing_attributes()and a Backbone template. - Add custom fields: image picker from media library, dropdowns, button groups, typography.
- Hook the JavaScript handler to
elementor/frontend/init, the entry point for any client logic.
Plugin structure and main class
We'll create the Elementor Awesomesauce plugin. Minimal file structure:
1 elementor-awesomesauce/ 2 ├── elementor-awesomesauce.php ← entry point 3 ├── plugin.php ← plugin singleton class 4 ├── widgets/ 5 │ └── awesomesauce.php ← widget class 6 └── assets/ 7 └── js/ 8 └── awesomesauce.js ← frontend JS handler
Main plugin file, standard WordPress header plus a loader class with compatibility checks. This is exactly the structure described in the Elementor first addon guide. The code below works in Elementor 3.5+ and Elementor 4.x: methods without the deprecated _ prefix, constants for modern PHP versions.
Add this code to elementor-awesomesauce.php. The file should be in /wp-content/plugins/elementor-awesomesauce/. Before activating, make a full site backup.
1 <?php 2 /** 3 * Plugin Name: Elementor Awesomesauce 4 * Description: Custom Elementor widget with live editing of text, images and styles. 5 * Plugin URI: https://techblog.sdstudio.top/ 6 * Version: 1.0.0 7 * Author: TechBlog SD Studio 8 * Text Domain: elementor-awesomesauce 9 */ 10 11 if ( ! defined( 'ABSPATH' ) ) exit; 12 13 final class Elementor_Awesomesauce { 14 15 const VERSION = '1.0.0'; 16 const MINIMUM_ELEMENTOR_VERSION = '3.5.0'; 17 const MINIMUM_PHP_VERSION = '7.4'; 18 19 public function __construct() { 20 add_action( 'init', array( $this, 'i18n' ) ); 21 add_action( 'plugins_loaded', array( $this, 'init' ) ); 22 } 23 24 public function i18n() { 25 load_plugin_textdomain( 'elementor-awesomesauce' ); 26 } 27 28 public function init() { 29 if ( ! did_action( 'elementor/loaded' ) ) { 30 add_action( 'admin_notices', array( $this, 'admin_notice_missing_main_plugin' ) ); 31 return; 32 } 33 34 if ( ! version_compare( ELEMENTOR_VERSION, self::MINIMUM_ELEMENTOR_VERSION, '>=' ) ) { 35 add_action( 'admin_notices', array( $this, 'admin_notice_minimum_elementor_version' ) ); 36 return; 37 } 38 39 if ( version_compare( PHP_VERSION, self::MINIMUM_PHP_VERSION, '<' ) ) { 40 add_action( 'admin_notices', array( $this, 'admin_notice_minimum_php_version' ) ); 41 return; 42 } 43 44 require_once( 'plugin.php' ); 45 } 46 47 public function admin_notice_missing_main_plugin() { 48 if ( isset( $_GET['activate'] ) ) { 49 unset( $_GET['activate'] ); 50 } 51 $message = sprintf( 52 esc_html__( '"%1$s" requires "%2$s" to be installed and activated.', 'elementor-awesomesauce' ), 53 '<strong>' . esc_html__( 'Elementor Awesomesauce', 'elementor-awesomesauce' ) . '</strong>', 54 '<strong>' . esc_html__( 'Elementor', 'elementor-awesomesauce' ) . '</strong>' 55 ); 56 printf( '<p>%1$s</p>', $message ); 57 } 58 59 public function admin_notice_minimum_elementor_version() { 60 if ( isset( $_GET['activate'] ) ) { 61 unset( $_GET['activate'] ); 62 } 63 $message = sprintf( 64 esc_html__( '"%1$s" requires "%2$s" version %3$s or greater.', 'elementor-awesomesauce' ), 65 '<strong>' . esc_html__( 'Elementor Awesomesauce', 'elementor-awesomesauce' ) . '</strong>', 66 '<strong>' . esc_html__( 'Elementor', 'elementor-awesomesauce' ) . '</strong>', 67 self::MINIMUM_ELEMENTOR_VERSION 68 ); 69 printf( '<p>%1$s</p>', $message ); 70 } 71 72 public function admin_notice_minimum_php_version() { 73 if ( isset( $_GET['activate'] ) ) { 74 unset( $_GET['activate'] ); 75 } 76 $message = sprintf( 77 esc_html__( '"%1$s" requires "%2$s" version %3$s or greater.', 'elementor-awesomesauce' ), 78 '<strong>' . esc_html__( 'Elementor Awesomesauce', 'elementor-awesomesauce' ) . '</strong>', 79 '<strong>' . esc_html__( 'PHP', 'elementor-awesomesauce' ) . '</strong>', 80 self::MINIMUM_PHP_VERSION 81 ); 82 printf( '<p>%1$s</p>', $message ); 83 } 84 } 85 86 new Elementor_Awesomesauce();
What's important here. The MINIMUM_ELEMENTOR_VERSION constant, 3.5.0. Starting with this version, new conventions for hook and method naming without the _ prefix came into effect. For Elementor 4.x (current version as of June 2026) this same code works without changes, the widget API in 4.x wasn't broken. PHP, minimum 7.4, but in practice 8.x is already the de facto standard. The class is declared final: this is the entry point, no need to inherit from it.
Plugin class: singleton and widget registration
File plugin.php, a singleton that loads JS assets and registers the widget through the elementor/widgets/register hook. This is the main change compared to old guides: the elementor/widgets/widgets_registered hook was declared deprecated since version 3.5.0, the register_widget_type() method was replaced with register().
1 <?php 2 namespace ElementorAwesomesauce; 3 4 use Elementor\Plugin as ElementorPlugin; 5 6 class Plugin { 7 8 private static $_instance = null; 9 10 public static function instance() { 11 if ( is_null( self::$_instance ) ) { 12 self::$_instance = new self(); 13 } 14 return self::$_instance; 15 } 16 17 public function widget_scripts() { 18 wp_register_script( 19 'elementor-awesomesauce', 20 plugins_url( '/assets/js/awesomesauce.js', __FILE__ ), 21 [ 'jquery' ], 22 false, 23 true 24 ); 25 } 26 27 private function include_widgets_files() { 28 require_once( __DIR__ . '/widgets/awesomesauce.php' ); 29 } 30 31 public function register_widgets( $widgets_manager ) { 32 $this->include_widgets_files(); 33 $widgets_manager->register( new \ElementorAwesomesauceWidgets\Awesomesauce() ); 34 } 35 36 public function __construct() { 37 add_action( 'elementor/frontend/after_register_scripts', [ $this, 'widget_scripts' ] ); 38 add_action( 'elementor/widgets/register', [ $this, 'register_widgets' ] ); 39 } 40 } 41 42 Plugin::instance();
Note: register_widgets accepts a $widgets_manager parameter. In the new hook, the manager is passed directly, not retrieved through ElementorPlugin::instance()->widgets_manager. Cleaner and without the extra import.
Widget class: inheritance, controls and render
File widgets/awesomesauce.php, the heart of the plugin. Extend Widget_Base, define name, title, icon, and category. The register_controls() method adds three text fields: single line, multiline, and WYSIWYG. The render() method outputs markup on the frontend, and content_template() defines the Backbone template for live preview in the editor.
1 <?php 2 namespace ElementorAwesomesauceWidgets; 3 4 use Elementor\Widget_Base; 5 use Elementor\Controls_Manager; 6 7 if ( ! defined( 'ABSPATH' ) ) exit; 8 9 class Awesomesauce extends Widget_Base { 10 11 public function get_name() { 12 return 'awesomesauce'; 13 } 14 15 public function get_title() { 16 return __( 'Awesomesauce', 'elementor-awesomesauce' ); 17 } 18 19 public function get_icon() { 20 return 'eicon-pencil'; 21 } 22 23 public function get_categories() { 24 return [ 'general' ]; 25 } 26 27 public function get_keywords() { 28 return [ 'awesomesauce', 'custom', 'demo' ]; 29 } 30 31 protected function register_controls() { 32 $this->start_controls_section( 33 'section_content', 34 [ 35 'label' => __( 'Content', 'elementor-awesomesauce' ), 36 ] 37 ); 38 39 $this->add_control( 40 'title', 41 [ 42 'label' => __( 'Title', 'elementor-awesomesauce' ), 43 'type' => Controls_Manager::TEXT, 44 'default' => __( 'Title', 'elementor-awesomesauce' ), 45 ] 46 ); 47 48 $this->add_control( 49 'description', 50 [ 51 'label' => __( 'Description', 'elementor-awesomesauce' ), 52 'type' => Controls_Manager::TEXTAREA, 53 'default' => __( 'Description', 'elementor-awesomesauce' ), 54 ] 55 ); 56 57 $this->add_control( 58 'content', 59 [ 60 'label' => __( 'Content', 'elementor-awesomesauce' ), 61 'type' => Controls_Manager::WYSIWYG, 62 'default' => __( 'Content', 'elementor-awesomesauce' ), 63 ] 64 ); 65 66 $this->end_controls_section(); 67 } 68 69 protected function render() { 70 $settings = $this->get_settings_for_display(); 71 72 $this->add_inline_editing_attributes( 'title', 'none' ); 73 $this->add_inline_editing_attributes( 'description', 'basic' ); 74 $this->add_inline_editing_attributes( 'content', 'advanced' ); 75 ?> 76 <div class="elementor-awesomesauce"> 77 <h2 <?php $this->print_render_attribute_string( 'title' ); ?>> 78 <?php $this->print_unescaped_setting( 'title' ); ?> 79 </h2> 80 <div <?php $this->print_render_attribute_string( 'description' ); ?>> 81 <?php $this->print_unescaped_setting( 'description' ); ?> 82 </div> 83 <div <?php $this->print_render_attribute_string( 'content' ); ?>> 84 <?php $this->print_unescaped_setting( 'content' ); ?> 85 </div> 86 </div> 87 <?php 88 } 89 90 protected function content_template() { 91 ?> 92 <# 93 view.addInlineEditingAttributes( 'title', 'none' ); 94 view.addInlineEditingAttributes( 'description', 'basic' ); 95 view.addInlineEditingAttributes( 'content', 'advanced' ); 96 #> 97 <div class="elementor-awesomesauce"> 98 <h2 {{{ view.getRenderAttributeString( 'title' ) }}}>{{{ settings.title }}}</h2> 99 <div {{{ view.getRenderAttributeString( 'description' ) }}}>{{{ settings.description }}}</div> 100 <div {{{ view.getRenderAttributeString( 'content' ) }}}>{{{ settings.content }}}</div> 101 </div> 102 <?php 103 } 104 }
What changed relative to outdated guides. Methods register_controls() and content_template() are written without the _ prefix, this is a change since Elementor 3.1. For output in render(), use print_render_attribute_string() and print_unescaped_setting() instead of direct echo, a modern approach recommended since Elementor 3.x and working in 4.x. Icon replaced from fa fa-pencil (Font Awesome 4, removed from core) to eicon-pencil from Elementor's native set.
Custom fields: media, selects, typography
Text fields don't limit the possibilities. We'll cover four control types that handle most real scenarios.
Media field
Controls_Manager::MEDIA adds standard image selection from the WordPress media library. The Utils::get_placeholder_image_src() method works in current versions and provides a gray placeholder if no image is selected.
1 $this->add_control( 2 'mask_image', 3 [ 4 'label' => __( 'Mask Image', 'elementor-awesomesauce' ), 5 'type' => Controls_Manager::MEDIA, 6 'default' => [ 7 'url' => \Elementor\Utils::get_placeholder_image_src(), 8 ], 9 ] 10 );
Dropdown list
Controls_Manager::SELECT, choice from predefined values. Below is an example for heading HTML tag:
1 $this->add_control( 2 'title_tag', 3 [ 4 'label' => __( 'Title HTML Tag', 'elementor-awesomesauce' ), 5 'type' => Controls_Manager::SELECT, 6 'default' => 'h2', 7 'options' => [ 8 'h1' => 'H1', 9 'h2' => 'H2', 10 'h3' => 'H3', 11 'h4' => 'H4', 12 ], 13 ] 14 );
Button group
Controls_Manager::CHOOSE displays a row of icons for visual selection. Icons, only from the eicon-* set, not fa fa-*:
1 $this->add_control( 2 'text_align', 3 [ 4 'label' => __( 'Alignment', 'elementor-awesomesauce' ), 5 'type' => Controls_Manager::CHOOSE, 6 'options' => [ 7 'left' => [ 8 'title' => __( 'Left', 'elementor-awesomesauce' ), 9 'icon' => 'eicon-text-align-left', 10 ], 11 'center' => [ 12 'title' => __( 'Center', 'elementor-awesomesauce' ), 13 'icon' => 'eicon-text-align-center', 14 ], 15 'right' => [ 16 'title' => __( 'Right', 'elementor-awesomesauce' ), 17 'icon' => 'eicon-text-align-right', 18 ], 19 ], 20 'default' => 'center', 21 'toggle' => true, 22 ] 23 );
Typography through Group Control
The typography group control provides the full set: font, size, letter spacing, weight, all with responsive breakpoints. More details in the Group_Control_Typography documentation. Key point: Scheme_Typography was deprecated since Elementor 3.x, don't use it. Instead, either link to global styles or omit the scheme key entirely.
1 use Elementor\Group_Control_Typography; 2 3 $this->add_group_control( 4 Group_Control_Typography::get_type(), 5 [ 6 'name' => 'content_typography', 7 'label' => __( 'Typography', 'elementor-awesomesauce' ), 8 'selector' => '{{WRAPPER}} .elementor-awesomesauce', 9 'fields_options' => [ 10 'letter_spacing' => [ 11 'range' => [ 12 'min' => 0, 13 'max' => 100, 14 ], 15 ], 16 ], 17 ] 18 );
Why a plugin and not functions.php
A custom widget in the theme works. But only until you switch themes. In a separate plugin you get three important advantages:
- Conditional loading. Elementor calls
widget_scriptsonly when the widget is actually displayed on the page, not across the entire site. For projects with dozens of widgets, this is a noticeable savings in HTTP requests. - Isolation. PHP logic, CSS, and JS don't mix with the theme. Finding and fixing a bug takes minutes, not hours of digging through 2000 lines in
functions.php. - Portability. Activate the plugin on another site, the widget works. No copy-paste and manual path editing.

Video: live demonstration from empty plugin to working widget
Theory is good, but watching code in action is faster. In this 30-minute guide, the author goes through the entire process: from an empty folder to a widget with controls and render.
⁉️🤔 Frequently asked questions
Why doesn't the widget appear in the Elementor panel?
First, check the registration hook. Since Elementor 3.5 it uses
elementor/widgets/register(notelementor/widgets/widgets_registered, it's deprecated). Second: theregister_controls()method must be without the_prefix. Third:get_categories()must return an array with an existing category,'general'always works. Fourth: clear the WordPress cache after activating the plugin.
What's the difference between print_render_attribute_string() and direct echo in render()?
print_render_attribute_string()automatically applies attribute filters, including inline editing and Elementor data attributes. Directechowon't provide this data, inline editing simply won't activate. For outputting setting values, useprint_unescaped_setting(), it correctly handles escaping inside controls.
Can you get by without a separate JS file?
Yes, if the widget purely renders PHP markup without interactivity. But as soon as sliders, animations, AJAX loading, or any client dynamics appear, JS is mandatory. Even a minimal handler (like in the example above) provides an entry point for future logic, without requiring rewriting the registration later.
Is a namespace mandatory in the plugin?
Formally no. But without a namespace you risk getting class name conflicts with another plugin or theme, names like
PluginorWidgetare far from unique. For production, mandatory. The prefixElementorAwesomesauceWidgetspractically guarantees uniqueness.
How to update an old widget written according to outdated guides?
The migration plan is described in the official Elementor deprecations guide: (1) rename methods with the
_prefix,_register_controls()→register_controls(),_content_template()→content_template(); (2) replace the hook withelementor/widgets/register, and the registration method with$manager->register(); (3) replacefa fa-*icons witheicon-*, removeScheme_Typography. After edits, bumpMINIMUM_ELEMENTOR_VERSIONto current and test the widget on three levels: editor panel, live preview, frontend.
What to do with old widgets: migration plan to the current API
If you already have custom widgets written according to 2019-2021 guides, don't panic. Elementor maintains backward compatibility with deprecation handlers up to 8 major versions. But it's better to update the code now, before deprecation notices turn into fatal errors.
Migration checklist:
- **Methods without **
_: everywhere you see_register_controlsand_content_template, remove the prefix. - Registration hook:
elementor/widgets/widgets_registered→elementor/widgets/register. - Registration method:
$manager->register_widget_type()→$manager->register(). - Icons:
fa fa-*→eicon-*(Elementor native set) ordashicons-*(WordPress set). - Typography:
Scheme_Typography→ either global styles through'global' => [...], or direct values without theschemekey.
After changes, test the widget on three levels: does the controls panel open in the editor, does live preview work (Backbone template), does the frontend render without errors. And remember about dry-run before deploying to prod: Elementor silently skips broken controls without crashing the entire page, so visual verification is mandatory.
Old code in functions.php or plugin from scratch: what to choose
If you're starting a new project, only a plugin. There are no arguments left "for" functions.php: even for a micro-widget of 20 lines, the plugin structure pays off with the first theme update.
If you already have working code in the theme, extract it into a plugin at the next refactoring. The process is straightforward: create a folder and main plugin file according to the structure above, move the widget class to widgets/, set up registration through elementor/widgets/register, and test on staging. In practice, this takes 15-20 minutes for a typical widget.
Start with the base class from this guide, copy the main file and plugin.php as a skeleton, replace the widget name with your own. And when questions arise, check the Widgets section on developers.elementor.com: it covers media rendering, working with repeater fields, and optimizing output.



