How to Add a Custom Widget in WordPress

Building a custom widget using the WP_Widget class

WordPress widgets let you drop reusable blocks of content into any sidebar or footer area. While the default set covers most common cases, sometimes you need a widget that does exactly one specific thing. Building a custom widget with the WP_Widget class is straightforward.

Problem: How do you create a reusable custom widget in WordPress that can be placed in any registered sidebar?

Solution: The example below creates a simple "Latest Posts by Category" widget:

class My_Posts_Widget extends WP_Widget {

    public function __construct() {
        parent::__construct(
            'my_posts_widget',
            __( 'Posts by Category', 'textdomain' ),
            [ 'description' => __( 'Displays latest posts from a chosen category.', 'textdomain' ) ]
        );
    }

    // Front-end output
    public function widget( $args, $instance ) {
        $cat_id = ! empty( $instance['cat_id'] ) ? absint( $instance['cat_id'] ) : 0;
        $count  = ! empty( $instance['count'] )  ? absint( $instance['count'] )  : 5;

        echo $args['before_widget'];
        if ( ! empty( $instance['title'] ) ) {
            echo $args['before_title'] . esc_html( $instance['title'] ) . $args['after_title'];
        }

        $posts = new WP_Query( [
            'cat'            => $cat_id,
            'posts_per_page' => $count,
            'no_found_rows'  => true,
        ] );

        if ( $posts->have_posts() ) {
            echo '<ul>';
            while ( $posts->have_posts() ) {
                $posts->the_post();
                echo '<li><a href="' . esc_url( get_permalink() ) . '">' . esc_html( get_the_title() ) . '</a></li>';
            }
            echo '</ul>';
            wp_reset_postdata();
        }

        echo $args['after_widget'];
    }

    // Back-end form
    public function form( $instance ) {
        $title  = $instance['title']  ?? '';
        $cat_id = $instance['cat_id'] ?? '';
        $count  = $instance['count']  ?? 5;
        ?>
        <p>
            <label><?php esc_html_e( 'Title:', 'textdomain' ); ?>
                <input class="widefat" name="<?php echo $this->get_field_name( 'title' ); ?>"
                       value="<?php echo esc_attr( $title ); ?>">
            </label>
        </p>
        <p>
            <label><?php esc_html_e( 'Category ID:', 'textdomain' ); ?>
                <input class="widefat" name="<?php echo $this->get_field_name( 'cat_id' ); ?>"
                       value="<?php echo esc_attr( $cat_id ); ?>">
            </label>
        </p>
        <p>
            <label><?php esc_html_e( 'Number of posts:', 'textdomain' ); ?>
                <input class="tiny-text" name="<?php echo $this->get_field_name( 'count' ); ?>"
                       value="<?php echo absint( $count ); ?>" type="number" min="1" max="20">
            </label>
        </p>
        <?php
    }

    // Save widget settings
    public function update( $new, $old ) {
        return [
            'title'  => sanitize_text_field( $new['title'] ),
            'cat_id' => absint( $new['cat_id'] ),
            'count'  => absint( $new['count'] ),
        ];
    }
}

add_action( 'widgets_init', function() {
    register_widget( 'My_Posts_Widget' );
} );

NOTE: Always sanitize input in update() and escape output in widget(). Call wp_reset_postdata() after a custom WP_Query inside a widget to restore the global post object.