WP_Widget Class Generator

Generate a WP_Widget subclass with its form, update and widget methods, correctly escaped, plus the registration.

Live output

Enable JavaScript to customise; default output below.

Stored with every placed instance. Renaming it orphans the widgets already on the site.

Live preview widget.php
<?php
/**
 * The "Recent Items" widget.
 */

defined( 'ABSPATH' ) || exit;

/**
 * A classic widget. Since WordPress 5.8 it also appears in the block widget
 * screen through the legacy widget block, so it keeps working either way.
 */
class My_Plugin_Recent_Widget extends WP_Widget {

	/**
	 * Registers the widget with its ID base, name and description.
	 */
	public function __construct() {
		parent::__construct(
			'my_plugin_recent',
			__( 'Recent Items', 'my-plugin' ),
			array(
				'description' => __( 'A short list of the most recent items.', 'my-plugin' ),
				'classname'   => 'my_plugin_recent',
			)
		);
	}

	/**
	 * The defaults, used before the widget has been saved once.
	 *
	 * @return array
	 */
	protected function defaults() {
		return array(
			'title' => 'Recent Items',
			'count' => '5',
		);
	}

	/**
	 * The form in the widgets screen.
	 *
	 * @param array $instance Saved settings.
	 */
	public function form( $instance ) {
		$instance = wp_parse_args( (array) $instance, $this->defaults() );

		printf(
			'<p><label for="%1$s">%2$s</label>',
			esc_attr( $this->get_field_id( 'title' ) ),
			esc_html__( 'Title', 'my-plugin' )
		);
		printf(
			'<input class="widefat" type="text" id="%1$s" name="%2$s" value="%3$s" /></p>',
			esc_attr( $this->get_field_id( 'title' ) ),
			esc_attr( $this->get_field_name( 'title' ) ),
			esc_attr( $instance['title'] )
		);

		printf(
			'<p><label for="%1$s">%2$s</label>',
			esc_attr( $this->get_field_id( 'count' ) ),
			esc_html__( 'How many', 'my-plugin' )
		);
		printf(
			'<input class="tiny-text" type="number" step="1" min="0" id="%1$s" name="%2$s" value="%3$s" /></p>',
			esc_attr( $this->get_field_id( 'count' ) ),
			esc_attr( $this->get_field_name( 'count' ) ),
			esc_attr( $instance['count'] )
		);
	}

	/**
	 * Cleans the submitted settings.
	 *
	 * Starting from the defaults means a key you never declared cannot be
	 * saved, whatever the form posts.
	 *
	 * @param array $new_instance Submitted values.
	 * @param array $old_instance Previously saved values.
	 * @return array
	 */
	public function update( $new_instance, $old_instance ) {
		$instance = $this->defaults();

		$instance['title'] = isset( $new_instance['title'] )
			? sanitize_text_field( $new_instance['title'] )
			: '';

		$instance['count'] = isset( $new_instance['count'] )
			? (string) absint( $new_instance['count'] )
			: '';

		return $instance;
	}

	/**
	 * Renders the widget on the front end.
	 *
	 * @param array $args     Sidebar markup from register_sidebar().
	 * @param array $instance Saved settings.
	 */
	public function widget( $args, $instance ) {
		$instance = wp_parse_args( (array) $instance, $this->defaults() );

		echo wp_kses_post( $args['before_widget'] );

		$title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );

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

		$items = get_posts(
			array(
				'post_type'      => 'post',
				'posts_per_page' => absint( $instance['count'] ),
				'post_status'    => 'publish',
				'no_found_rows'  => true,
			)
		);

		if ( $items ) {
			echo '<ul>';

			foreach ( $items as $item ) {
				printf(
					'<li><a href="%1$s">%2$s</a></li>',
					esc_url( get_permalink( $item ) ),
					esc_html( get_the_title( $item ) )
				);
			}

			echo '</ul>';
		} else {
			echo '<p>' . esc_html__( 'Nothing to show yet.', 'my-plugin' ) . '</p>';
		}

		echo wp_kses_post( $args['after_widget'] );
	}
}

/**
 * Registers the widget.
 */
function my_plugin_register_widget() {
	register_widget( 'My_Plugin_Recent_Widget' );
}
add_action( 'widgets_init', 'my_plugin_register_widget' );

Output is valid and updates as you type.

Generate a WP_Widget subclass with the three methods it needs, each doing its own job: form() prints the settings, update() cleans them, and widget() renders the front end.

How to use

  1. Pick the ID base carefully. It is stored with every placed instance, so renaming it later leaves those widgets orphaned in the database.
  2. Keep a setting called title. WordPress and most themes expect it, and it gets the theme’s before_title markup automatically.
  3. Let update() start from the defaults. Building the saved array from your own declared keys means a key you never defined cannot be stored.
  4. Escape in widget(), not in update(). Storing escaped values means double escaping the day you output them somewhere else.
  5. Register on widgets_init with register_widget() and the class name.

Example

The part that catches people out is the sidebar markup:

public function widget( $args, $instance ) {
	echo wp_kses_post( $args['before_widget'] );

	$title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );

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

	echo wp_kses_post( $args['after_widget'] );
}

$args['before_widget'] and its partner come from register_sidebar(). Skipping them means the widget loses the theme’s wrapper, its ID and its classes.

Pitfalls

  • Forgetting before_widget and after_widget drops the per-widget ID and classes, which is why “my widget CSS does not apply” is such a common report.
  • update() receives raw input. Sanitizing there and escaping in widget() is the split; doing both in one place leads to double-escaped output.
  • get_field_id() and get_field_name() exist because WordPress renumbers instances. Hardcoded names break the second copy of the widget.
  • An unchecked checkbox posts nothing, so update() has to treat a missing key as off rather than as unchanged.
  • The ID base is part of the stored option. Changing it after launch leaves the old widgets in the sidebar pointing at a class that no longer claims them.
  • Since WordPress 5.8 the widgets screen is block based, and classic widgets appear through the legacy widget block. They still work; they just look different in the admin.
  • apply_filters( 'widget_title', ... ) is what lets other plugins adjust the title. Skipping it quietly breaks those integrations.
  • A query inside widget() runs on every page the sidebar appears on. Keep it small and set no_found_rows.

Compatibility

WP_Widget has been the widget base class since WordPress 2.8, and register_widget() on widgets_init since then. The block widgets screen arrived in 5.8 and renders classic widgets through the legacy widget block; show_instance_in_rest controls whether an instance is exposed there. wp_parse_args() and the field helper methods have been stable throughout. The generated code targets PHP 7.0 and up, and the tool runs entirely in your browser.

Frequently asked questions

Should I still write classic widgets?
For a site that uses the classic widgets screen, or a plugin that has to support one, yes. A block is the native path for a new block theme.
Why is my widget missing its wrapper?
$args['before_widget'] and $args['after_widget'] were not echoed. They carry the theme’s markup.
Why do two copies of the widget fight?
Field names were hardcoded rather than built with get_field_name(), so both instances post to the same key.
Where are widget settings stored?
In an option named widget_<id_base>, keyed by instance number. That is why the ID base matters.
How do I convert this to a block?
Rewrite the render as a dynamic block and register the settings as block attributes. The logic moves across; the form does not.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.