WP_List_Table Tutorial: How to Create & Extend Admin Tables in WordPress

Table of Contents
A deep-dive into the WordPress WP_List_Table class — what it is, how to extend it with static patterns, how to handle pagination, search, bulk actions, security, screen options, help tabs, and a fully working example ready for production.

The WP_List_Table class is one of the most powerful—but under-documented—tools in WordPress. It powers the familiar admin tables you see for posts, users, comments, plugins, and more. Although it’s marked as “private” (not officially part of the public API), it’s widely used and stable enough for custom admin interfaces.

1. What Is WP_List_Table and Why Use It?

WP_List_Table is the abstract base class that powers every admin list screen in WordPress — posts, users, comments, plugins, media, and more. Located at wp-admin/includes/class-wp-list-table.php, it provides a consistent, battle-tested UI for rendering tabular data with built-in support for:

  • Column sorting and customizable column headers
  • Pagination with per-page screen options
  • Search boxes and extra filter dropdowns
  • Bulk actions with nonce-protected forms
  • Row actions (edit, delete, view links)
  • Checkbox columns for batch operations
  • AJAX-ready architecture
  • Accessibility and screen-reader-friendly markup

Why Not Build Your Own Table?

Rolling your own HTML tables for admin pages leads to inconsistent UI, duplicated pagination logic, missed security checks, and a maintenance burden. By extending WP_List_Table you get all of the above for free, your table looks native to the WordPress admin, and you benefit from future WordPress core improvements automatically.

Important Caveat

WP_List_Table is officially marked as a private API — WordPress documentation states it may change between versions. In practice, it has remained stable for over a decade and is the de facto standard for admin tables. Just pin your implementation to the documented methods and test when upgrading WordPress.

2. How to Extend WP_List_Table

To use WP_List_Table you must include it first — it is not auto-loaded. Then you create a child class that overrides the required methods. In this guide, every extending class follows a static pattern: custom logic lives in static methods and properties, while only the inherited parent methods ($this->set_pagination_args(), $this->items, etc.) are called dynamically.

Minimal Skeleton

PHP
if ( ! \class_exists( 'WP_List_Table' ) ) {
    require_once \ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

class Books_List_Table extends \WP_List_Table {

    /**
     * Static column definitions — no instance state needed.
     */
    private static array $column_map = [
        'cb'     => '<input type="checkbox" />',
        'title'  => 'Book Title',
        'author' => 'Author',
        'isbn'   => 'ISBN',
        'price'  => 'Price',
    ];

    public function __construct() {
        parent::__construct( [
            'singular' => 'book',
            'plural'   => 'books',
            'ajax'     => false,
        ] );
    }

    /** Static column list — no dynamic state. */
    public function get_columns(): array {
        return self::$column_map;
    }

    /** Must be implemented — loads data into $this->items. */
    public function prepare_items(): void {
        $this->_column_headers = $this->get_column_info();
        $this->items           = self::fetch_table_data();

        $this->set_pagination_args( [
            'total_items' => \count( $this->items ),
            'per_page'    => 20,
        ] );
    }

    /**
     * Static data source — keeps the class stateless.
     */
    private static function fetch_table_data(): array {
        // We will flesh this out in the "Preparing Data" section.
        return [];
    }
}

Key Takeaways

  • parent::__construct() must receive singular, plural, and optionally ajax.
  • get_columns() defines the table header. The key cb is reserved for checkboxes.
  • prepare_items() is where you query, filter, sort, and paginate your data.
  • All custom helpers are static — the instance only touches parent internals.

2.1 Common mistakes

  • class not loaded – always check if WP_List_Table is loaded
  • columns not showing – implement get_columns
  • pagination issues

3. Best Practices

  1. Load the base class conditionally.
    Always check \class_exists( 'WP_List_Table' ) before requiring.
  2. Keep custom logic static.
    Data fetching, filtering, and sorting helpers should be static methods. Only call parent dynamic methods through $this.
  3. Escape all output.
    Every value rendered in a column method must go through \esc_html(), \esc_url(), or \esc_attr().
  4. Verify nonces on every form submission.
    WordPress auto-generates the nonce field, but you must call \check_admin_referer( 'bulk-' . $plural ) when processing.
  5. Check capabilities early.
    Guard your admin page with \current_user_can() before any data operation.
  6. Instantiate the table before any output.
    prepare_items() may modify headers (redirects, screen options), so call it before <div class="wrap">.
  7. Implement get_sortable_columns().
    Users expect clickable column headers. Always define at least one sortable column.
  8. Use get_items_per_page() with a saved screen option.
    Hard-coding the per-page value prevents users from customising their view.
  9. Declare a primary column.
    Override get_default_primary_column_name() — it controls where row actions appear.
  10. Never echo directly outside column methods.
    Return or echo only inside the method that owns the cell.
PHP
<?php
// Best practice: instantiate and prepare before output.
$table = new Books_List_Table();
$table->prepare_items();

echo '<div class="wrap">';
echo '<h1 class="wp-heading-inline">' . \esc_html__( 'Books', 'my-plugin' ) . '</h1>';
echo '<hr class="wp-header-end">';

$table->views();

echo '<form method="get">';
echo '<input type="hidden" name="page" value="' . \esc_attr( $_REQUEST['page'] ?? '' ) . '" />';
$table->search_box( \esc_html__( 'Search Books', 'my-plugin' ), 'book-search' );
$table->display();
echo '</form>';
echo '</div>';

4. How to Prepare Data

The prepare_items() method is the heart of your list table. It must handle four concerns: querying, searching, sorting, and paginating. Because we keep the class static, each concern lives in its own static helper.

4.1 Fetching Raw Data

PHP
private static function fetch_table_data(): array {
    global $wpdb;

    $table_name = $wpdb->prefix . 'books';

    $results = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT id, title, author, isbn, price, created_at FROM {$table_name} WHERE 1=%d",
            1
        ),
        ARRAY_A
    );

    return \is_array( $results ) ? $results : [];
}
PHP
/**
 * Filter rows by search term — pure static method.
 */
private static function filter_by_search( array $data, string $search ): array {
    if ( '' === $search ) {
        return $data;
    }

    $search = \mb_strtolower( $search );

    return \array_filter( $data, static function ( array $row ) use ( $search ): bool {
        return \str_contains( \mb_strtolower( $row['title'] ?? '' ), $search )
            || \str_contains( \mb_strtolower( $row['author'] ?? '' ), $search )
            || \str_contains( \mb_strtolower( $row['isbn'] ?? '' ), $search );
    } );
}

4.3 Sorting

PHP
/**
 * Sort the dataset — static, no instance state.
 */
private static function sort_data( array $data, string $orderby, string $order ): array {
    $allowed_columns = [ 'title', 'author', 'price', 'created_at' ];

    if ( ! \in_array( $orderby, $allowed_columns, true ) ) {
        $orderby = 'title';
    }

    \usort( $data, static function ( array $a, array $b ) use ( $orderby, $order ): int {
        $result = \strnatcasecmp( (string) ( $a[ $orderby ] ?? '' ), (string) ( $b[ $orderby ] ?? '' ) );
        return 'desc' === $order ? -$result : $result;
    } );

    return $data;
}

4.4 Paginating

PHP
/**
 * Slice the dataset for the current page.
 */
private static function paginate( array $data, int $per_page, int $current_page ): array {
    return \array_slice( $data, ( $current_page - 1 ) * $per_page, $per_page );
}

4.5 Putting It All Together in prepare_items()

PHP
public function prepare_items(): void {
    $per_page     = $this->get_items_per_page( 'books_per_page', 20 );
    $current_page = $this->get_pagenum();

    // 1. Fetch
    $data = self::fetch_table_data();

    // 2. Search
    $search = isset( $_REQUEST['s'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['s'] ) ) : '';
    $data   = self::filter_by_search( $data, $search );

    // 3. Sort
    $orderby = isset( $_REQUEST['orderby'] ) ? \sanitize_key( $_REQUEST['orderby'] ) : 'title';
    $order   = isset( $_REQUEST['order'] ) && 'desc' === $_REQUEST['order'] ? 'desc' : 'asc';
    $data    = self::sort_data( $data, $orderby, $order );

    // 4. Pagination args (uses parent dynamic method)
    $total_items = \count( $data );

    $this->set_pagination_args( [
        'total_items' => $total_items,
        'per_page'    => $per_page,
        'total_pages' => (int) \ceil( $total_items / $per_page ),
    ] );

    // 5. Slice for current page
    $this->items = self::paginate( $data, $per_page, $current_page );

    // 6. Column headers
    $this->_column_headers = $this->get_column_info();
}

4.6 Server-Side Search with $wpdb

For large datasets, push the search to the database instead of filtering in PHP:

PHP
private static function fetch_table_data( string $search = '' ): array {
    global $wpdb;

    $table_name = $wpdb->prefix . 'books';
    $sql        = "SELECT id, title, author, isbn, price, created_at FROM {$table_name}";

    if ( '' !== $search ) {
        $like = '%' . $wpdb->esc_like( $search ) . '%';
        $sql .= $wpdb->prepare(
            ' WHERE title LIKE %s OR author LIKE %s OR isbn LIKE %s',
            $like,
            $like,
            $like
        );
    }

    $sql .= ' ORDER BY id DESC';

    $results = $wpdb->get_results( $sql, ARRAY_A );

    return \is_array( $results ) ? $results : [];
}

5. Security — Nonces, Capabilities, and Escaping

Admin tables handle user data and destructive operations. Every layer must be secured.

5.1 Capability Checks

PHP
\add_menu_page(
    \__( 'Books', 'my-plugin' ),
    \__( 'Books', 'my-plugin' ),
    'manage_options',               // ← capability gate
    'books-manager',
    [ Books_Admin::class, 'render_page' ],
    'dashicons-book-alt',
    26
);

Always verify inside the render callback too:

PHP
public static function render_page(): void {
    if ( ! \current_user_can( 'manage_options' ) ) {
        \wp_die( \esc_html__( 'You do not have permission to access this page.', 'my-plugin' ) );
    }

    // ... render table
}

5.2 Nonce Verification for Bulk Actions

The parent class automatically outputs a nonce field with the action bulk-{plural}. When processing the form, verify it:

PHP
public static function process_bulk_action( \WP_List_Table $table ): void {
    $action = $table->current_action();

    if ( ! $action ) {
        return;
    }

    // Verify the nonce — action name matches "bulk-books".
    \check_admin_referer( 'bulk-books' );

    $ids = isset( $_REQUEST['book'] ) ? \array_map( 'absint', (array) $_REQUEST['book'] ) : [];

    if ( empty( $ids ) ) {
        return;
    }

    switch ( $action ) {
        case 'delete':
            if ( ! \current_user_can( 'manage_options' ) ) {
                \wp_die( \esc_html__( 'Unauthorized.', 'my-plugin' ) );
            }
            self::delete_books( $ids );
            break;
    }

    \wp_safe_redirect( \remove_query_arg( [ '_wp_http_referer', '_wpnonce', 'action', 'action2', 'book' ] ) );
    exit;
}

5.3 Escaping Output

PHP
<?php
protected function column_default( $item, $column_name ): string {
    return \esc_html( $item[ $column_name ] ?? '' );
}

protected function column_title( $item ): string {
    $edit_url = \admin_url( 'admin.php?page=books-edit&id=' . \absint( $item['id'] ) );

    $actions = [
        'edit'   => \sprintf(
            '<a href="%s">%s</a>',
            \esc_url( $edit_url ),
            \esc_html__( 'Edit', 'my-plugin' )
        ),
        'delete' => \sprintf(
            '<a href="%s" onclick="return confirm(\'%s\');">%s</a>',
            \esc_url( \wp_nonce_url(
                \admin_url( 'admin.php?page=books-manager&action=delete&book=' . \absint( $item['id'] ) ),
                'bulk-books'
            ) ),
            \esc_js( \__( 'Are you sure?', 'my-plugin' ) ),
            \esc_html__( 'Delete', 'my-plugin' )
        ),
    ];

    return \sprintf(
        '<strong>%s</strong>%s',
        \esc_html( $item['title'] ),
        $this->row_actions( $actions )
    );
}

5.4 Input Sanitization Checklist

  • \sanitize_text_field() for general text inputs
  • \sanitize_key() for slugs and identifiers
  • \absint() for IDs and numeric values
  • \wp_unslash() before sanitizing $_REQUEST data
  • $wpdb->prepare() for every database query with variable data
  • $wpdb->esc_like() for LIKE clauses before passing to prepare()

6. Screen Options and the Help Tab

Screen options let the user choose how many rows to display. The help tab provides inline documentation. Both must be registered on the load-{page} hook — before any output.

6.1 Registering the Per-Page Screen Option

PHP
class Books_Admin {

    private static string $page_hook = '';

    public static function register_menu(): void {
        self::$page_hook = \add_menu_page(
            \__( 'Books', 'my-plugin' ),
            \__( 'Books', 'my-plugin' ),
            'manage_options',
            'books-manager',
            [ self::class, 'render_page' ],
            'dashicons-book-alt',
            26
        );

        // Hook into the load event for this specific page.
        \add_action( 'load-' . self::$page_hook, [ self::class, 'add_screen_options' ] );
    }

    public static function add_screen_options(): void {
        \add_screen_option( 'per_page', [
            'label'   => \__( 'Books per page', 'my-plugin' ),
            'default' => 20,
            'option'  => 'books_per_page',
        ] );

        self::add_help_tab();
    }
}

WordPress saves the user’s choice via set-screen-option filter automatically for recognized options. If the option is not auto-saved, add:

PHP
\add_filter( 'set-screen-option', static function ( $status, string $option, int $value ) {
    if ( 'books_per_page' === $option ) {
        return $value;
    }
    return $status;
}, 10, 3 );

6.2 Adding a Help Tab

PHP
<?php
private static function add_help_tab(): void {
    $screen = \get_current_screen();

    if ( ! $screen ) {
        return;
    }

    $screen->add_help_tab( [
        'id'      => 'books-overview',
        'title'   => \__( 'Overview', 'my-plugin' ),
        'content' => '' . \esc_html__(
            'This screen displays all books in the system. Use the search box to filter by title, author, or ISBN.',
            'my-plugin'
        ) . '
',
    ] );

    $screen->add_help_tab( [
        'id'      => 'books-bulk-actions',
        'title'   => \__( 'Bulk Actions', 'my-plugin' ),
        'content' => '' . \esc_html__(
            'Select books using the checkboxes, choose an action from the Bulk Actions menu, and click Apply.',
            'my-plugin'
        ) . '
',
    ] );

    $screen->set_help_sidebar(
        '<strong>' . \esc_html__( 'For more information:', 'my-plugin' ) . '</strong>
'
        . '<a href="https://developer.wordpress.org/">' . \esc_html__( 'WordPress Developer Resources', 'my-plugin' ) . '</a>
'
    );
}

7. Essential Hooks

Here are the hooks you should know when working with WP_List_Table:

Hook Type Purpose
admin_menu Action Register your admin page
load-{page_hook} Action Add screen options, help tabs, instantiate the table
set-screen-option Filter Persist custom screen options
manage_{screen_id}_columns Filter Modify visible columns
hidden_columns Filter Set default hidden columns
list_table_primary_column Filter Override the primary column
bulk_actions-{screen_id} Filter Modify bulk actions from outside the class
handle_bulk_actions-{screen_id} Action Process bulk actions from outside the class
{$plural}_table_class Filter Customize the CSS classes on the <table> element

Hook Wiring Example

PHP
// In your plugin or theme functions.php

\add_action( 'admin_menu', [ Books_Admin::class, 'register_menu' ] );

\add_filter( 'set-screen-option', static function ( $status, string $option, int $value ) {
    return 'books_per_page' === $option ? $value : $status;
}, 10, 3 );

// Optionally hide columns by default.
\add_filter( 'hidden_columns', static function ( array $hidden, \WP_Screen $screen ): array {
    if ( 'toplevel_page_books-manager' === $screen->id ) {
        $hidden[] = 'isbn';
    }
    return $hidden;
}, 10, 2 );

8. Bulk Actions and Nonces

8.1 Defining Bulk Actions

Override get_bulk_actions() to define available actions:

PHP
protected function get_bulk_actions(): array {
    return [
        'delete'  => \__( 'Delete', 'my-plugin' ),
        'archive' => \__( 'Archive', 'my-plugin' ),
    ];
}

8.2 The Checkbox Column

The cb column must return a checkbox with the row’s ID:

PHP
protected function column_cb( $item ): string {
    return \sprintf(
        '<input type="checkbox" name="book[]" value="%d" />',
        \absint( $item['id'] )
    );
}

8.3 How the Nonce Works

When display() calls display_tablenav( 'top' ), it automatically outputs:

PHP
// Internally generated by WP_List_Table::display_tablenav()
\wp_nonce_field( 'bulk-books' );
// This creates a hidden field: _wpnonce with action "bulk-books"

The action string is always bulk-{plural} — where {plural} is the value you passed to the constructor.

8.4 Processing the Submission

PHP
public static function handle_page_load(): void {
    $table  = new Books_List_Table();
    $action = $table->current_action();

    if ( $action ) {
        // Nonce verification — dies on failure.
        \check_admin_referer( 'bulk-books' );

        $ids = \array_map( 'absint', (array) ( $_REQUEST['book'] ?? [] ) );

        if ( ! empty( $ids ) ) {
            switch ( $action ) {
                case 'delete':
                    self::delete_books( $ids );
                    break;

                case 'archive':
                    self::archive_books( $ids );
                    break;
            }
        }

        // Redirect to prevent re-submission on refresh.
        \wp_safe_redirect(
            \add_query_arg( 'message', 'done', \remove_query_arg(
                [ '_wp_http_referer', '_wpnonce', 'action', 'action2', 'book' ]
            ) )
        );
        exit;
    }
}

8.5 Single-Row Actions with Nonces

For single-row delete links, create a dedicated nonce per item:

PHP
$delete_url = \wp_nonce_url(
    \admin_url( 'admin.php?page=books-manager&action=delete&book[]=' . \absint( $item['id'] ) ),
    'bulk-books'
);

10. Fully Working Example

Below is a complete, copy-paste-ready implementation. It registers a custom admin page, creates a database table on activation, provides full CRUD list display, search, sortable columns, pagination with screen options, a help tab, bulk delete, and single-row actions. Every custom helper is static; only parent methods are called dynamically.

PHP
<?php
/**
 * Plugin Name: Books Manager
 * Description: A complete WP_List_Table example with static patterns.
 * Version:     1.0.0
 * Author:      Sdobreff
 * Text Domain: books-manager
 *
 * @package wp-list-table
 */

declare( strict_types=1 );

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/*
──────────────────────────────────────────────
 * 1. ACTIVATION — Create the books table.
 * ──────────────────────────────────────────────
 */

\register_activation_hook(
	__FILE__,
	static function (): void {
		global $wpdb;

		$table   = $wpdb->prefix . 'books';
		$charset = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE IF NOT EXISTS {$table} (
        id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        title      VARCHAR(255) NOT NULL DEFAULT '',
        author     VARCHAR(255) NOT NULL DEFAULT '',
        isbn       VARCHAR(20)  NOT NULL DEFAULT '',
        price      DECIMAL(10,2) NOT NULL DEFAULT 0.00,
        genre      VARCHAR(100) NOT NULL DEFAULT '',
        created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY idx_title  (title),
        KEY idx_author (author)
    ) {$charset};";

		require_once \ABSPATH . 'wp-admin/includes/upgrade.php';
		\dbDelta( $sql );

		// Insert sample data if table is empty.
		$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
		if ( 0 === $count ) {
			$books = array(
				array( 'Clean Code', 'Robert C. Martin', '978-0132350884', 34.99, 'programming' ),
				array( 'Design Patterns', 'Gang of Four', '978-0201633610', 44.99, 'programming' ),
				array( 'The Pragmatic Programmer', 'Hunt & Thomas', '978-0135957059', 39.99, 'programming' ),
				array( 'Refactoring', 'Martin Fowler', '978-0134757599', 42.00, 'programming' ),
				array( 'Dune', 'Frank Herbert', '978-0441013593', 14.99, 'fiction' ),
				array( 'Neuromancer', 'William Gibson', '978-0441569595', 12.99, 'fiction' ),
				array( '1984', 'George Orwell', '978-0451524935', 9.99, 'fiction' ),
				array( 'Sapiens', 'Yuval Noah Harari', '978-0062316097', 15.99, 'non-fiction' ),
				array( 'Thinking, Fast and Slow', 'Daniel Kahneman', '978-0374533557', 13.99, 'non-fiction' ),
				array( 'Atomic Habits', 'James Clear', '978-0735211292', 16.99, 'non-fiction' ),
			);

			foreach ( $books as [ $title, $author, $isbn, $price, $genre ] ) {
				$wpdb->insert(
					$table,
					array(
						'title'  => $title,
						'author' => $author,
						'isbn'   => $isbn,
						'price'  => $price,
						'genre'  => $genre,
					),
					array( '%s', '%s', '%s', '%f', '%s' )
				);
			}
		}
	}
);

/*
──────────────────────────────────────────────
 * 2. ADMIN PAGE REGISTRATION
 * ──────────────────────────────────────────────
 */

/**
 * Books Admin class.
 *
 * @since 1.0.0
 */
final class Books_Admin {

	/**
	 * Admin page hook suffix.
	 *
	 * @var string
	 *
	 * @since 1.0.0
	 */
	private static string $page_hook = '';

	/**
	 * Instance of the list table class.
	 *
	 * @var Books_List_Table|null
	 *
	 * @since 1.0.0
	 */
	private static ?object $table = null;

	/* ── Menu ─────────────────────────────── */

	/**
	 * Registers the admin menu page and sets up the load action.
	 */
	public static function register_menu(): void {
		self::$page_hook = \add_menu_page(
			\__( 'Books', 'books-manager' ),
			\__( 'Books', 'books-manager' ),
			'manage_options',
			'books-manager',
			array( self::class, 'render_page' ),
			'dashicons-book-alt',
			26
		);

		\add_action( 'load-' . self::$page_hook, array( self::class, 'on_load' ) );
	}

	/* ── Screen Options & Help ─────────────── */

	/**
	 * Sets up screen options and help tabs when the admin page loads.
	 */
	public static function on_load(): void {
		// Screen option: items per page.
		\add_screen_option(
			'per_page',
			array(
				'label'   => \__( 'Books per page', 'books-manager' ),
				'default' => 20,
				'option'  => 'books_per_page',
			)
		);

		// Help tabs.
		$screen = \get_current_screen();
		if ( $screen ) {
			$screen->add_help_tab(
				array(
					'id'      => 'books-overview',
					'title'   => \__( 'Overview', 'books-manager' ),
					'content' => '' . \esc_html__(
						'This screen lists all books. You can search, sort, filter by genre, and perform bulk actions.',
						'books-manager'
					) . '',
				)
			);
			$screen->add_help_tab(
				array(
					'id'      => 'books-actions',
					'title'   => \__( 'Actions', 'books-manager' ),
					'content' => '' . \esc_html__(
						'Hover over a row to reveal Edit and Delete links. Use checkboxes and the Bulk Actions menu to delete multiple books at once.',
						'books-manager'
					) . '',
				)
			);
			$screen->set_help_sidebar(
				'<strong>' . \esc_html__( 'More info:', 'books-manager' ) . '</strong>'
				. '<a href="https://developer.wordpress.org/">Developer Resources</a>'
			);
		}

		// Instantiate the table early so headers can be set.
		self::$table = new Books_List_Table();

		// Process bulk actions before any output.
		self::process_bulk_action();
	}

	/* ── Bulk Action Processing ───────────── */

	/**
	 * Processes bulk actions for the books list table.
	 */
	private static function process_bulk_action(): void {
		if ( null === self::$table ) {
			return;
		}

		$action = self::$table->current_action();

		if ( ! $action ) {
			return;
		}

		\check_admin_referer( 'bulk-books' );

		if ( ! \current_user_can( 'manage_options' ) ) {
			\wp_die( \esc_html__( 'Unauthorized.', 'books-manager' ) );
		}

		$ids = \array_map( 'absint', (array) ( $_REQUEST['book'] ?? array() ) );

		if ( empty( $ids ) ) {
			return;
		}

		if ( 'delete' === $action ) {
			global $wpdb;
			$table_name   = $wpdb->prefix . 'books';
			$placeholders = \implode( ',', \array_fill( 0, \count( $ids ), '%d' ) );

			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$table_name} WHERE id IN ({$placeholders})",
					...$ids
				)
			);
		}

		\wp_safe_redirect(
			\add_query_arg(
				array(
					'page'    => 'books-manager',
					'deleted' => \count( $ids ),
				),
				\admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/* ── Render ────────────────────────────── */

	/**
	 * Renders the admin page content.
	 *
	 * @noinspection PhpDocMissingThrowsInspection
	 *
	 * @return void
	 */
	public static function render_page(): void {
		if ( ! \current_user_can( 'manage_options' ) ) {
			\wp_die( \esc_html__( 'You do not have permission to access this page.', 'books-manager' ) );
		}

		if ( null === self::$table ) {
			self::$table = new Books_List_Table();
		}

		self::$table->prepare_items();

		echo '<div class="wrap">';
		echo '<h1 class="wp-heading-inline">' . \esc_html__( 'Books', 'books-manager' ) . '</h1>';
		echo '<hr class="wp-header-end">';

		if ( isset( $_GET['deleted'] ) ) {
			$count = \absint( $_GET['deleted'] );
			echo '<div class="notice notice-success is-dismissible">';
			echo \esc_html(
				\sprintf(
					// translators: %d is the number of deleted books.
					\_n( '%d book deleted.', '%d books deleted.', $count, 'books-manager' ),
					$count
				)
			);
			echo '</div>';
		}

		echo '<form method="get">';
		echo '<input type="hidden" name="page" value="' . \esc_attr( $_REQUEST['page'] ?? 'books-manager' ) . '" />';

		self::$table->search_box( \esc_html__( 'Search Books', 'books-manager' ), 'book-search-input' );
		self::$table->display();

		echo '</form>';
		echo '</div>';
	}
}

\add_action( 'admin_menu', array( Books_Admin::class, 'register_menu' ) );

\add_filter(
	'set-screen-option',
	static function ( $status, string $option, int $value ) {
		return 'books_per_page' === $option ? $value : $status;
	},
	10,
	3
);

/*
──────────────────────────────────────────────
 * 3. THE LIST TABLE CLASS
 * ──────────────────────────────────────────────
 */

if ( ! \class_exists( 'WP_List_Table' ) ) {
	require_once \ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

/**
 * Books List Table class.
 *
 * @since 1.0.0
 */
final class Books_List_Table extends \WP_List_Table {

	/* ── Static column definitions ────────── */

	/**
	 * Maps column keys to their labels and defines the checkbox column.
	 *
	 * @var array<string, string>
	 */
	private static array $column_map = array(
		'cb'         => '<input type="checkbox" />',
		'title'      => 'Book Title',
		'author'     => 'Author',
		'isbn'       => 'ISBN',
		'price'      => 'Price',
		'genre'      => 'Genre',
		'created_at' => 'Date Added',
	);

	/**
	 * Defines which columns are sortable and their corresponding database fields.
	 *
	 * @var array<string, array{0: string, 1: bool}>
	 */
	private static array $sortable = array(
		'title'      => array( 'title', false ),
		'author'     => array( 'author', false ),
		'price'      => array( 'price', false ),
		'created_at' => array( 'created_at', true ),
	);

	/**
	 * Defines the available genres for filtering.
	 *
	 * @var array<string, string>
	 */
	private static array $genres = array(
		'programming' => 'Programming',
		'fiction'     => 'Fiction',
		'non-fiction' => 'Non-Fiction',
	);

	/* ── Constructor ──────────────────────── */

	/**
	 * Initializes the list table with singular and plural labels.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'book',
				'plural'   => 'books',
				'ajax'     => false,
			)
		);
	}

	/* ── Columns ──────────────────────────── */

	/**
	 * Returns the column definitions for the list table.
	 *
	 * @return array<string, string>
	 */
	public function get_columns(): array {
		return self::$column_map;
	}

	/**
	 * Returns the sortable columns and their default sort order.
	 *
	 * @return array<string, array{0: string, 1: bool}>
	 */
	protected function get_sortable_columns(): array {
		return self::$sortable;
	}

	/**
	 * Returns the name of the primary column, which is used for row actions.
	 *
	 * @return string
	 */
	protected function get_default_primary_column_name(): string {
		return 'title';
	}

	/* ── Bulk Actions ─────────────────────── */

	/**
	 * Returns the available bulk actions for the list table.
	 *
	 * @return array<string, string>
	 */
	protected function get_bulk_actions(): array {
		return array(
			'delete' => \__( 'Delete', 'books-manager' ),
		);
	}

	/* ── Checkbox Column ──────────────────── */

	/**
	 * Renders the checkbox for bulk actions in each row.
	 *
	 * @param array $item The current item being rendered.
	 * @return string The HTML for the checkbox input.
	 */
	protected function column_cb( $item ): string {
		return \sprintf(
			'<input type="checkbox" name="book[]" value="%d" />',
			\absint( $item['id'] )
		);
	}

	/* ── Column: Title (primary) ──────────── */

	/**
	 * Renders the Title column, which includes the book title and row actions.
	 *
	 * @param array $item The current item being rendered.
	 * @return string The HTML for the Title column.
	 */
	protected function column_title( $item ): string {
		$delete_url = \wp_nonce_url(
			\admin_url( 'admin.php?page=books-manager&action=delete&book[]=' . \absint( $item['id'] ) ),
			'bulk-books'
		);

		$actions = array(
			'delete' => \sprintf(
				'<a href="%s" onclick="return confirm(\'%s\');">%s</a>',
				\esc_url( $delete_url ),
				\esc_js( \__( 'Are you sure you want to delete this book?', 'books-manager' ) ),
				\esc_html__( 'Delete', 'books-manager' )
			),
		);

		return \sprintf(
			'<strong>%s</strong>%s',
			\esc_html( $item['title'] ),
			$this->row_actions( $actions )
		);
	}

	/* ── Column: Price (formatted) ────────── */

	/**
	 * Renders the Price column, formatting the price as currency.
	 *
	 * @param array $item The current item being rendered.
	 * @return string The formatted price.
	 */
	protected function column_price( $item ): string {
		return \esc_html( '$' . \number_format( (float) $item['price'], 2 ) );
	}

	/* ── Column: Date (formatted) ─────────── */

	/**
	 * Renders the Date Added column, formatting the date according to WordPress settings.
	 *
	 * @param array $item The current item being rendered.
	 * @return string The formatted date.
	 */
	protected function column_created_at( $item ): string {
		return \esc_html(
			\date_i18n(
				\get_option( 'date_format' ),
				\strtotime( $item['created_at'] )
			)
		);
	}

	/* ── Default Column ───────────────────── */

	/**
	 * Renders the default columns (Author, ISBN, Genre) by escaping their values.
	 *
	 * @param array  $item The current item being rendered.
	 * @param string $column_name The name of the column being rendered.
	 * @return string The escaped value for the column.
	 */
	protected function column_default( $item, $column_name ): string {
		return \esc_html( (string) ( $item[ $column_name ] ?? '' ) );
	}

	/* ── No Items Message ─────────────────── */

	/**
	 * Displays a message when no items are found in the list table.
	 */
	public function no_items(): void {
		\esc_html_e( 'No books found.', 'books-manager' );
	}

	/* ── Extra Tablenav: Genre Filter ─────── */

	/**
	 * Adds a genre filter dropdown above the list table.
	 *
	 * @param string $which The location of the extra tablenav (top or bottom).
	 */
	protected function extra_tablenav( $which ): void {
		if ( 'top' !== $which ) {
			return;
		}

		$current = isset( $_REQUEST['genre'] ) ? \sanitize_key( $_REQUEST['genre'] ) : '';

		echo '<div class="alignleft actions">';
		echo '<select name="genre">';
		echo '<option value="">' . \esc_html__( 'All Genres', 'books-manager' ) . '</option>';

		foreach ( self::$genres as $slug => $label ) {
			\printf(
				'<option value="%s" %s>%s</option>',
				\esc_attr( $slug ),
				\selected( $current, $slug, false ),
				\esc_html( $label )
			);
		}

		echo '</select>';
		\submit_button( \__( 'Filter', 'books-manager' ), '', 'filter_action', false );
		echo '</div>';
	}

	/*
	──────────────────────────────────────
	 * DATA LAYER — all static helpers.
	 * ──────────────────────────────────────
	 */

	/**
	 * Prepares the items for display in the list table, including pagination and sorting.
	 */
	public function prepare_items(): void {
		$per_page     = $this->get_items_per_page( 'books_per_page', 20 );
		$current_page = $this->get_pagenum();

		$search  = isset( $_REQUEST['s'] ) ? \sanitize_text_field( \wp_unslash( $_REQUEST['s'] ) ) : '';
		$orderby = isset( $_REQUEST['orderby'] ) ? \sanitize_key( $_REQUEST['orderby'] ) : 'created_at';
		$order   = isset( $_REQUEST['order'] ) && 'asc' === $_REQUEST['order'] ? 'ASC' : 'DESC';
		$genre   = isset( $_REQUEST['genre'] ) ? \sanitize_key( $_REQUEST['genre'] ) : '';

		$result = self::query_books( $search, $orderby, $order, $genre, $per_page, $current_page );

		$this->items = $result['items'];

		$this->set_pagination_args(
			array(
				'total_items' => $result['total'],
				'per_page'    => $per_page,
				'total_pages' => (int) \ceil( $result['total'] / $per_page ),
			)
		);

		$this->_column_headers = $this->get_column_info();
	}

	/**
	 * Queries the books from the database based on search, sorting, and filtering parameters.
	 *
	 * @param string $search The search term to filter books by title, author, or ISBN.
	 * @param string $orderby The column to sort by (title, author, price, created_at).
	 * @param string $order The sort direction (ASC or DESC).
	 * @param string $genre The genre to filter by (programming, fiction, non-fiction).
	 * @param int    $per_page The number of items to display per page.
	 * @param int    $current_page The current page number for pagination.
	 *
	 * @return array{items: array, total: int} An array containing the queried items and the total count of matching records.
	 */
	private static function query_books(
		string $search,
		string $orderby,
		string $order,
		string $genre,
		int $per_page,
		int $current_page
	): array {
		global $wpdb;

		$table = $wpdb->prefix . 'books';

		// Whitelist sortable columns to prevent SQL injection.
		$allowed_orderby = array( 'title', 'author', 'price', 'created_at' );
		if ( ! \in_array( $orderby, $allowed_orderby, true ) ) {
			$orderby = 'created_at';
		}
		$order = 'ASC' === $order ? 'ASC' : 'DESC';

		// Build WHERE clauses.
		$where = array();
		$args  = array();

		if ( '' !== $search ) {
			$like    = '%' . $wpdb->esc_like( $search ) . '%';
			$where[] = '( title LIKE %s OR author LIKE %s OR isbn LIKE %s )';
			$args[]  = $like;
			$args[]  = $like;
			$args[]  = $like;
		}

		if ( '' !== $genre && isset( self::$genres[ $genre ] ) ) {
			$where[] = 'genre = %s';
			$args[]  = $genre;
		}

		$where_sql = ! empty( $where ) ? 'WHERE ' . \implode( ' AND ', $where ) : '';

		// Total count.
		$count_sql   = "SELECT COUNT(*) FROM {$table} {$where_sql}";
		$total_items = empty( $args )
			? (int) $wpdb->get_var( $count_sql )
			: (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$args ) );

		// Data query.
		$offset   = ( $current_page - 1 ) * $per_page;
		$data_sql = "SELECT * FROM {$table} {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
		$args[]   = $per_page;
		$args[]   = $offset;

		$items = $wpdb->get_results( $wpdb->prepare( $data_sql, ...$args ), ARRAY_A );

		return array(
			'items' => \is_array( $items ) ? $items : array(),
			'total' => $total_items,
		);
	}
}

How to Test This Example

  1. Save the code above as books-manager.php in your wp-content/plugins/ directory.
  2. Activate the plugin from Plugins → Installed Plugins.
  3. Navigate to the new Books menu item in the admin sidebar.
  4. You will see 10 sample books, full pagination, search, genre filter, sortable columns, bulk delete, and screen options.
  5. Open Screen Options (top-right) to set items per page.
  6. Click the Help tab (top-right) to see the inline documentation.

Summary

WP_List_Table gives you a production-ready admin table with minimal code. Keep your extending classes statically structured — static properties for configuration, static methods for data operations, and only use $this for inherited parent methods. Always verify nonces, check capabilities, escape output, and sanitize input. Register screen options and help tabs on the load-{page} hook, and instantiate your table class before any output begins.

With these patterns, your custom admin tables will look native, behave consistently, and remain secure.

What is WP_List_Table in WordPress?

WP_List_Table is an internal WordPress class used to create admin tables in the dashboard, including posts, users, and plugins.

Is WP_List_Table officially supported?

WP_List_Table is a private API and not officially supported, but it is widely used and stable across WordPress versions.

How do I extend WP_List_Table in WordPress?

You extend WP_List_Table by creating a custom class, defining required methods, and rendering the table inside a WordPress admin page.

← What is global-styles-inline-css in WordPress? How to Hide or Change wp-json in WordPress (Without Breaking Your Site) →
Share this page
Back to top