Admin list table columns and dashboard widgets

Add columns to the events list, make them sortable, avoid one query per row, and give booking managers a dashboard widget.

  • Intermediate
  • 11 min read
  • Updated 17 Oct 2026

What you will learn

  • Add a sortable admin column
  • Add a dashboard widget
  • Load column data for a whole page in one query
  • Cache widget data and clear it when bookings change

Before you start

  • Admin menus and pages
  • Admin menus and pages, from this course
  • pre_get_posts and meta queries, from the WordPress Development Complete Course

The events list in the admin shows a title, an author and a publish date. Editors care about something else: when the event happens, what it costs and how many people have booked. This lesson adds those columns, makes the date sortable, and adds a dashboard widget that shows the next few events at a glance.

Adding columns

Two hooks work together. A filter defines which columns exist, and an action prints each cell:

HookTypeReceives
manage_{$post_type}_posts_columnsFilterThe columns array: key to label
manage_{$post_type}_posts_custom_columnActionThe column key and the post ID
PHP
<?php
// wp-content/plugins/myplugin/admin/columns.php
defined( 'ABSPATH' ) || exit;

add_filter( 'manage_myplugin_event_posts_columns', 'myplugin_event_columns' );

/**
 * Insert event columns after the title, and drop the publish date.
 */
function myplugin_event_columns( array $columns ): array {
    $new = array();

    foreach ( $columns as $key => $label ) {
        if ( 'date' === $key ) {
            continue;
        }

        $new[ $key ] = $label;

        if ( 'title' === $key ) {
            $new['myplugin_event_date'] = __( 'Event date', 'myplugin' );
            $new['myplugin_price']      = __( 'Price', 'myplugin' );
            $new['myplugin_bookings']   = __( 'Booked', 'myplugin' );
        }
    }

    return $new;
}

Rebuilding the array lets you choose the position. Appending to the end would put the columns after Date and the comments icon. The keys are prefixed, like everything else, because other plugins add columns to the same table.

Removing the publish date column is a judgement call: for events, the date the post was published is noise next to the date the event happens. Users can still hide or show columns from Screen Options.

Printing cells

PHP
<?php
// wp-content/plugins/myplugin/admin/columns.php

add_action( 'manage_myplugin_event_posts_custom_column', 'myplugin_event_column_content', 10, 2 );

function myplugin_event_column_content( string $column, int $post_id ): void {
    switch ( $column ) {
        case 'myplugin_event_date':
            $date = (string) get_post_meta( $post_id, 'myplugin_event_date', true );
            echo $date ? esc_html( wp_date( get_option( 'date_format' ), strtotime( $date . ' 12:00' ) ) ) : esc_html__( 'Not set', 'myplugin' );
            break;

        case 'myplugin_price':
            $price = (float) get_post_meta( $post_id, 'myplugin_price', true );
            echo $price > 0
                ? esc_html( myplugin_get_settings()['currency'] . ' ' . number_format_i18n( $price, 2 ) )
                : esc_html__( 'Free', 'myplugin' );
            break;

        case 'myplugin_bookings':
            $counts = myplugin_booking_counts_for_screen();
            echo esc_html( number_format_i18n( $counts[ $post_id ] ?? 0 ) );
            break;
    }
}

The action fires once per row per custom column, and it fires for every plugin's columns, so the switch only handles your keys and ignores the rest. Remember that this action prints; nothing is returned.

The date is formatted with the site's date format. Adding a midday time before converting avoids the date shifting by a day in time zones far from UTC.

Why the meta calls are cheap

get_post_meta() inside a loop looks like one query per row, but it is not. The list screen runs a WP_Query, which loads the meta for every post on the page in one query and keeps it in the object cache. Each get_post_meta() call then reads from memory.

Avoiding one query per row for your own data

Your bookings table gets no such help. A SELECT COUNT(*) inside the cell callback would run 20 queries on a 20-row page, and 200 when a user shows 200 per page. Load the counts for the whole page once:

PHP
<?php
// wp-content/plugins/myplugin/admin/columns.php

/**
 * Confirmed guest counts for every event on the current list screen.
 *
 * @return array<int,int> Event ID to number of people booked.
 */
function myplugin_booking_counts_for_screen(): array {
    static $counts = null;

    if ( null !== $counts ) {
        return $counts;
    }

    global $wp_query, $wpdb;

    $counts = array();
    $ids    = array_map( 'intval', wp_list_pluck( $wp_query->posts, 'ID' ) );

    if ( ! $ids ) {
        return $counts;
    }

    $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );

    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT event_id, COUNT(*) + SUM(guests) AS people FROM %i WHERE status = %s AND event_id IN ($placeholders) GROUP BY event_id",
            array_merge( array( myplugin_bookings_table(), 'confirmed' ), $ids )
        )
    );

    foreach ( $rows as $row ) {
        $counts[ (int) $row->event_id ] = (int) $row->people;
    }

    return $counts;
}

On the list screen, the global $wp_query holds exactly the posts being displayed. The static variable keeps the result for the rest of the request, so the query runs once, on the first cell. Each booking counts the person who booked plus their guests, and the IN list uses one placeholder per ID, as in the WordPress course's $wpdb lesson. The (event_id, status) index from the custom tables lesson makes the query fast.

Making a column sortable

Declare the column sortable, then teach the query what sorting by it means:

PHP
<?php
// wp-content/plugins/myplugin/admin/columns.php

add_filter( 'manage_edit-myplugin_event_sortable_columns', 'myplugin_event_sortable_columns' );

function myplugin_event_sortable_columns( array $columns ): array {
    $columns['myplugin_event_date'] = 'myplugin_event_date';
    $columns['myplugin_price']      = 'myplugin_price';

    return $columns;
}

The filter name has edit- in it because it is named after the screen ID, edit-myplugin_event. The value is what WordPress puts in the URL as orderby when the user clicks the heading. WordPress does not know what myplugin_event_date means, so handle it in pre_get_posts:

PHP
<?php
// wp-content/plugins/myplugin/admin/columns.php

add_action( 'pre_get_posts', 'myplugin_event_admin_orderby' );

function myplugin_event_admin_orderby( WP_Query $query ): void {
    if ( ! is_admin() || ! $query->is_main_query() || 'myplugin_event' !== $query->get( 'post_type' ) ) {
        return;
    }

    $orderby = $query->get( 'orderby' );

    if ( 'myplugin_event_date' === $orderby ) {
        $query->set(
            'meta_query',
            array(
                'relation'   => 'OR',
                'event_date' => array(
                    'key'     => 'myplugin_event_date',
                    'compare' => 'EXISTS',
                ),
                array(
                    'key'     => 'myplugin_event_date',
                    'compare' => 'NOT EXISTS',
                ),
            )
        );
        $query->set( 'orderby', 'event_date' );
    } elseif ( 'myplugin_price' === $orderby ) {
        $query->set( 'meta_key', 'myplugin_price' );
        $query->set( 'orderby', 'meta_value_num' );
    }
}

This is the reverse of the guards in the WordPress course's pre_get_posts lesson: here the callback runs only in the admin, on the main query, for events.

The date sort uses a named meta clause, event_date, and orders by it. The OR with NOT EXISTS keeps events that have no date yet in the list; a plain meta_key sort would silently hide them. Y-m-d strings sort correctly as text, so no cast is needed. The price sort uses meta_value_num so that 100 sorts after 20. Because it uses meta_key, events without a price drop out while sorting by price; the meta box always saves one, so that is acceptable here.

Taxonomy columns need none of this. Registering a taxonomy with 'show_admin_column' => true adds a column listing each post's terms, with links that filter the list.

A dashboard widget

Booking managers open the Dashboard first. Give them the next five events and their booking numbers there:

PHP
<?php
// wp-content/plugins/myplugin/admin/dashboard.php
defined( 'ABSPATH' ) || exit;

add_action( 'wp_dashboard_setup', 'myplugin_dashboard_widgets' );

function myplugin_dashboard_widgets(): void {
    if ( ! current_user_can( 'myplugin_manage_bookings' ) ) {
        return;
    }

    wp_add_dashboard_widget(
        'myplugin_upcoming_events',
        __( 'Upcoming events', 'myplugin' ),
        'myplugin_render_dashboard_widget'
    );
}

function myplugin_render_dashboard_widget(): void {
    $events = get_transient( 'myplugin_dashboard_events' );

    if ( false === $events ) {
        $events = myplugin_build_dashboard_events();
        set_transient( 'myplugin_dashboard_events', $events, HOUR_IN_SECONDS );
    }

    if ( ! $events ) {
        echo '<p>' . esc_html__( 'No upcoming events.', 'myplugin' ) . '</p>';
        return;
    }

    echo '<ul>';

    foreach ( $events as $event ) {
        printf(
            '<li><a href="%1$s">%2$s</a>: %3$s, %4$s</li>',
            esc_url( (string) get_edit_post_link( $event['id'], 'raw' ) ),
            esc_html( $event['title'] ),
            esc_html( $event['date'] ),
            /* translators: %s: number of people booked. */
            esc_html( sprintf( _n( '%s booked', '%s booked', $event['people'], 'myplugin' ), number_format_i18n( $event['people'] ) ) )
        );
    }

    echo '</ul>';
}

Notes on the widget:

  • The capability check in wp_dashboard_setup means users who cannot manage bookings never see the widget, and its query never runs for them.
  • wp_add_dashboard_widget() also accepts a control callback for a settings form, callback arguments, a context (normal, side, column3 or column4) and a priority. Users can still move or hide the widget themselves.
  • The Dashboard is the first screen after login, so a slow widget slows everyone's start. Cache the data.

The builder returns plain arrays, which cache cleanly:

PHP
<?php
// wp-content/plugins/myplugin/admin/dashboard.php

function myplugin_build_dashboard_events(): array {
    global $wpdb;

    $posts = get_posts(
        array(
            'post_type'      => 'myplugin_event',
            'post_status'    => 'publish',
            'posts_per_page' => 5,
            'meta_query'     => array(
                'event_date' => array(
                    'key'     => 'myplugin_event_date',
                    'value'   => wp_date( 'Y-m-d' ),
                    'compare' => '>=',
                    'type'    => 'DATE',
                ),
            ),
            'orderby'        => 'event_date',
            'order'          => 'ASC',
        )
    );

    $events = array();

    foreach ( $posts as $post ) {
        $people = (int) $wpdb->get_var(
            $wpdb->prepare(
                'SELECT COALESCE(COUNT(*) + SUM(guests), 0) FROM %i WHERE event_id = %d AND status = %s',
                myplugin_bookings_table(),
                $post->ID,
                'confirmed'
            )
        );

        $events[] = array(
            'id'     => $post->ID,
            'title'  => get_the_title( $post ),
            'date'   => (string) get_post_meta( $post->ID, 'myplugin_event_date', true ),
            'people' => $people,
        );
    }

    return $events;
}

Five small indexed queries, once an hour, is fine. Cached for an hour, the numbers could be stale right after a booking, so clear the transient when the data changes:

PHP
<?php
// wp-content/plugins/myplugin/includes/bookings.php
add_action( 'myplugin_booking_created', 'myplugin_clear_dashboard_cache' );
add_action( 'myplugin_booking_status_changed', 'myplugin_clear_dashboard_cache' );
add_action( 'save_post_myplugin_event', 'myplugin_clear_dashboard_cache' );

function myplugin_clear_dashboard_cache(): void {
    delete_transient( 'myplugin_dashboard_events' );
}

The booking hooks are the ones myplugin_insert_booking() and myplugin_set_booking_status() fire. This is the payoff from routing every write through those functions: one listener catches every change. The function lives in includes/ because bookings are created on the front end too, where admin files are not loaded.

Summary

  • Add columns with manage_{$post_type}_posts_columns and print cells with manage_{$post_type}_posts_custom_column, handling only your own prefixed keys.
  • Post meta in columns is preloaded; data from your own tables needs one query for the whole page, not one per row.
  • Make columns sortable with manage_edit-{$post_type}_sortable_columns, then translate the orderby value in pre_get_posts, limited to the admin main query.
  • Use a named meta clause with EXISTS or NOT EXISTS to sort without hiding posts that lack the value.
  • Add dashboard widgets on wp_dashboard_setup behind a capability check, cache their data, and clear the cache from the hooks your write functions fire.

Further reading