GeneratorsAdminList Table

List Table Generator

A WP_List_Table subclass with sortable columns, row and bulk actions, search and the admin page that renders it.

class-acme_briefs_table.php
Saved just now
The table
Columns
4 columns · 3 sortable
Titletitle
SortablePrimary
Authorauthor
SortablePrimary
Statusstatus
SortablePrimary
Createdcreated
SortablePrimary
Actions
2 row · 1 bulk · 3 views
Table extras
Search box
search_box() above the table, wired to the s parameter.
Per-page screen option
add_screen_option plus the set-screen-option filter that lets it save.
<?php
// Generated with WP CodeKit — powered by GrowQuest (https://growquest.io).
/**
 * Plugin Name:       Briefs list table
 * Description:       An admin table of briefs with sorting, search and bulk actions.
 * Version:           1.0.0
 * Requires PHP:      7.4
 */

defined( 'ABSPATH' ) || exit;

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

/**
 * A table of briefs.
 */
class Acme_Briefs_Table extends WP_List_Table {

	/**
	 * Tell the parent what one row is called.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'brief',
				'plural'   => 'briefs',
				'ajax'     => false,
			)
		);
	}

	/**
	 * The columns, in order.
	 *
	 * @return array
	 */
	public function get_columns() {
		return array(
			'cb'      => '<input type="checkbox" />',
			'title'   => __( 'Title', 'acme' ),
			'author'  => __( 'Author', 'acme' ),
			'status'  => __( 'Status', 'acme' ),
			'created' => __( 'Created', 'acme' ),
		);
	}

	/**
	 * Which columns can be sorted, and which is sorted by default.
	 *
	 * @return array
	 */
	public function get_sortable_columns() {
		return array(
			'title'   => array( 'title', true ),
			'status'  => array( 'status', false ),
			'created' => array( 'created', false ),
		);
	}

	/**
	 * Which column carries the row actions.
	 *
	 * @return string
	 */
	protected function get_default_primary_column_name() {
		return 'title';
	}

	/**
	 * The checkbox column.
	 *
	 * @param array $item One row.
	 * @return string
	 */
	public function column_cb( $item ) {
		return sprintf(
			'<input type="checkbox" name="briefs[]" value="%s" />',
			esc_attr( $item['id'] )
		);
	}

	/**
	 * The bulk actions dropdown.
	 *
	 * @return array
	 */
	public function get_bulk_actions() {
		return array(
			'delete' => __( 'Delete permanently', 'acme' ),
		);
	}

	/**
	 * Run a bulk action. Called before anything is fetched.
	 */
	protected function process_bulk_action() {
		$action = $this->current_action();

		if ( ! $action ) {
			return;
		}

		check_admin_referer( 'bulk-' . $this->_args['plural'] );

		if ( ! current_user_can( 'edit_others_posts' ) ) {
			wp_die( esc_html__( 'You are not allowed to do that.', 'acme' ) );
		}

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

		if ( ! $ids ) {
			return;
		}

		switch ( $action ) {
			case 'delete':
				// Delete permanently — act on $ids here.
				break;
		}
	}

	/**
	 * The status links above the table.
	 *
	 * @return array
	 */
	protected function get_views() {
		$current = isset( $_REQUEST['status'] ) ? sanitize_key( $_REQUEST['status'] ) : 'all';
		$base    = remove_query_arg( array( 'status', 'paged' ) );
		$views   = array();

		$views['all'] = sprintf(
			'<a href="%1$s"%2$s>%3$s</a>',
			esc_url( add_query_arg( 'status', 'all', $base ) ),
			'all' === $current ? ' class="current"' : '',
			esc_html( __( 'All', 'acme' ) )
		);

		$views['draft'] = sprintf(
			'<a href="%1$s"%2$s>%3$s</a>',
			esc_url( add_query_arg( 'status', 'draft', $base ) ),
			'draft' === $current ? ' class="current"' : '',
			esc_html( __( 'Drafts', 'acme' ) )
		);

		$views['review'] = sprintf(
			'<a href="%1$s"%2$s>%3$s</a>',
			esc_url( add_query_arg( 'status', 'review', $base ) ),
			'review' === $current ? ' class="current"' : '',
			esc_html( __( 'In review', 'acme' ) )
		);

		return $views;
	}

	/**
	 * The primary column, with its row actions.
	 *
	 * @param array $item One row.
	 * @return string
	 */
	public function column_title( $item ) {
		$actions = array(
			'edit' => sprintf(
				'<a href="%1$s">%2$s</a>',
				esc_url( wp_nonce_url( add_query_arg( array( 'action' => 'edit', 'brief' => $item['id'] ) ), 'acme_edit_' . $item['id'] ) ),
				esc_html( __( 'Edit', 'acme' ) )
			),
			'delete' => sprintf(
				'<a href="%1$s" class="submitdelete">%2$s</a>',
				esc_url( wp_nonce_url( add_query_arg( array( 'action' => 'delete', 'brief' => $item['id'] ) ), 'acme_delete_' . $item['id'] ) ),
				esc_html( __( 'Delete', 'acme' ) )
			),
		);

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

	/**
	 * @param array $item One row.
	 * @return string
	 */
	public function column_status( $item ) {
		return sprintf(
			'<span class="acme-status acme-status--%1$s">%2$s</span>',
			esc_attr( $item['status'] ),
			esc_html( ucfirst( $item['status'] ) )
		);
	}

	/**
	 * @param array $item One row.
	 * @return string
	 */
	public function column_created( $item ) {
		return esc_html( mysql2date( get_option( 'date_format' ), $item['created'] ) );
	}

	/**
	 * Anything without its own column_ method lands here.
	 *
	 * @param array  $item        One row.
	 * @param string $column_name The column.
	 * @return string
	 */
	public function column_default( $item, $column_name ) {
		return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '';
	}

	/**
	 * Shown when there is nothing to list.
	 */
	public function no_items() {
		esc_html_e( 'No briefs yet.', 'acme' );
	}

	/**
	 * Fetch, paginate and hand the rows to the parent.
	 *
	 * Order matters: bulk action, then fetch, then pagination, then items.
	 */
	public function prepare_items() {
		$this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns() );

		$this->process_bulk_action();

		$per_page = $this->get_items_per_page( 'acme_briefs_per_page', 20 );
		$paged    = $this->get_pagenum();
		$offset   = ( $paged - 1 ) * $per_page;

		global $wpdb;

		$table   = $wpdb->prefix . 'acme_briefs';
		$orderby = isset( $_REQUEST['orderby'] ) ? sanitize_key( $_REQUEST['orderby'] ) : 'title';
		$order   = isset( $_REQUEST['order'] ) && 'desc' === strtolower( $_REQUEST['order'] ) ? 'DESC' : 'ASC';
		$search  = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';

		// Only ever sort by a column you declared sortable.
		$allowed = array_keys( $this->get_sortable_columns() );

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

		$where = 'WHERE 1=1';
		$args  = array();

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

		$args[] = $per_page;
		$args[] = $offset;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$sql = "SELECT * FROM {$table} {$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";

		$items = $wpdb->get_results( $wpdb->prepare( $sql, $args ), ARRAY_A );
		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" );

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

		$this->items = $items;
	}
}

About this generator

List Table Generator Online

A complete WP_List_Table example, generated from your columns. You get the subclass — get_columns(), get_sortable_columns(), column_cb(), get_bulk_actions(), process_bulk_action(), get_views(), no_items(), prepare_items() and a column_ method per typed column — plus the admin page that instantiates it, adds the per-page screen option and whitelists it through set-screen-option. Rows can come from a custom $wpdb table, a post type, or a plain PHP array.

The Preview tab renders the finished screen with your columns, sort arrows, status links and bulk-action dropdown against sample rows, so you can judge the layout before writing a line of SQL. Output the class or the admin page. Free, no account, and nothing you enter leaves the browser.

Reviewed Output tested on WordPress 6.8 · PHP 8.3

Why this WP_List_Table example beats the one you found on a blog

WP_List_Table is marked @access private in core and is not autoloaded, so the first thing any working example needs is a guarded require_once of wp-admin/includes/class-wp-list-table.php. After that come the parts people leave out: sorting has to be whitelisted against get_sortable_columns() before it reaches SQL, bulk actions need check_admin_referer( 'bulk-' . $plural ), and the search box only works when the table lives inside a form that carries the page parameter.

The require_once guard, written in

Every generated class file opens with if ( ! class_exists( 'WP_List_Table' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; }. Without it you get a fatal error the moment your screen loads outside the exact admin context that happened to include the file.

Sorting whitelisted before it touches SQL

The $wpdb source reads $_REQUEST['orderby'] through sanitize_key() and then checks it against array_keys( $this->get_sortable_columns() ), falling back to your first sortable column. That whitelist is the thing that makes interpolating a column name into the query safe.

Bulk actions with the nonce core expects

process_bulk_action() calls check_admin_referer( 'bulk-' . $this->_args['plural'] ) — the exact nonce action core's own bulk form emits — then a current_user_can() check, then array_map( 'absint', … ) over the submitted ids. A bulk delete behind an unguarded GET is the classic list-table vulnerability.

Typed columns get their own method

A date column renders through mysql2date( get_option( 'date_format' ) ), a number through number_format_i18n(), a status as a prefixed badge span. Everything else falls through column_default(), which escapes with esc_html().

Row actions on the primary column

The column you mark primary gets the row_actions() treatment with nonced links per action, and get_default_primary_column_name() is emitted so the mobile toggle has something to expand. Marking two columns primary is caught as an error.

Screen options wired both ways

The admin page output registers add_screen_option( 'per_page', … ) on the load-{hook} action and adds the set-screen-option filter that allows your option name through. Miss the filter and the user's choice is silently discarded on every save.

How does the List Table Generator work?

Four steps: name the thing you are listing, define the columns, add the actions, then export the class and its page.

  1. 01

    Name the table and its source

    Set the singular and plural labels — the plural becomes the bulk-action field name and the nonce, so it matters — plus the class name, the function prefix and the capability. Then choose a custom $wpdb table, a post type, or a PHP array as the row source.

  2. 02

    Define the columns

    Add each column with a key, a label and a type: text, number, date, link or status badge. Mark which ones are sortable and which single column is primary. The first sortable column becomes the default sort.

  3. 03

    Add row actions, bulk actions and views

    Row actions are the hover links under the primary column, bulk actions fill the dropdown above it, views are the status links across the top. All three take a comma-separated value:Label list.

  4. 04

    Switch output and export

    Turn the search box and per-page screen option on, clear the checks, then copy the class from the first tab and the admin page from the second — they are two files and you need both.

Worked example — the admin page that renders the table

The second output mode. The table has to be instantiated and prepare_items() called before display(), and the whole thing must sit inside a form that carries the page parameter or search and bulk actions lose their way back.

/**
 * The admin page that renders the table.
 */
function acme_render_briefs_page() {
	if ( ! current_user_can( 'edit_others_posts' ) ) {
		return;
	}

	$table = new Acme_Briefs_Table();
	$table->prepare_items();
	?>
	<div class="wrap">
		<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
		<form method="get">
			<input type="hidden" name="page" value="acme-briefs" />
			<?php
			$table->views();
			$table->search_box( __( 'Search briefs', 'acme' ), 'acme-search' );
			$table->display();
			?>
		</form>
	</div>
	<?php
}

The class tab holds Acme_Briefs_Table extends WP_List_Table itself, opening with the guarded require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php' and closing with a prepare_items() that sets $this->_column_headers, runs the bulk action, fetches, then calls set_pagination_args() — in that order, because the parent reads them in that order.

WP_List_Table — frequently asked questions

What developers hit when building a custom admin table on top of WP_List_Table.

Why do I get "Class WP_List_Table not found"?

WP_List_Table lives in wp-admin/includes/class-wp-list-table.php and is not loaded on every admin request. Your file has to require it itself: if ( ! class_exists( 'WP_List_Table' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; }. Guarding with class_exists() matters because on some screens core has already loaded it.

Is it safe to use WP_List_Table when core marks it @access private?

It is used by thousands of plugins and has been stable for years, but core reserves the right to change it and gives no backwards-compatibility promise. The practical advice is to use it, keep your subclass thin, and re-test the screen after every major WordPress release rather than assuming it still works.

How do I make columns sortable in WP_List_Table?

Return them from get_sortable_columns() as 'column_key' => array( 'orderby_value', $is_default_sort ), and set $this->_column_headers in prepare_items() with the sortable array as its third element. Then read $_REQUEST['orderby'] in your query — but check it against array_keys( $this->get_sortable_columns() ) first, because it is user input heading for an ORDER BY clause.

Why is my search box not filtering anything?

Two things have to be true. search_box() must be called inside a <form method="get"> that also carries a hidden page input, or the submission navigates away from your screen. And prepare_items() has to actually read $_REQUEST['s'] and apply it — core renders the box but never touches your query.

How do I add bulk actions to a WP_List_Table?

Return a value => Label array from get_bulk_actions(), add a cb column whose column_cb() prints a checkbox named after your plural, then handle it in process_bulk_action() before you fetch. Core's bulk form nonce is bulk-{plural}, so verify with check_admin_referer( 'bulk-' . $this->_args['plural'] ) and follow it with a capability check.

Why does the per-page screen option not save?

Registering the option with add_screen_option() is only half of it. WordPress will not persist a screen option unless a set-screen-option filter (or the newer set_screen_option_{$option} filter) returns the value for your option name. Without that filter the save is silently dropped and get_items_per_page() keeps returning your default.

Where do I paste the code from the List Table generator?

You have three good options and one bad one. Best is a small site-specific plugin — the generator can output one for you, and it keeps working when you change theme. A code-snippets plugin is fine too. A child theme's functions.php works but ties the code to that theme. Never edit the parent theme: the next update overwrites it and your code is gone.

Can I use the generated code in client work or a commercial plugin?

Yes. The output is ordinary WordPress API calls that you configured — there is no licence attached to it and no attribution required. Use it in client sites, commercial plugins and themes, or products you sell. The generated file carries a short credit comment you are free to delete.