Skip to content

Everything for WordPress, web development — and beyond

🛠 How to create a widget plugin for WordPress: a step-by-step guide

🛠 How to create a widget plugin for WordPress: a step-by-step guide

💡 How to build a WordPress widget plugin

  • Create a my-widget-plugin folder inside wp-content/plugins/ and a my-widget-plugin.php file with the plugin header, then activate it in the admin panel
  • Declare a class that extends WP_Widget and override the __construct(), form(), update(), and widget() methods
  • Register the widget with the register_widget() function on the widgets_init hook so that WordPress can see it in the list
  • In the form() method, render the settings fields; in update(), sanitize and save data via sanitize_text_field()
  • In the widget() method, output content on the front end, escaping values with esc_html() and wp_kses_post()

Step 1: creating the plugin scaffold

A widget in WordPress is not just a line in functions.php. If you want settings to persist, the admin form to work, and the widget itself to survive the next theme update, you need to package it as a plugin. This isolates the code and makes the widget independent of any theme change.

Start with an empty folder. Navigate to wp-content/plugins/ and create a directory called my-widget-plugin. Inside it, create a file named my-widget-plugin.php. This is the file WordPress will read first on activation. Open the file and add the standard plugin header:

1<?php
2/*
3Plugin Name: My Widget Plugin
4Plugin URI: https://www.wpexplorer.com/create-widget-plugin-wordpress/
5Description: Adds a customizable widget with text, textarea, checkbox, and dropdown.
6Version: 1.0
7Author: AJ Clarke
8Author URI: https://www.wpexplorer.com/
9License: GPL2
10*/

Save the file. Now go to the WordPress admin panel → Plugins. If the plugin appears in the list, the scaffold is ready. Click "Activate." It does not do anything useful yet; we have only announced its existence. But WordPress already knows this plugin exists and is ready to execute its code. This is an important principle: registration first, logic second.

How the plugin works internally, which hooks fire on activation, and how WordPress locates your file are all covered in the video above. Now let's move on to the most interesting part: the widget class.

Step 2: registering the widget via WP_Widget

WordPress provides a built-in class called WP_Widget; it has been part of the core since version 2.8 and remains the foundation for any custom widgets. You do not need to write saving logic, field generation, or registration from scratch: just extend the class and override four methods.

Add this code to your my-widget-plugin.php right after the plugin header, before the closing ?>:

1// Widget class
2class My_Custom_Widget extends WP_Widget {
3
4 public function __construct() {
5 parent::__construct(
6 'my_custom_widget',
7 __( 'My Custom Widget', 'text_domain' ),
8 array(
9 'customize_selective_refresh' => true,
10 )
11 );
12 }
13
14 public function form( $instance ) {
15 /* ... admin form ... */
16 }
17
18 public function update( $new_instance, $old_instance ) {
19 /* ... saving settings ... */
20 }
21
22 public function widget( $args, $instance ) {
23 /* ... frontend output ... */
24 }
25}
26
27// Widget registration
28function my_register_custom_widget() {
29 register_widget( 'My_Custom_Widget' );
30}
31add_action( 'widgets_init', 'my_register_custom_widget' );

Let's break down what is happening here. The class My_Custom_Widget extends WP_Widget, which gives you ready-made methods like get_field_id() and get_field_name() for generating form field attributes. In the constructor we pass three things to the parent class: a unique widget ID (lowercase Latin characters, no spaces, my_custom_widget), its human-readable name (the __() function makes it translatable), and an options array. The customize_selective_refresh => true parameter allows the widget to refresh in the Customizer without reloading the entire page, a small detail that saves a lot of frustration during setup.

The function my_register_custom_widget() calls register_widget() on the widgets_init hook. This is how WordPress learns about your widget. Without this line, nothing will show up in the admin panel.

Now let's fill the form(), update(), and widget() methods with real logic.

Step 3: creating the widget form in the admin panel

The form is what the administrator sees when dragging the widget into a sidebar. It consists of fields: text inputs, dropdowns, checkboxes. Each field must be able to save its value and display the current one when reopened.

3.1. The form() function and input fields

Add this code to the form() method of your class. It creates five fields: a title, a text input, a textarea, a checkbox, and a dropdown.

1public function form( $instance ) {
2
3 $defaults = array(
4 'title' => '',
5 'text' => '',
6 'textarea' => '',
7 'checkbox' => '',
8 'select' => '',
9 );
10
11 $args = wp_parse_args( (array) $instance, $defaults );
12 $title = $args['title'];
13 $text = $args['text'];
14 $textarea = $args['textarea'];
15 $checkbox = $args['checkbox'];
16 $select = $args['select'];
17 ?>
18
19 <p>
20 <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
21 <?php _e( 'Widget Title', 'text_domain' ); ?>
22 </label>
23 <input class="widefat"
24 id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
25 name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
26 type="text"
27 value="<?php echo esc_attr( $title ); ?>" />
28 </p>
29
30 <p>
31 <label for="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>">
32 <?php _e( 'Text:', 'text_domain' ); ?>
33 </label>
34 <input class="widefat"
35 id="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>"
36 name="<?php echo esc_attr( $this->get_field_name( 'text' ) ); ?>"
37 type="text"
38 value="<?php echo esc_attr( $text ); ?>" />
39 </p>
40
41 <p>
42 <label for="<?php echo esc_attr( $this->get_field_id( 'textarea' ) ); ?>">
43 <?php _e( 'Textarea:', 'text_domain' ); ?>
44 </label>
45 <textarea class="widefat"
46 id="<?php echo esc_attr( $this->get_field_id( 'textarea' ) ); ?>"
47 name="<?php echo esc_attr( $this->get_field_name( 'textarea' ) ); ?>"><?php
48 echo wp_kses_post( $textarea );
49 ?></textarea>
50 </p>
51
52 <p>
53 <input id="<?php echo esc_attr( $this->get_field_id( 'checkbox' ) ); ?>"
54 name="<?php echo esc_attr( $this->get_field_name( 'checkbox' ) ); ?>"
55 type="checkbox"
56 value="1"
57 <?php checked( '1', $checkbox ); ?> />
58 <label for="<?php echo esc_attr( $this->get_field_id( 'checkbox' ) ); ?>">
59 <?php _e( 'Show additional block', 'text_domain' ); ?>
60 </label>
61 </p>
62
63 <p>
64 <label for="<?php echo $this->get_field_id( 'select' ); ?>">
65 <?php _e( 'Display variant', 'text_domain' ); ?>
66 </label>
67 <select name="<?php echo $this->get_field_name( 'select' ); ?>"
68 id="<?php echo $this->get_field_id( 'select' ); ?>"
69 class="widefat">
70 <?php
71 $options = array(
72 '' => __( '— Select —', 'text_domain' ),
73 'option_1' => __( 'Option 1', 'text_domain' ),
74 'option_2' => __( 'Option 2', 'text_domain' ),
75 'option_3' => __( 'Option 3', 'text_domain' ),
76 );
77 foreach ( $options as $key => $name ) {
78 printf(
79 '<option value="%s" %s>%s</option>',
80 esc_attr( $key ),
81 selected( $select, $key, false ),
82 esc_html( $name )
83 );
84 }
85 ?>
86 </select>
87 </p>
88
89<?php }

Pay attention to two things. First, we replaced the deprecated extract() function with direct array access. extract() has long been excluded from the WordPress coding standards; it creates variables named after array keys, which is unsafe and makes debugging harder. Second, every output value is passed through esc_attr(), wp_kses_post(), or esc_html(). This is not paranoia: data from the database can come from anywhere, and sanitization is mandatory.

3.2. The update() function for saving

The update() method is called when the "Save" button is clicked in the widget form. Its job is to validate each field and return a sanitized array for writing to the database.

1public function update( $new_instance, $old_instance ) {
2 $instance = $old_instance;
3
4 $instance['title'] = isset( $new_instance['title'] )
5 ? sanitize_text_field( $new_instance['title'] ) : '';
6 $instance['text'] = isset( $new_instance['text'] )
7 ? sanitize_text_field( $new_instance['text'] ) : '';
8 $instance['textarea'] = isset( $new_instance['textarea'] )
9 ? wp_kses_post( $new_instance['textarea'] ) : '';
10 $instance['checkbox'] = isset( $new_instance['checkbox'] ) ? 1 : false;
11 $instance['select'] = isset( $new_instance['select'] )
12 ? sanitize_text_field( $new_instance['select'] ) : '';
13
14 return $instance;
15}

Here we use sanitize_text_field() instead of wp_strip_all_tags() because it also normalizes whitespace, removes invisible control characters, and converts the string to safe UTF-8. This is a more thorough cleanup. wp_strip_all_tags() leaves "raw" text with all whitespace artifacts intact. For text form fields, always choose sanitize_text_field().

The textarea keeps wp_kses_post(): it allows basic HTML (links, bold text, lists) but strips scripts. The checkbox returns 1 or false, which is readable and unambiguous in the database.

Step 4: rendering the widget on the front end

The widget() function is what the visitor sees. It receives two parameters: $args (the widget wrapper, tags before and after the title and sidebar) and $instance (the saved settings for this particular instance).

1public function widget( $args, $instance ) {
2
3 $title = isset( $instance['title'] )
4 ? apply_filters( 'widget_title', $instance['title'] ) : '';
5 $text = isset( $instance['text'] ) ? $instance['text'] : '';
6 $textarea = isset( $instance['textarea'] ) ? $instance['textarea'] : '';
7 $select = isset( $instance['select'] ) ? $instance['select'] : '';
8 $checkbox = ! empty( $instance['checkbox'] ) ? $instance['checkbox'] : false;
9
10 echo $args['before_widget'];
11
12 echo '<div class="widget-text wp_widget_plugin_box">';
13
14 if ( $title ) {
15 echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
16 }
17
18 if ( $text ) {
19 echo '<p>' . esc_html( $text ) . '</p>';
20 }
21
22 if ( $textarea ) {
23 echo '<div class="widget-textarea">' . wp_kses_post( $textarea ) . '</div>';
24 }
25
26 if ( $select ) {
27 echo '<p class="widget-select">' . esc_html( $select ) . '</p>';
28 }
29
30 if ( $checkbox ) {
31 echo '<p class="widget-checkbox-result">' . esc_html__( 'Additional block activated', 'text_domain' ) . '</p>';
32 }
33
34 echo '</div>';
35
36 echo $args['after_widget'];
37}

The key point here: we replaced extract( $args ) with direct access via $args['before_widget']. The reason is the same: extract() is deprecated and unsafe. We also wrapped the output in esc_html() wherever plain text is expected (the title, the text string, the select value). The textarea is rendered through wp_kses_post(), so if the administrator inserted a link or bold text, they will be preserved.

The CSS class wp_widget_plugin_box lets you style the block from the theme's stylesheet. Feel free to rename it, just make sure the class is unique and does not conflict with theme classes.

Full plugin code

Let's put it all together. Here is the complete my-widget-plugin.php file, ready to copy and activate:

1<?php
2/*
3Plugin Name: My Widget Plugin
4Plugin URI: https://www.wpexplorer.com/create-widget-plugin-wordpress/
5Description: Adds a customizable widget with text, textarea, checkbox, and dropdown.
6Version: 1.0
7Author: AJ Clarke
8Author URI: https://www.wpexplorer.com/
9License: GPL2
10*/
11
12class My_Custom_Widget extends WP_Widget {
13
14 public function __construct() {
15 parent::__construct(
16 'my_custom_widget',
17 __( 'My Custom Widget', 'text_domain' ),
18 array( 'customize_selective_refresh' => true )
19 );
20 }
21
22 public function form( $instance ) {
23 $defaults = array(
24 'title' => '', 'text' => '', 'textarea' => '',
25 'checkbox' => '', 'select' => ''
26 );
27 $args = wp_parse_args( (array) $instance, $defaults );
28 $title = $args['title'];
29 $text = $args['text'];
30 $textarea = $args['textarea'];
31 $checkbox = $args['checkbox'];
32 $select = $args['select'];
33 ?>
34 <p>
35 <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
36 <?php _e( 'Widget Title', 'text_domain' ); ?>
37 </label>
38 <input class="widefat"
39 id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
40 name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
41 type="text" value="<?php echo esc_attr( $title ); ?>" />
42 </p>
43 <p>
44 <label for="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>">
45 <?php _e( 'Text:', 'text_domain' ); ?>
46 </label>
47 <input class="widefat"
48 id="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>"
49 name="<?php echo esc_attr( $this->get_field_name( 'text' ) ); ?>"
50 type="text" value="<?php echo esc_attr( $text ); ?>" />
51 </p>
52 <p>
53 <label for="<?php echo esc_attr( $this->get_field_id( 'textarea' ) ); ?>">
54 <?php _e( 'Textarea:', 'text_domain' ); ?>
55 </label>
56 <textarea class="widefat"
57 id="<?php echo esc_attr( $this->get_field_id( 'textarea' ) ); ?>"
58 name="<?php echo esc_attr( $this->get_field_name( 'textarea' ) ); ?>"><?php
59 echo wp_kses_post( $textarea );
60 ?></textarea>
61 </p>
62 <p>
63 <input id="<?php echo esc_attr( $this->get_field_id( 'checkbox' ) ); ?>"
64 name="<?php echo esc_attr( $this->get_field_name( 'checkbox' ) ); ?>"
65 type="checkbox" value="1" <?php checked( '1', $checkbox ); ?> />
66 <label for="<?php echo esc_attr( $this->get_field_id( 'checkbox' ) ); ?>">
67 <?php _e( 'Show additional block', 'text_domain' ); ?>
68 </label>
69 </p>
70 <p>
71 <label for="<?php echo $this->get_field_id( 'select' ); ?>">
72 <?php _e( 'Display variant', 'text_domain' ); ?>
73 </label>
74 <select name="<?php echo $this->get_field_name( 'select' ); ?>"
75 id="<?php echo $this->get_field_id( 'select' ); ?>" class="widefat">
76 <?php
77 $options = array(
78 '' => __( '— Select —', 'text_domain' ),
79 'option_1' => __( 'Option 1', 'text_domain' ),
80 'option_2' => __( 'Option 2', 'text_domain' ),
81 'option_3' => __( 'Option 3', 'text_domain' ),
82 );
83 foreach ( $options as $key => $name ) {
84 printf(
85 '<option value="%s" %s>%s</option>',
86 esc_attr( $key ),
87 selected( $select, $key, false ),
88 esc_html( $name )
89 );
90 }
91 ?>
92 </select>
93 </p>
94 <?php
95 }
96
97 public function update( $new_instance, $old_instance ) {
98 $instance = $old_instance;
99 $instance['title'] = isset( $new_instance['title'] )
100 ? sanitize_text_field( $new_instance['title'] ) : '';
101 $instance['text'] = isset( $new_instance['text'] )
102 ? sanitize_text_field( $new_instance['text'] ) : '';
103 $instance['textarea'] = isset( $new_instance['textarea'] )
104 ? wp_kses_post( $new_instance['textarea'] ) : '';
105 $instance['checkbox'] = isset( $new_instance['checkbox'] ) ? 1 : false;
106 $instance['select'] = isset( $new_instance['select'] )
107 ? sanitize_text_field( $new_instance['select'] ) : '';
108 return $instance;
109 }
110
111 public function widget( $args, $instance ) {
112 $title = isset( $instance['title'] )
113 ? apply_filters( 'widget_title', $instance['title'] ) : '';
114 $text = isset( $instance['text'] ) ? $instance['text'] : '';
115 $textarea = isset( $instance['textarea'] ) ? $instance['textarea'] : '';
116 $select = isset( $instance['select'] ) ? $instance['select'] : '';
117 $checkbox = ! empty( $instance['checkbox'] ) ? $instance['checkbox'] : false;
118
119 echo $args['before_widget'];
120 echo '<div class="widget-text wp_widget_plugin_box">';
121
122 if ( $title ) {
123 echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
124 }
125 if ( $text ) {
126 echo '<p>' . esc_html( $text ) . '</p>';
127 }
128 if ( $textarea ) {
129 echo '<div class="widget-textarea">' . wp_kses_post( $textarea ) . '</div>';
130 }
131 if ( $select ) {
132 echo '<p class="widget-select">' . esc_html( $select ) . '</p>';
133 }
134 if ( $checkbox ) {
135 echo '<p class="widget-checkbox-result">'
136 . esc_html__( 'Additional block activated', 'text_domain' ) . '</p>';
137 }
138
139 echo '</div>';
140 echo $args['after_widget'];
141 }
142}
143
144function my_register_custom_widget() {
145 register_widget( 'My_Custom_Widget' );
146}
147add_action( 'widgets_init', 'my_register_custom_widget' );

Place this file in wp-content/plugins/my-widget-plugin/, activate the plugin, and drag the widget into any sidebar via Appearance → Widgets. Fill in the fields, save, and check your site.

The finished code is also available on GitHub: wpexplorer/my-widget-plugin, where you can compare it with the original 2017 version and see exactly what we changed.

⁉️🤔 Frequently asked questions

Why put a widget in a plugin when you can just add code to the theme's functions.php?

Code in functions.php is tied to the active theme. Switch themes, and the widget disappears. A plugin works independently of the theme. On top of that, a plugin can be selectively activated on different sites, while theme code cannot. If the widget solves a business need (for example, displaying a subscription form with a specific layout), it belongs in a plugin.

Why is sanitize_text_field() better than wp_strip_all_tags()?

sanitize_text_field() does not just remove HTML tags; it also normalizes whitespace, strips invisible control characters, and converts the string to UTF-8. This is a more thorough cleanup. wp_strip_all_tags() leaves "raw" text with all whitespace artifacts intact. For text form fields, always choose sanitize_text_field().

Why did you replace extract() with direct array access?

The extract() function has been excluded from the WordPress coding standards since version 4.3. It creates variables named after array keys in the local scope; if a key matches an existing variable, you will overwrite it and end up with a hard-to-find bug. Direct access like $args['before_widget'] is readable and safe.

Do I need to support block widgets (Gutenberg)?

The classic WP_Widget works with block-based sidebars through backward compatibility: WordPress automatically wraps it in a Legacy Widget Block. This is sufficient for most scenarios. If you want to create truly native blocks, see the Block Editor Handbook, which has a separate API. But starting with WP_Widget is easier: the code is shorter, debugging is faster, and it works on all WordPress versions without additional compatibility plugins.

How do I debug a widget that does not appear in the admin panel?

Check three things. First: did the widgets_init hook fire? Add error_log( 'Widget registered' ) to the registration function and check the logs. Second: is there a fatal error in the constructor? Enable WP_DEBUG in wp-config.php. Third: does the class name in register_widget() match the name of the class extending WP_Widget? A single typo, and the widget will not show up.

What's next: from template to your own widget

We have built a working plugin with five field types. This is not a finished product; it is a scaffold. Take it as your foundation and adapt it to your needs. Need a subscription form widget? Replace the text fields with email and name fields, and add a mailing service API call inside widget(). Need a block with promotions and banners? Load images through the media uploader and render them in your markup. The mechanics are always the same: form() draws the fields, update() saves them, widget() renders the output.

Start small: copy the full code above, activate it on a test site, and experiment with the settings. Once you understand how data flows from the form to the front end, start adding your own fields. And if the widget crashes with a white screen, go back to step 2 and check the constructor.