Previous Article
Google reCAPTCHA with PHP: v2, v3 and the Verification Step Everyone Skips
5 September 2018
A widget is a self-contained block of content a site owner can drag into a sidebar or footer without touching code. Building one means extending WP_Widget and implementing four methods.
Before the code, one thing that changed and confuses people: WordPress 5.8 replaced the widget screen with the block editor. Classic widgets still work, but where they appear and how they behave shifted. That is covered at the end.
You will find this in older tutorials, including the earlier version of this article:
// Deprecated since WordPress 2.8 — released in 2009
register_sidebar_widget( __('Search'), 'custom_search_widget' );
It has been deprecated for over fifteen years. It offers no settings form, no instance data, and no way for a user to configure anything. Use the WP_Widget class.
A widget needs somewhere to live. Register the area on widgets_init:
<?php
add_action( 'widgets_init', 'bi_register_sidebars' );
function bi_register_sidebars(): void {
register_sidebar( [
'name' => __( 'Main Sidebar', 'being-idea' ),
'id' => 'main-sidebar',
'description' => __( 'Appears on posts and archive pages.', 'being-idea' ),
// %1$s is the widget id, %2$s its class names — keep both.
'before_widget' => '<section id="%1$s" class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h2 class="widget__title">',
'after_title' => '</h2>',
] );
}
Keep the %1$s and %2$s placeholders. Dropping them removes the per-widget IDs and classes that themes and plugins rely on for styling.
Then output it in your template:
<?php if ( is_active_sidebar( 'main-sidebar' ) ) : ?>
<aside class="sidebar" role="complementary">
<?php dynamic_sidebar( 'main-sidebar' ); ?>
</aside>
<?php endif; ?>
The is_active_sidebar() check matters — without it you render an empty <aside> that still takes up grid space when no widgets are assigned.
Four methods: __construct(), widget() to render the front end, form() for the admin settings, and update() to sanitise what gets saved.
class BI_Recent_Posts_Widget extends WP_Widget {
public function __construct() {
parent::__construct(
'bi_recent_posts', // base id
__( 'Being Idea: Recent Posts', 'being-idea' ),
[
'description' => __( 'Recent posts, optionally filtered by category.', 'being-idea' ),
'classname' => 'bi-recent-posts',
'show_instance_in_rest' => true, // needed for the block editor
]
);
}
/** Front-end output. */
public function widget( $args, $instance ): void {
$title = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Posts', 'being-idea' );
$number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;
$cat = ! empty( $instance['category'] ) ? absint( $instance['category'] ) : 0;
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => $number,
'ignore_sticky_posts' => true,
'no_found_rows' => true, // skip the pagination COUNT — faster
'cat' => $cat ?: null,
] );
if ( ! $query->have_posts() ) {
return; // render nothing rather than an empty box
}
echo $args['before_widget'];
// apply_filters keeps compatibility with translation and title plugins.
echo $args['before_title']
. esc_html( apply_filters( 'widget_title', $title, $instance, $this->id_base ) )
. $args['after_title'];
echo '<ul class="bi-recent-posts__list">';
while ( $query->have_posts() ) {
$query->the_post();
printf(
'<li><a href="%s">%s</a> <time datetime="%s">%s</time></li>',
esc_url( get_permalink() ),
esc_html( get_the_title() ),
esc_attr( get_the_date( DATE_W3C ) ),
esc_html( get_the_date() )
);
}
echo '</ul>';
echo $args['after_widget'];
// Essential — restores the main query so the rest of the page is correct.
wp_reset_postdata();
}
/** Admin settings form. */
public function form( $instance ): string {
$title = $instance['title'] ?? '';
$number = $instance['number'] ?? 5;
$cat = $instance['category'] ?? 0;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'being-idea' ); ?>
</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" 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 posts:', 'being-idea' ); ?>
</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'number' ) ); ?>"
type="number" step="1" min="1" max="20"
value="<?php echo esc_attr( $number ); ?>" />
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'category' ) ); ?>">
<?php esc_html_e( 'Category:', 'being-idea' ); ?>
</label>
<?php
wp_dropdown_categories( [
'show_option_all' => __( 'All categories', 'being-idea' ),
'selected' => $cat,
'id' => $this->get_field_id( 'category' ),
'name' => $this->get_field_name( 'category' ),
'class' => 'widefat',
'hide_empty' => false,
] );
?>
</p>
<?php
return '';
}
/** Sanitise before saving. */
public function update( $new_instance, $old_instance ): array {
return [
'title' => sanitize_text_field( $new_instance['title'] ?? '' ),
'number' => min( 20, max( 1, absint( $new_instance['number'] ?? 5 ) ) ),
'category' => absint( $new_instance['category'] ?? 0 ),
];
}
}
add_action( 'widgets_init', function (): void {
register_widget( 'BI_Recent_Posts_Widget' );
} );
get_field_id() and get_field_name() generate unique identifiers per widget instance. Hardcoding name="title" means two copies of your widget overwrite each other's settings.
Escape on output, sanitise on input. esc_attr() in form fields, esc_html() and esc_url() in the front end, sanitize_text_field() and absint() in update(). Widget settings are entered by administrators, but a compromised or lower-privileged account should not be able to inject script into every page.
wp_reset_postdata() after any custom WP_Query. Omit it and the global post object stays pointing at your last widget item, so template tags below the sidebar output the wrong post. This is a genuinely common and confusing bug.
'no_found_rows' => true skips the SQL_CALC_FOUND_ROWS query WordPress runs for pagination. A widget never paginates, so that is a free saving on every page load.
A widget appears on every page, so its query runs on every page. Cache it:
public function widget( $args, $instance ): void {
$key = 'bi_recent_' . md5( wp_json_encode( $instance ) );
$cached = get_transient( $key );
if ( false !== $cached ) {
echo $cached; // already escaped when it was built
return;
}
ob_start();
// … build output …
$output = ob_get_clean();
set_transient( $key, $output, HOUR_IN_SECONDS );
echo $output;
}
// Clear when content changes, or the widget shows stale posts for an hour.
add_action( 'save_post', function (): void {
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_bi_recent_%'" );
} );
WordPress 5.8 replaced Appearance → Widgets with a block-based editor. Practical consequences:
WP_Widget classes still work — they appear inside a "Legacy Widget" block.'show_instance_in_rest' => true in your constructor options, or the widget cannot render a preview in the block editor and shows a placeholder instead.form() often misbehave, because the block editor renders the form in an iframe and your inline scripts may not run.If a client's site depends on the old screen, restore it:
// Bring back the classic widgets screen. add_filter( 'use_widgets_block_editor', '__return_false' );
Alternatively install the official Classic Widgets plugin, which does the same and is easier to explain to a non-technical site owner.
For new work, consider building a block instead. Blocks can be placed in post content, templates and widget areas alike, whereas a widget only works in registered sidebars. The WP_Widget API is not deprecated, but it is no longer where WordPress is heading.
WP_Widget; register_sidebar_widget() has been deprecated since 2009.widgets_init.get_field_id() / get_field_name() so multiple instances do not collide.update(), escape in widget() and form().wp_reset_postdata() after a custom query.save_post.show_instance_in_rest for block editor previews.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.