Skip to content
wp-skills
Register
tools/wordpress-widget-generator

WordPress Widget Generator

Build a WP_Widget class with its form, its save routine and its front end — fields sanitized per type, settings names built by get_field_name() so they post where update() reads them, and the flag that makes the block widget editor show a real preview.

What the widgets screen calls it.

Not a label: every instance is stored in widget_{this}, and changing it later orphans widgets already placed in a sidebar.

Goes into the theme's wrapper markup. Blank derives it from the ID base.

Rendered through the theme's before_title / after_title markup and the widget_title filter, the way every core widget does it.

What lets the block widget editor show a real preview instead of a placeholder. Every core widget sets it.

Re-renders just this widget on change rather than reloading the whole preview.

A comment showing both ways to render it — the_widget() takes a different before_widget from a sidebar, and getting it wrong is a fatal on PHP 8.

Fields1
  • #1

    Lowercase letters, numbers and underscores. This is the key inside the saved instance.

snippet.php
<?php
/**
 * Widget: My Custom Widget
 *
 * Generated by wp-skills.com
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * A classic widget, which the block widget editor still shows.
 *
 * WordPress 5.8 replaced the widgets screen with a block editor, and the usual
 * conclusion is that WP_Widget is dead. It is not: a widget registered this way
 * appears in /wp/v2/widget-types and is offered in the editor as a Legacy
 * Widget block, alongside every one of core's own — verified on WordPress 7.1.
 */
class WP_Skills_Widget_My_Custom_Widget extends WP_Widget {

	/**
	 * Every setting this widget stores, with the type that renders and
	 * sanitizes it.
	 *
	 * Held in one place because three separate passes read it — form(),
	 * update() and widget() — and a key that exists in only two of them is a
	 * field that renders and never saves.
	 */
	private $fields = array(
		array(
			'id'      => 'title',
			'label'   => 'Title',
			'type'    => 'text',
			'default' => '',
		),
		array(
			'id'      => 'message',
			'label'   => 'Message',
			'type'    => 'textarea',
			'default' => '',
		)
	);

	public function __construct() {
		/*
		 * The first argument is the $id_base, and it is not a label: the
		 * option row every instance is saved in is 'widget_' . $id_base, each
		 * instance's id is "$id_base-2", and get_field_name() renders it into
		 * the name attribute of every input below. Changing it later orphans
		 * every widget already placed in a sidebar.
		 */
		parent::__construct(
			'my_custom_widget',
			__( 'My Custom Widget', 'text-domain' ),
			array(
				'classname'                   => 'widget_my_custom_widget',
				'description'                 => __( 'Lorem ipsum dolor sit amet.', 'text-domain' ),
				'customize_selective_refresh' => true,
				'show_instance_in_rest'       => true,
			)
		);
	}

	/**
	 * The front end.
	 *
	 * $args comes from register_sidebar() — before_widget and after_widget are
	 * the theme's wrapper, already built. Echoing them is not optional: a widget
	 * that skips them loses the theme's markup and the id the sidebar gave it.
	 */
	public function widget( $args, $instance ) {
		$instance = wp_parse_args( (array) $instance, $this->defaults() );

		echo $args['before_widget'];

		/*
		 * The title goes through 'before_title'/'after_title' rather than into
		 * the body: that is where the theme's markup for it lives, and where
		 * every core widget puts it. The 'widget_title' filter is what plugins
		 * hook to translate or decorate it.
		 */
		$title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );

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

		?>
			<div class="widget_my_custom_widget__message"><?php echo wpautop( esc_html( $instance['message'] ) ); ?></div>
		<?php
		echo $args['after_widget'];
	}

	/**
	 * The form on the widgets screen.
	 *
	 * get_field_id() and get_field_name() are what tie an input to this
	 * instance: they render as widget-my_custom_widget-2-key and
	 * widget-my_custom_widget[2][key]. Hand-writing either one means the
	 * value posts under a name update() never sees.
	 */
	public function form( $instance ) {
		$instance = wp_parse_args( (array) $instance, $this->defaults() );

		foreach ( $this->fields as $field ) {
			$value = $instance[ $field['id'] ];

			echo '<p>';
			printf(
				'<label for="%1$s">%2$s</label>',
				esc_attr( $this->get_field_id( $field['id'] ) ),
				esc_html( $field['label'] )
			);
			$this->render_field( $field, $value );
			echo '</p>';
		}
	}

	private function render_field( $field, $value ) {
		switch ( $field['type'] ) {
			case 'textarea':
				printf(
					'<textarea class="widefat" id="%1$s" name="%2$s" rows="4">%3$s</textarea>',
					esc_attr( $this->get_field_id( $field['id'] ) ),
					esc_attr( $this->get_field_name( $field['id'] ) ),
					esc_textarea( $value )
				);
				break;

			default:
				printf(
					'<input class="widefat" id="%1$s" name="%2$s" type="%3$s" value="%4$s">',
					esc_attr( $this->get_field_id( $field['id'] ) ),
					esc_attr( $this->get_field_name( $field['id'] ) ),
					esc_attr( $field['type'] ),
					esc_attr( $value )
				);
		}
	}

	/**
	 * Saves the instance.
	 *
	 * Whatever this returns is stored verbatim, so it is the only place the
	 * values are constrained. Returning $new_instance untouched — the shortest
	 * thing that works — stores whatever was posted.
	 */
	public function update( $new_instance, $old_instance ) {
		$instance = $this->defaults();

		foreach ( $this->fields as $field ) {
			$key = $field['id'];

			// An unchecked box posts nothing at all, so its absence is the
			// value. Skipping it would leave the previous '1' in place forever.
			if ( 'checkbox' === $field['type'] ) {
				$instance[ $key ] = empty( $new_instance[ $key ] ) ? '0' : '1';
				continue;
			}

			if ( ! isset( $new_instance[ $key ] ) ) {
				continue;
			}

			$instance[ $key ] = $this->sanitize_field( $field, $new_instance[ $key ] );
		}

		return $instance;
	}

	private function sanitize_field( $field, $value ) {
		switch ( $field['type'] ) {
			case 'textarea':
				return sanitize_textarea_field( $value );

			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Every field's default, keyed the way the instance stores them.
	 *
	 * An instance saved before a field was added simply has no value for it, so
	 * widget() and form() both merge through here rather than indexing into an
	 * array that may not have the key.
	 */
	private function defaults() {
		$defaults = array();

		foreach ( $this->fields as $field ) {
			$defaults[ $field['id'] ] = $field['default'];
		}

		return $defaults;
	}
}

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

/*
 * Two ways to render this widget, and they do not take the same arguments.
 *
 * In a sidebar, WordPress does the work — drop the widget into a widget area
 * and the theme's dynamic_sidebar() call renders it. 'before_widget' there gets
 * two sprintf arguments, the instance id and the class name, which is why
 * register_sidebar() defaults it to '<li id="%1$s" class="widget %2$s">'.
 *
 * Directly in a template, the_widget() renders it without a sidebar — but it
 * builds 'before_widget' with only ONE argument, the class name. Handing it the
 * sidebar-shaped string above is an ArgumentCountError, which is a fatal on
 * PHP 8. Note the single %s:
 *
 *     the_widget(
 *         'WP_Skills_Widget_My_Custom_Widget',
 *         array( 'title' => 'From a template' ),
 *         array(
 *             'before_widget' => '<section class="widget %s">',
 *             'after_widget'  => '</section>',
 *             'before_title'  => '<h2 class="widget-title">',
 *             'after_title'   => '</h2>',
 *         )
 *     );
 */