Widgets and Sidebars

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A widget area (called a sidebar in the code, wherever it appears on the page) is a slot that a classic theme declares and the site owner fills with widgets: a search box, a list of recent posts, a paragraph of text, a menu. Widgets let administrators change the content around the main column without editing template files. This page is for classic and hybrid theme developers and for administrators who look after classic sites. It adds two widget areas and a "Latest books" widget to the Lumen Press classic theme lumen-classic, then checks the result on a running site with WP-CLI, the REST API and the rendered HTML.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WPCS 3.4.1 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2, in beta and due in December 2026, announces no change to widgets so far.

Widgets are a classic-theme feature. Block themes have no widget areas: the same jobs are done by template parts and patterns edited in the Site Editor (see Templates and Template Parts and Theme Patterns). The starting point for the theme is the skeleton of Themes Overview; template files and The Loop are covered in Classic Theme Anatomy and The Loop.

Widget areas, widgets and where they live

Concept What it is

Widget area (sidebar)

A named slot registered by the theme with register_sidebar() on the widgets_init action. The ID (lumen-sidebar) is what the database and the code use; the name is what the admin shows.

Widget type

A PHP class extending WP_Widget, registered with register_widget(). Its id_base (lumen_latest_books) identifies the type. Core ships the classic types (Archives, Calendar, Search, Text, Custom HTML, Recent Posts and so on) and, since 5.8, the block type.

Widget instance

One placed copy of a type with its own settings, identified as <id_base>-<number> (lumen_latest_books-1, block-7).

Block widget

An instance of the block widget type: its only setting is content, the serialized block markup. This is what the block-based widgets editor creates.

Inactive Widgets

The pseudo-area wp_inactive_widgets: instances removed from an area but kept with their settings.

Everything is stored in the options table, not in posts:

Option Content

sidebars_widgets

The assignment: an array of area ID to an ordered list of instance IDs, plus wp_inactive_widgets and an array_version key (3).

widget_<id_base>

One option per widget type holding every instance’s settings, keyed by number, plus a _multiwidget flag. Block widgets are in widget_block, the "Latest books" widget in widget_lumen_latest_books.

theme_mods_<theme>

When the theme is switched, the outgoing theme’s assignment is saved in its theme mod sidebars_widgets (with a time stamp). When the new theme is a block theme, its registered areas are saved in the theme mod wp_classic_sidebars.

Because the settings and the assignment are separate, removing an area or a widget type from the code never deletes settings. When retrieve_widgets() reconciles the assignment (on the Widgets screen and after a theme switch), it maps old areas onto registered ones and discards from the areas any instance whose type is no longer registered.

Version history

Version Change

2.2, 2.8

Widgets and register_sidebar() in core (2.2); the WP_Widget class API used today (2.8).

4.2

register_sidebar() without an id triggers a _doing_it_wrong() notice: generated IDs (sidebar-1, sidebar-2) change when other code registers areas first, and widgets end up in the wrong place.

4.3

The PHP 4 style constructor WP_Widget::WP_Widget() is deprecated; subclasses call parent::__construct().

5.6

before_sidebar and after_sidebar arguments wrap the whole area.

5.8

The block-based widgets editor on Appearance > Widgets and in the Customizer, the block widget type stored in widget_block, the Legacy Widget block, the widgets-block-editor theme support and the use_widgets_block_editor filter, the show_instance_in_rest widget option, and the REST endpoints /wp/v2/sidebars, /wp/v2/widgets and /wp/v2/widget-types.

5.9

The Widget Group block; the show_in_rest argument of register_sidebar().

6.2

After a switch from a classic theme to a block theme, the old areas stay registered so the Template Part block can import their widgets ("Import widget area").

Registering the widget areas of lumen-classic

lumen-classic gets two areas: a sidebar next to the main column and a footer area. The function below is appended to the theme’s functions.php (the rest of the file, lumen_classic_setup() and the enqueue function, is unchanged from Themes Overview). It also loads and registers the widget class shown later on this page.

/**
 * Registers the two widget areas of Lumen Classic and the "Latest books" widget.
 */
function lumen_classic_widgets_init() {
	$shared = array(
		'before_widget' => '<section id="%1$s" class="widget %2$s">',
		'after_widget'  => '</section>',
		'before_title'  => '<h2 class="widget-title">',
		'after_title'   => '</h2>',
	);

	register_sidebar(
		array_merge(
			$shared,
			array(
				'id'             => 'lumen-sidebar',
				'name'           => esc_html__( 'Book sidebar', 'lumen-classic' ),
				'description'    => esc_html__( 'Shown next to posts and the book catalogue.', 'lumen-classic' ),
				'before_sidebar' => '<aside id="%1$s" class="widget-area %2$s" aria-label="' . esc_attr__( 'Book sidebar', 'lumen-classic' ) . '">',
				'after_sidebar'  => '</aside>',
			)
		)
	);

	register_sidebar(
		array_merge(
			$shared,
			array(
				'id'          => 'lumen-footer',
				'name'        => esc_html__( 'Footer', 'lumen-classic' ),
				'description' => esc_html__( 'Shown at the bottom of every page.', 'lumen-classic' ),
			)
		)
	);

	require_once get_template_directory() . '/inc/class-lumen-classic-latest-books-widget.php';
	register_widget( 'Lumen_Classic_Latest_Books_Widget' );
}
add_action( 'widgets_init', 'lumen_classic_widgets_init' );

The arguments of register_sidebar() in 7.1:

Argument Default Meaning

id

sidebar-<n>

Unique, lowercase, no spaces. Always set it; it is the key in sidebars_widgets, and changing it later orphans the widgets placed in the area.

name

Sidebar <n>

Label on the Widgets screen, in the Customizer and in wp sidebar list.

description

empty

Help text shown under the name.

class

empty

Extra class for the area in the admin; also passed to before_sidebar as %2$s.

before_widget, after_widget

<li id="%1$s" class="widget %2$s">, </li>

Wrapper of every widget. %1$s receives the instance ID, %2$s the type’s classname. The <li> default assumes the theme prints the area inside a <ul>; lumen-classic uses <section> instead.

before_title, after_title

<h2 class="widgettitle">, </h2>

Wrapper of a widget title. Classic widgets use it; block widgets do not, unless they sit in a Widget Group block.

before_sidebar, after_sidebar

empty

Wrapper of the whole area (5.6+), printed only when the area has widgets. %1$s receives the area ID, %2$s its class.

show_in_rest

false

Whether the area is listed by the REST API to users who cannot edit theme options (5.9+). Administrators always see it.

The markup arguments are printed by dynamic_sidebar() exactly as given, without escaping, so they must be trusted strings written by the theme. Anything dynamic inside them is escaped when the area is registered: the translated aria-label above goes through esc_attr__(). The name and description also go through esc_html__() because translation files are input from outside the theme, which is what the WordPress Coding Standards ask for. A theme must never put user-supplied content in these arguments.

The theme gets the widgets theme support automatically with the first register_sidebar() call; that support is what makes Appearance > Widgets and the Customizer’s Widgets panel appear. A theme with no registered area has no Widgets screen.

Printing a widget area

dynamic_sidebar( $id ) prints every widget of an area in order and returns false when the area is empty or unknown. is_active_sidebar( $id ) returns true when at least one widget is assigned to it. Wrapping markup is printed only when the area has content, so the theme checks first. In lumen-classic the sidebar lives in sidebar.php, which get_sidebar() loads; get_sidebar( 'footer' ) would load sidebar-footer.php instead.

<?php // sidebar.php (file comment omitted).
if ( ! is_active_sidebar( 'lumen-sidebar' ) ) {
	return;
}

dynamic_sidebar( 'lumen-sidebar' );

The <aside> landmark comes from before_sidebar, so sidebar.php needs no wrapper of its own. The footer area is printed from footer.php, with its wrapper in the template:

<?php // footer.php (file comment omitted). ?>
<footer class="site-footer">
	<?php if ( is_active_sidebar( 'lumen-footer' ) ) : ?>
		<div class="footer-widgets">
			<?php dynamic_sidebar( 'lumen-footer' ); ?>
		</div>
	<?php endif; ?>
	<p><?php bloginfo( 'name' ); ?></p>
</footer>
<?php wp_footer(); ?>
</body>
</html>

index.php of the skeleton wraps <main> and a get_sidebar() call in a <div class="site-columns"> and ends with get_footer() instead of printing wp_footer() and the closing tags itself.

Around the output, dynamic_sidebar() fires dynamic_sidebar_before and dynamic_sidebar_after (with the area ID), applies the dynamic_sidebar_params filter to every widget’s wrapper arguments, and fires dynamic_sidebar for each widget. Plugins use these hooks to add markup without touching the theme. To print one widget outside any area, for example in a template, the_widget( 'Lumen_Classic_Latest_Books_Widget', array( 'number' => 3 ) ) instantiates the type with the given settings and uses default wrapper markup.

The block-based widgets editor

Since WordPress 5.8, Appearance > Widgets and Appearance > Customize > Widgets are block editors: each widget area is a container, and any block can be inserted into it. Every top-level block the owner adds becomes one instance of the block widget type, with the block’s serialized markup in its content setting, stored in the widget_block option. Existing classic widgets keep working: the editor shows each one in a Legacy Widget block that renders the widget’s own form() for editing and its widget() output as a preview. The Widget Group block (5.9) groups blocks under a title that is printed with the area’s before_title and after_title, so a group of blocks looks like a classic widget.

A fresh 7.1.3 installation creates five block widgets (Search, and groups for Recent Posts, Recent Comments, Archives and Categories) in the areas sidebar-1 and sidebar-2. When a classic theme is activated, WordPress maps them onto the theme’s registered areas in order, which is why lumen-classic shows them before anything is configured.

Nothing has to be declared for the block editor: core adds the widgets-block-editor theme support on after_setup_theme (priority 1), and wp_use_widgets_block_editor() returns it through the use_widgets_block_editor filter. A theme or site that still needs the drag-and-drop screen has three switches:

<?php
/**
 * Lumen Classic: restores the classic Widgets screen (only if the site depends on it).
 */
function lumen_classic_classic_widgets_screen() {
	remove_theme_support( 'widgets-block-editor' );
}
add_action( 'after_setup_theme', 'lumen_classic_classic_widgets_screen' );
  • the theme removes the widgets-block-editor support as above;

  • the owner activates the official Classic Widgets plugin (maintained "to 2024, or as long as is necessary");

  • a must-use plugin filters use_widgets_block_editor, for example with __return_false, or per user.

With the support removed, wp_use_widgets_block_editor() returns false on the test site below and the classic screen comes back. Block widgets already saved keep rendering on the front end; on the classic screen they appear as "Block" widgets whose form is a "Block HTML" textarea holding the raw markup. Opting out is a compatibility measure for sites whose widgets cannot work in the block editor, not a recommendation.

Block themes never get these screens. When a site switches from a classic theme to a block theme, WordPress 6.2+ saves the old areas in the theme mod wp_classic_sidebars and keeps them registered without adding widgets support, so in the Site Editor the Template Part block’s Advanced panel can "Import widget area" into a template part. Widgets that only existed as PHP classes of the old theme cannot come along; see Hybrid Themes and Migration to Block Themes.

A legacy widget: Latest books

New code should prefer blocks (see the last section), but WP_Widget is still fully supported in 7.1, many classic sites run on such widgets, and the API explains how the stored data looks. The class below lists the latest books (lp_book, registered by the lumen-catalog plugin) and falls back to posts when the plugin is inactive. It lives in lumen-classic/inc/class-lumen-classic-latest-books-widget.php and is registered by lumen_classic_widgets_init() above.

<?php
/**
 * Legacy "Latest books" widget, kept to show the WP_Widget API.
 *
 * @package Lumen_Classic
 */

/**
 * Lists the most recent books (lp_book), or posts when lumen-catalog is inactive.
 */
class Lumen_Classic_Latest_Books_Widget extends WP_Widget {

	/**
	 * Sets the widget ID base, name and options.
	 */
	public function __construct() {
		parent::__construct(
			'lumen_latest_books',
			esc_html__( 'Latest books', 'lumen-classic' ),
			array(
				'classname'                   => 'lumen-latest-books',
				'description'                 => esc_html__( 'The most recent books in the catalogue.', 'lumen-classic' ),
				'customize_selective_refresh' => true,
				'show_instance_in_rest'       => true,
			)
		);
	}

	/**
	 * Front end output.
	 *
	 * @param array $args     Sidebar markup: before_widget, after_widget, before_title, after_title.
	 * @param array $instance Saved settings of this widget instance.
	 */
	public function widget( $args, $instance ) {
		$title  = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Latest books', 'lumen-classic' );
		$title  = apply_filters( 'widget_title', $title, $instance, $this->id_base );
		$number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;

		$books = new WP_Query(
			array(
				'post_type'           => post_type_exists( 'lp_book' ) ? 'lp_book' : 'post',
				'posts_per_page'      => $number,
				'no_found_rows'       => true,
				'ignore_sticky_posts' => true,
			)
		);
		if ( ! $books->have_posts() ) {
			return;
		}

		echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup registered by the theme.
		echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo '<ul>';
		while ( $books->have_posts() ) {
			$books->the_post();
			printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
		}
		echo '</ul>';
		echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		wp_reset_postdata();
	}

	/**
	 * Settings form on the Widgets screen and in the Legacy Widget block.
	 *
	 * @param array $instance Current settings.
	 * @return string Default return is 'noform'.
	 */
	public function form( $instance ) {
		$title  = isset( $instance['title'] ) ? $instance['title'] : '';
		$number = isset( $instance['number'] ) ? absint( $instance['number'] ) : 5;
		?>
		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Title:', 'lumen-classic' ); ?></label>
			<input class="widefat" type="text" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" value="<?php echo esc_attr( $title ); ?>">
		</p>
		<p>
			<label for="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>"><?php esc_html_e( 'Number of books:', 'lumen-classic' ); ?></label>
			<input class="tiny-text" type="number" min="1" max="20" id="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'number' ) ); ?>" value="<?php echo esc_attr( $number ); ?>">
		</p>
		<?php
		return '';
	}

	/**
	 * Sanitizes the submitted settings before they are saved.
	 *
	 * @param array $new_instance Values just sent from the form.
	 * @param array $old_instance Previously saved values.
	 * @return array Settings to save.
	 */
	public function update( $new_instance, $old_instance ) {
		// Fields missing from the request (wp widget update, REST) keep their saved value.
		$instance = wp_parse_args( $new_instance, $old_instance );

		return array(
			'title'  => sanitize_text_field( $instance['title'] ?? '' ),
			'number' => min( 20, max( 1, absint( $instance['number'] ?? 5 ) ) ),
		);
	}
}
Part Role

parent::__construct( $id_base, $name, $widget_options )

id_base names the type and the option (widget_lumen_latest_books). classname becomes %2$s in before_widget. customize_selective_refresh lets the Customizer re-render only this widget. show_instance_in_rest (5.8) exposes the settings as instance.raw in the REST API, which the block editor needs to transform the widget into a block; without it only an encoded, hashed copy is exposed.

widget( $args, $instance )

Front end output. It must print $args['before_widget'] and $args['after_widget'] around its markup and the title between before_title and after_title, and escape everything it prints itself. widget_title is the filter every core widget applies to titles. The base class method `die()`s, so a subclass must override it.

form( $instance )

The settings form, shown on the classic screen and inside the Legacy Widget block. get_field_id() and get_field_name() produce the input IDs and names WordPress expects.

update( $new_instance, $old_instance )

Returns the settings to save, or false to cancel. It is the only place to sanitize: here the title goes through sanitize_text_field() and the number is clamped to 1–20.

update() merges the new values over the saved ones with wp_parse_args(): wp widget update and REST clients send only the fields they change, and a version that read $new_instance alone blanked the title on the test site when only --number was updated. The phpcs:ignore comments mark the trusted wrapper markup as the only unescaped output; the file passes the WordPress ruleset of WPCS 3.4.1.

A widget type registered by a theme exists only while that theme is active (see the pitfalls below). For a real site the class belongs in the lumen-catalog plugin, next to the post type it lists and its block replacement.

Checking the result on a running site

The commands below use the Docker Compose stack of Local Development Environments (services db, wordpress and cli, with lumen-catalog active) on a fresh volume, with one more line in its &wp-volumes list: ./themes/lumen-classic:/var/www/html/wp-content/themes/lumen-classic. The site is installed under https://lumenpress.test, and the books catalogue holds four published lp_book posts.

docker compose run --rm cli wp core install --url=https://lumenpress.test --title="Lumen Press" \
  --admin_user=admin --admin_email=admin@lumenpress.test --skip-email
docker compose run --rm cli wp rewrite structure '/%postname%/'
docker compose run --rm cli wp theme activate lumen-classic
docker compose run --rm cli wp sidebar list
docker compose run --rm cli wp widget list lumen-sidebar --fields=name,id,position
Success: Switched to 'Lumen Classic' theme.
name	id	description
Book sidebar	lumen-sidebar	Shown next to posts and the book catalogue.
Footer	lumen-footer	Shown at the bottom of every page.
Inactive Widgets	wp_inactive_widgets	Drag widgets here to remove them from the sidebar but keep their settings.
name	id	position
block	block-2	1
block	block-3	2
block	block-4	3

The three block widgets are the installation defaults mapped onto the first area. The next commands move all default widgets to Inactive Widgets, place the "Latest books" widget and a block widget, and read back the stored data.

docker compose run --rm cli wp widget reset lumen-sidebar lumen-footer
docker compose run --rm cli wp widget add lumen_latest_books lumen-sidebar 1 --title="New from Lumen Press" --number=3
docker compose run --rm cli wp widget add block lumen-footer \
  --content='<!-- wp:paragraph --><p>Submissions open every spring.</p><!-- /wp:paragraph -->'
docker compose run --rm cli wp widget list lumen-sidebar --fields=name,id,position,options
docker compose run --rm cli wp widget update lumen_latest_books-1 --number=50
docker compose run --rm cli wp option get widget_lumen_latest_books --format=json
docker compose run --rm cli wp option get sidebars_widgets --format=json
Sidebar 'lumen-sidebar' reset.
Sidebar 'lumen-footer' reset.
Success: Reset 2 of 2 sidebars.
Success: Added widget to sidebar.
Success: Added widget to sidebar.
name	id	position	options
lumen_latest_books	lumen_latest_books-1	1	{"title":"New from Lumen Press","number":3}
Success: Widget updated.
{"_multiwidget":1,"1":{"title":"New from Lumen Press","number":20}}
{"wp_inactive_widgets":["block-2","block-3","block-4","block-5","block-6"],"lumen-sidebar":["lumen_latest_books-1"],"lumen-footer":["block-7"]}

wp widget add and wp widget update run the widget’s update() method, so the requested 50 books were clamped to 20 and the title kept its value (the number was set back to 3 before the requests below). wp widget reset deletes nothing: the defaults are now in wp_inactive_widgets, and wp widget delete <id> removes them for good. wp widget move <id> --sidebar-id=<area> and wp widget deactivate <id> cover the other drag-and-drop operations.

The REST API view

The block-based Widgets screen is a REST client. The edit_theme_options capability is needed to read areas and widgets, so the requests are made as admin with wp eval and rest_do_request():

docker compose run --rm cli wp eval --user=admin '
$r = new WP_REST_Request( "GET", "/wp/v2/widgets" );
$r->set_param( "sidebar", "lumen-sidebar" );
$r->set_param( "context", "edit" );
foreach ( rest_do_request( $r )->get_data() as $w ) {
	echo wp_json_encode( array( "id" => $w["id"], "id_base" => $w["id_base"], "raw" => $w["instance"]["raw"] ) ), "\n";
}'
{"id":"lumen_latest_books-1","id_base":"lumen_latest_books","raw":{"title":"New from Lumen Press","number":3}}

Next to raw, the instance object carries encoded (the base64 of the serialized settings) and hash (an HMAC keyed with the site’s salts), which is all a client gets when show_instance_in_rest is off. /wp/v2/sidebars lists each area with its arguments and widget IDs, and /wp/v2/widget-types the registered types.

The rendered page

docker compose exec wordpress curl -s -H 'Host: lumenpress.test' -H 'X-Forwarded-Proto: https' http://localhost/ \
  | sed -n '/<\/main>/,/<\/footer>/p'
</main>
<aside id="lumen-sidebar" class="widget-area " aria-label="Book sidebar"><section id="lumen_latest_books-1" class="widget lumen-latest-books"><h2 class="widget-title">New from Lumen Press</h2><ul><li><a href="https://lumenpress.test/books/night-ferry/">Night Ferry</a></li><li><a href="https://lumenpress.test/books/a-map-of-quiet-rooms/">A Map of Quiet Rooms</a></li><li><a href="https://lumenpress.test/books/salt-and-signal/">Salt and Signal</a></li></ul></section></aside></div>
<footer class="site-footer">
			<div class="footer-widgets">
			<section id="block-7" class="widget widget_block widget_text"><p class="wp-block-paragraph">Submissions open every spring.</p></section>		</div>
		<p>Lumen Press</p>
</footer>

The <aside> comes from before_sidebar with the area ID as %1$s (and an empty %2$s, because the area has no class). Each widget is wrapped in the <section> of before_widget; the classic widget’s title uses before_title, while the block widget gets the classes widget_block plus one derived from its block (widget_text for a paragraph). While the footer area was empty, is_active_sidebar( 'lumen-footer' ) was false and no footer-widgets wrapper was printed.

A Heading block inside a block widget prints its own <h2 class="wp-block-heading">; a Widget Group block with {"title":"Submissions"} instead printed <h2 class="widget-title">Submissions</h2>, like the classic widgets.

Errors and pitfalls

  • Theme switches rearrange widgets. On the test site, wp theme activate twentytwentyfive followed by wp theme activate lumen-classic left the "Latest books" widget in no area at all (its type is registered by the theme, so it did not exist while Twenty Twenty-Five was active) and moved the five previously inactive default widgets back into lumen-sidebar and lumen-footer. The settings survived in widget_lumen_latest_books. After any theme switch, check with wp widget list and repair with wp widget move, deactivate and add.

  • Missing or renamed id. Generated sidebar-<n> IDs shift when a plugin registers an area first, and renaming an id in a theme update leaves the widgets of the old area unprinted.

  • Registering at the wrong time. Use widgets_init (fired on init at priority 1), never a template.

  • Trusting the instance. Sanitize in update() and still escape in widget(); see Security for Developers.

  • Widgets that break in the block editor. A widget whose form() depends on scripts loaded only on the classic screen, or that calls wp.editor.initialize(), fails inside the Legacy Widget block. The fix is to load the scripts on the Widgets screen properly or to replace the widget with a block; removing widgets-block-editor only hides the problem.

When a block is better than a widget

Blocks replace widgets for new development. A WP_Widget works only in the widget areas of a classic theme; a block works in posts, pages, patterns, template parts of block themes and, through the block widget type, in classic widget areas too.

Legacy WP_Widget Block (lumen/latest-books)

Where it can appear

Classic widget areas only

Posts, pages, patterns, block theme templates and parts, and classic widget areas as a block widget

Editing experience

A PHP form; preview only in the Legacy Widget block or the Customizer

Visual editing with live preview, block supports (colour, spacing, typography) and the inspector

Survives a switch to a block theme

No; at best imported through "Import widget area" if the type is still registered

Yes, when it is registered by a plugin

Server rendering

widget()

render.php of a dynamic block, so the list stays current

REST and editor integration

Settings visible only with show_instance_in_rest

Attributes in the serialized markup

A widget is still reasonable when a classic site must keep working without changes, or when a third-party widget has no block equivalent yet. For new work on Lumen Press, the catalogue list is the dynamic block lumen/latest-books, registered by lumen-catalog and described in Static and Dynamic Blocks. In lumen-classic it is placed in a widget area as a block widget. With a minimal server-rendered block registered under that name, the command below rendered the block’s <ul class="wp-block-lumen-latest-books"> inside the area’s <section id="block-8" class="widget widget_block"> wrapper.

docker compose run --rm cli wp widget add block lumen-sidebar \
  --content='<!-- wp:heading --><h2 class="wp-block-heading">New from Lumen Press</h2><!-- /wp:heading --><!-- wp:lumen/latest-books /-->'

Once the block exists, the legacy widget can stop being offered for new placements without breaking the instances already in use. The filter below removes it from the Legacy Widget block’s list; existing lumen_latest_books-* instances keep rendering. On the test site, get_legacy_widget_block_editor_settings() then listed it after the 17 core types hidden by default, all of which have block equivalents.

<?php
/**
 * Hides the legacy "Latest books" widget from the Legacy Widget block once the
 * lumen/latest-books block replaces it. Existing instances keep working.
 *
 * @param string[] $widget_types Widget id_base values the Legacy Widget block does not offer.
 * @return string[]
 */
function lumen_classic_hide_legacy_widgets( $widget_types ) {
	$widget_types[] = 'lumen_latest_books';
	return $widget_types;
}
add_filter( 'widget_types_to_hide_from_legacy_widget_block', 'lumen_classic_hide_legacy_widgets' );

The same handbook page adds a block transform from core/legacy-widget that reads instance.raw (hence show_instance_in_rest) and turns each placed widget into the new block. Converting widget areas into template parts is part of Hybrid Themes and Migration to Block Themes.

What older books and tutorials get wrong

Older advice Current practice

Widgets are built only as WP_Widget subclasses and placed by dragging them on Appearance > Widgets (the only approach in classic-theme books such as Pro WordPress)

Since 5.8 the Widgets screen and the Customizer panel are block editors; blocks are the default way to fill an area, and WP_Widget classes appear inside Legacy Widget blocks. The class API is still supported, but new features go into blocks.

A widget’s constructor calls parent::WP_Widget() or a PHP 4 style method

Deprecated since 4.3; call parent::__construct().

register_sidebar() with only a name

Always pass an id; generated IDs move widgets between areas.

update() returns $new_instance and widget() echoes the stored title

Sanitize in update() and escape in widget(); settings are user input.

Block themes have a Widgets screen

They do not; widget areas become template parts and patterns, and 6.2+ can import a former theme’s areas.

These corrections are also collected in Where Older Books and Tutorials Are Outdated.