11 min read

How to Create Custom WordPress Widgets Without a Plugin

By Jonas Lindemann

Updated

The Harbor call box widget's settings form in the WordPress Widgets screen next to the same widget in a site footer with a title, opening hours and a phone link

This tutorial builds a custom widget with PHP, without a plugin: a call box for a plumbing company's website with a title, opening hours and a phone link. It also adds a widget area of your own and shows how to use the same widget in a block theme, which has no widget areas.

All code was tested on WordPress 7.1.3 with the classic theme Twenty Twenty-One 2.9 and the block theme Twenty Twenty-Five.

Does your theme support widgets?

Widgets only show in widget areas, and only classic themes have them. A classic theme registers its areas with register_sidebar() in PHP. Block themes such as Twenty Twenty-Five build every part of the page from blocks instead, so WordPress has nowhere to put a widget, and the Widgets item is missing from the Appearance menu:

The Appearance menu with Twenty Twenty-Five, showing only Themes, Editor and Fonts, next to the Appearance menu with Twenty Twenty-One, which includes Widgets

Opening wp-admin/widgets.php directly with Twenty Twenty-Five shows "The theme you are currently using is not widget-aware, meaning that it has no sidebars that you are able to change." If that's your situation, you can still build the widget and show it through a shortcode, as described in "Use the widget in a block theme" below. For something new that only needs to work in block themes, a custom block is the better fit.

Where to put the code

Save the widget in a file in wp-content/mu-plugins/ (create the folder if it doesn't exist). WordPress loads every PHP file directly in that folder on every request, and the widget keeps working when you switch themes. You can also put the code in your theme's functions.php, but then the widget disappears with the theme. Our tutorial on building a custom login page explains both places in more detail.

The snippets below go into wp-content/mu-plugins/harbor-widgets.php. The complete file is further down.

Step 1: Create the widget class

A widget is a PHP class that extends WordPress's WP_Widget class and has four methods:

php
1class Harbor_Call_Box_Widget extends WP_Widget {
2
3    public function __construct() {
4        // The widget's ID, name and description
5    }
6
7    public function widget( $args, $instance ) {
8        // The HTML visitors see
9    }
10
11    public function form( $instance ) {
12        // The settings form in the Widgets screen
13    }
14
15    public function update( $new_instance, $old_instance ) {
16        // Clean the settings before WordPress saves them
17    }
18}

The next four sections fill in one method each.

The constructor: ID, name and description

php
1public function __construct() {
2    parent::__construct(
3        'harbor_call_box', // ID base: lowercase and unique
4        'Harbor call box', // the name in the block inserter
5        array(
6            'classname'   => 'harbor-call-box',
7            'description' => 'Phone number and opening hours with a call link.',
8        )
9    );
10}

parent::__construct() takes the ID base, the name readers see in the Widgets screen, and an array of options. The ID base names the database option the settings are stored in (widget_harbor_call_box), so give it a prefix of your own. classname becomes a CSS class on the widget's HTML, and description shows in the classic Widgets screen.

widget(): the HTML visitors see

php
1public function widget( $args, $instance ) {
2    $title = apply_filters( 'widget_title', $instance['title'] ?? '', $instance, $this->id_base );
3    $phone = $instance['phone'] ?? '';
4    $hours = $instance['hours'] ?? '';
5
6    echo $args['before_widget'];
7    if ( $title ) {
8        echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
9    }
10    if ( $hours ) {
11        echo '<p class="harbor-call-box__hours">' . nl2br( esc_html( $hours ) ) . '</p>';
12    }
13    if ( $phone ) {
14        $tel = preg_replace( '/[^0-9+]/', '', $phone );
15        echo '<p class="harbor-call-box__phone"><a href="' . esc_url( 'tel:' . $tel ) . '">' . esc_html( $phone ) . '</a></p>';
16    }
17    echo $args['after_widget'];
18}

$args comes from the widget area. It holds the HTML the theme wraps around every widget (before_widget, after_widget) and around its title (before_title, after_title), so your widget matches the theme's other widgets. $instance holds the saved settings.

Every saved value goes through esc_html() or esc_url() before it's printed. The opening hours keep their line breaks through nl2br(), and the phone link drops everything except digits and + from the number. Fields that are empty print nothing: in our test, a widget with an empty title had no heading.

form(): the settings form

php
1public function form( $instance ) {
2    $title = $instance['title'] ?? 'Call us';
3    $phone = $instance['phone'] ?? '';
4    $hours = $instance['hours'] ?? '';
5    ?>
6    <p>
7        <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">Title:</label>
8        <input class="widefat" type="text"
9            id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
10            name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
11            value="<?php echo esc_attr( $title ); ?>">
12    </p>
13    <p>
14        <label for="<?php echo esc_attr( $this->get_field_id( 'phone' ) ); ?>">Phone number:</label>
15        <input class="widefat" type="tel"
16            id="<?php echo esc_attr( $this->get_field_id( 'phone' ) ); ?>"
17            name="<?php echo esc_attr( $this->get_field_name( 'phone' ) ); ?>"
18            value="<?php echo esc_attr( $phone ); ?>">
19    </p>
20    <p>
21        <label for="<?php echo esc_attr( $this->get_field_id( 'hours' ) ); ?>">Opening hours:</label>
22        <textarea class="widefat" rows="3"
23            id="<?php echo esc_attr( $this->get_field_id( 'hours' ) ); ?>"
24            name="<?php echo esc_attr( $this->get_field_name( 'hours' ) ); ?>"><?php echo esc_textarea( $hours ); ?></textarea>
25    </p>
26    <?php
27}

get_field_id() and get_field_name() give each field the ID and name WordPress expects, so several copies of the widget can each keep their own settings. esc_attr() and esc_textarea() put the saved values back into the fields safely.

update(): clean the settings before they're saved

php
1public function update( $new_instance, $old_instance ) {
2    $instance          = array();
3    $instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' );
4    $instance['phone'] = sanitize_text_field( $new_instance['phone'] ?? '' );
5    $instance['hours'] = sanitize_textarea_field( $new_instance['hours'] ?? '' );
6    return $instance;
7}

WordPress saves whatever this method returns. sanitize_text_field() strips HTML tags and line breaks, and sanitize_textarea_field() does the same but keeps line breaks. In our test, a title entered as <b>Call</b> us was saved as "Call us", and a <script> tag in the opening hours was removed while both lines stayed.

Step 2: Register the widget

Below the class, tell WordPress about it on the widgets_init hook:

php
add_action( 'widgets_init', function () {
    register_widget( 'Harbor_Call_Box_Widget' );
} );

The complete file

wp-content/mu-plugins/harbor-widgets.phpphp
1<?php
2/**
3 * Plugin Name: Harbor Plumbing widgets
4 * Description: The Harbor call box widget.
5 */
6
7class Harbor_Call_Box_Widget extends WP_Widget {
8
9    public function __construct() {
10        parent::__construct(
11            'harbor_call_box', // ID base: lowercase and unique
12            'Harbor call box', // the name in the block inserter
13            array(
14                'classname'   => 'harbor-call-box',
15                'description' => 'Phone number and opening hours with a call link.',
16            )
17        );
18    }
19
20    public function widget( $args, $instance ) {
21        $title = apply_filters( 'widget_title', $instance['title'] ?? '', $instance, $this->id_base );
22        $phone = $instance['phone'] ?? '';
23        $hours = $instance['hours'] ?? '';
24
25        echo $args['before_widget'];
26        if ( $title ) {
27            echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
28        }
29        if ( $hours ) {
30            echo '<p class="harbor-call-box__hours">' . nl2br( esc_html( $hours ) ) . '</p>';
31        }
32        if ( $phone ) {
33            $tel = preg_replace( '/[^0-9+]/', '', $phone );
34            echo '<p class="harbor-call-box__phone"><a href="' . esc_url( 'tel:' . $tel ) . '">' . esc_html( $phone ) . '</a></p>';
35        }
36        echo $args['after_widget'];
37    }
38
39    public function form( $instance ) {
40        $title = $instance['title'] ?? 'Call us';
41        $phone = $instance['phone'] ?? '';
42        $hours = $instance['hours'] ?? '';
43        ?>
44        <p>
45            <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">Title:</label>
46            <input class="widefat" type="text"
47                id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
48                name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
49                value="<?php echo esc_attr( $title ); ?>">
50        </p>
51        <p>
52            <label for="<?php echo esc_attr( $this->get_field_id( 'phone' ) ); ?>">Phone number:</label>
53            <input class="widefat" type="tel"
54                id="<?php echo esc_attr( $this->get_field_id( 'phone' ) ); ?>"
55                name="<?php echo esc_attr( $this->get_field_name( 'phone' ) ); ?>"
56                value="<?php echo esc_attr( $phone ); ?>">
57        </p>
58        <p>
59            <label for="<?php echo esc_attr( $this->get_field_id( 'hours' ) ); ?>">Opening hours:</label>
60            <textarea class="widefat" rows="3"
61                id="<?php echo esc_attr( $this->get_field_id( 'hours' ) ); ?>"
62                name="<?php echo esc_attr( $this->get_field_name( 'hours' ) ); ?>"><?php echo esc_textarea( $hours ); ?></textarea>
63        </p>
64        <?php
65    }
66
67    public function update( $new_instance, $old_instance ) {
68        $instance          = array();
69        $instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' );
70        $instance['phone'] = sanitize_text_field( $new_instance['phone'] ?? '' );
71        $instance['hours'] = sanitize_textarea_field( $new_instance['hours'] ?? '' );
72        return $instance;
73    }
74}
75
76add_action( 'widgets_init', function () {
77    register_widget( 'Harbor_Call_Box_Widget' );
78} );

Check that the file shows up under Plugins > Must-Use.

Step 3: Add the widget in Appearance > Widgets

Since WordPress 5.8, the Widgets screen works like the block editor. Widgets built on WP_Widget are still there: WordPress lists them in the block inserter by their name and adds them as a Legacy Widget block.

  1. Go to Appearance > Widgets.
  2. Click the block in the widget area where the widget should go, for example Footer in Twenty Twenty-One.
  3. Click the blue + button at the top left and search for the widget's name.
  4. Click Harbor call box to add it.
  5. Fill in the form and click Update.
The block inserter in the Widgets screen with Harbor typed into the search field and the Harbor call box widget highlighted

The block shows the form from your form() method. The title starts with "Call us", the default set in form():

The Footer widget area in the Widgets screen with a heading block, a paragraph block and the highlighted Harbor call box widget form with Title, Phone number and Opening hours filled in

Step 4: Check the widget on the site

Open any page in a private window. In Twenty Twenty-One, the Footer widget area sits above the footer on every page, and the call box shows with the theme's widget title style:

The footer widget area of the Harbor Plumbing site with three columns: the site name, a short description and the Call us widget with opening hours and a linked phone number

Add your own widget area to a classic theme

If the theme's widget areas aren't where you want the widget, you can register one yourself with register_sidebar() and show it with dynamic_sidebar(). Do this in a child theme, so a theme update doesn't remove it. This child theme of Twenty Twenty-One adds an area called Above footer:

wp-content/themes/harbor-child/style.csscss
/*
Theme Name: Harbor Child
Template: twentytwentyone
*/
wp-content/themes/harbor-child/functions.phpphp
1<?php
2add_action( 'widgets_init', function () {
3    register_sidebar(
4        array(
5            'name'          => 'Above footer',
6            'id'            => 'harbor-above-footer',
7            'description'   => 'Shown on every page, above the footer widgets.',
8            'before_widget' => '<section id="%1$s" class="widget %2$s">',
9            'after_widget'  => '</section>',
10            'before_title'  => '<h2 class="widget-title">',
11            'after_title'   => '</h2>',
12        )
13    );
14} );

id must be unique and lowercase. The before_ and after_ values are what your widget receives in $args. Here they copy Twenty Twenty-One's own footer area, so widgets look the same in both.

register_sidebar() only makes the area show up in the Widgets screen. To print it on the page, call dynamic_sidebar() in a template file. Twenty Twenty-One prints its footer area in template-parts/footer/footer-widgets.php, so the child theme overrides that file and adds the new area first:

wp-content/themes/harbor-child/template-parts/footer/footer-widgets.phpphp
1<?php
2if ( is_active_sidebar( 'harbor-above-footer' ) ) : ?>
3    <aside class="widget-area harbor-above-footer" aria-label="Above footer">
4        <?php dynamic_sidebar( 'harbor-above-footer' ); ?>
5    </aside>
6<?php endif;
7
8if ( is_active_sidebar( 'sidebar-1' ) ) : ?>
9    <aside class="widget-area">
10        <?php dynamic_sidebar( 'sidebar-1' ); ?>
11    </aside>
12<?php endif;

After activating the child theme under Appearance > Themes, the Widgets screen lists both areas:

The Widgets screen with the new Above footer widget area highlighted, containing the Call us widget, and the Footer area below it

is_active_sidebar() skips the area's HTML when it has no widgets. In our test, with the call box moved to Above footer, the page showed it in its own row above the footer area, and the other widgets stayed in the footer area.

Use the widget in a block theme

Block themes have no widget areas, but the_widget() prints any registered widget wherever you call it, with the settings you pass. Wrapped in a shortcode, the call box works in a Shortcode block anywhere in a block theme. Add this to the same mu-plugin file:

php
1add_shortcode( 'harbor_call_box', function () {
2    ob_start();
3    the_widget(
4        'Harbor_Call_Box_Widget',
5        array(
6            'title' => 'Call us',
7            'phone' => '(503) 555-0142',
8            'hours' => "Monday to Friday, 7 am to 6 pm\nBurst pipes and leaks: 24/7",
9        ),
10        array(
11            'before_title' => '<h2 class="wp-block-heading">',
12            'after_title'  => '</h2>',
13        )
14    );
15    return ob_get_clean();
16} );

the_widget() prints its output, so ob_start() and ob_get_clean() catch it and the shortcode returns it. Our shortcode tutorial explains why a shortcode has to return its HTML.

To show it on every page, open Appearance > Editor, edit the footer template part, add a Shortcode block and type [harbor_call_box]. In our test with Twenty Twenty-Five, the call box appeared above the theme's footer:

The footer of the Twenty Twenty-Five theme with the Call us widget, its opening hours and phone link above the Harbor Plumbing site name and footer links

The settings are now in the code, so changing the phone number means editing the file. If editors should change it in the editor, build a custom block with its own settings instead.

Bring back the classic Widgets screen

If you prefer the old Widgets screen with the Available Widgets list, add this line to your mu-plugin file:

php
add_filter( 'use_widgets_block_editor', '__return_false' );

In our test with Twenty Twenty-One, Appearance > Widgets then showed the classic screen, with Harbor call box under Available Widgets.

The Widgets menu is missing

The active theme is a block theme or registers no widget areas. Check under Appearance > Themes which theme is active. You can switch to a classic theme, add a widget area as shown above if the theme is classic, or show the widget through a shortcode.

Looking for an Elementor widget?

Elementor widgets are a different thing with their own API. Our tutorial on building a custom Elementor widget covers those.

Further reading

The WordPress developer reference documents the WP_Widget class, register_sidebar() and the_widget().

About the Author

I've worked in SEO for more than ten years and helped over 100 businesses rank higher, mostly local businesses, e-commerce stores and SaaS companies. As co-founder of LPagery I work on local SEO, programmatic SEO and AI search visibility, and I test the strategies in these articles on our own sites and with our customers.