Build a Custom WordPress Widget with the WP_Widget Class and Block Fallback

WordPress classic widgets extend the WP_Widget base class and must implement four methods: __construct() registers the widget ID, name, and description; widget() outputs the frontend HTML given the current sidebar $args and the saved $instance values; form() renders the admin form fields inside the Widgets screen using $this->get_field_id() and $this->get_field_name() to generate unique field names; and update() sanitizes and returns the new instance values when the form is saved. The widget is registered on the widgets_init hook with register_widget() and must be registered every request — not just on activation. The $before_widget, $after_widget, $before_title, and $after_title variables from $args must be echoed around the widget output to respect the active theme’s sidebar HTML structure. Since WordPress 5.8 the Block Widgets Editor replaces the classic Widgets screen by default, and classic widgets are wrapped in a Legacy Widget block — to ensure full compatibility with both the classic and block widget editors, the widget should also provide a block alternative registered with register_block_type() that renders the same output. A simpler approach for new projects is to register a block directly and skip the WP_Widget class entirely — but maintaining a WP_Widget class ensures compatibility with themes that use dynamic_sidebar() to render specific sidebars programmatically. Sanitizing widget data in update() uses sanitize_text_field() for text, absint() for integers, and wp_kses_post() for HTML fields that accept limited markup — never store raw $_POST values in widget instances. The dynamic block post shows the block-first approach that supersedes classic widgets for new development.

Problem: A theme sidebar needs a custom "Recent Posts by Category" widget that shows a configurable number of posts from a selected category — the built-in Recent Posts widget cannot filter by category.

Solution: Extend WP_Widget to implement widget(), form(), and update() methods that render posts from a chosen category, display admin fields for category selection and post count, and sanitize all saved values with appropriate sanitization functions.

class Myplugin_Category_Posts_Widget extends WP_Widget {

    public function __construct() {
        parent::__construct(
            'myplugin_category_posts',           // widget ID base
            __('Posts by Category', 'myplugin'), // widget name
            ['description' => __('Shows recent posts from a selected category.', 'myplugin')]
        );
    }

    /** Frontend output */
    public function widget($args, $instance): void {
        $cat_id = absint($instance['category'] ?? 0);
        $number = absint($instance['number']   ?? 5);
        $title  = sanitize_text_field($instance['title'] ?? '');

        $query = new WP_Query([
            'cat'            => $cat_id ?: null,
            'posts_per_page' => $number,
            'no_found_rows'  => true,
        ]);

        if (!$query->have_posts()) return;

        echo $args['before_widget'];

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

        echo '
    '; while ($query->have_posts()) { $query->the_post(); printf( '
  • %s
  • ', esc_url(get_permalink()), esc_html(get_the_title()) ); } wp_reset_postdata(); echo '
'; echo $args['after_widget']; } /** Admin form */ public function form($instance): void { $title = sanitize_text_field($instance['title'] ?? ''); $cat_id = absint($instance['category'] ?? 0); $number = absint($instance['number'] ?? 5); ?>

$this->get_field_name('category'), 'id' => $this->get_field_id('category'), 'selected' => $cat_id, 'show_option_all' => __('— All categories —', 'myplugin'), 'class' => 'widefat', 'hide_empty' => false, ]); ?>

sanitize_text_field($new['title'] ?? ''), 'category' => absint($new['category'] ?? 0), 'number' => min(20, max(1, absint($new['number'] ?? 5))), ]; } } add_action('widgets_init', function() { register_widget('Myplugin_Category_Posts_Widget'); });

NOTE: Always call wp_reset_postdata() after a custom WP_Query loop inside a widget — omitting it corrupts the global $post object and causes subsequent template tags like get_the_title() or get_permalink() to return data from the wrong post in other widgets or template parts rendered after yours.