WordPress’s built-in admin list screens — All Posts, All Pages, Users, Comments — are all powered by an internal class called WP_List_Table. It handles column registration, sortable column links, bulk action dropdowns, per-page screen options, and pagination automatically. You can extend it to build your own admin screens that look and behave identically to WordPress’s native list tables: the same zebra-stripe rows, the same bulk action checkbox logic, and the same URL-based sorting and pagination. The class is documented as private API (the constructor accepts a _doing_it_wrong() notice if instantiated directly from outside WordPress), but the standard approach — creating a subclass — is widely used and stable across WordPress versions. This article shows a complete implementation for displaying a custom database table as a sortable, paginatable admin list.
Problem: You have a custom wp_form_leads database table and want to display its records in an admin screen with sortable columns, bulk delete, and per-page control — matching the look of WordPress's native list tables.
Solution: Extend WP_List_Table, override get_columns(), get_sortable_columns(), column_default(), prepare_items(), and register the page with add_menu_page().
<?php
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
class Leads_List_Table extends WP_List_Table {
public function get_columns() {
return [
'cb' => '<input type="checkbox">',
'name' => __( 'Name', 'textdomain' ),
'email' => __( 'Email', 'textdomain' ),
'created_at' => __( 'Date', 'textdomain' ),
];
}
public function get_sortable_columns() {
return [
'name' => [ 'name', false ],
'created_at' => [ 'created_at', true ], // true = default sort descending
];
}
public function column_default( $item, $column_name ) {
return esc_html( $item[ $column_name ] ?? '' );
}
// Checkbox column for bulk actions
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="lead_ids[]" value="%d">', (int) $item['id'] );
}
// Name column with row actions
public function column_name( $item ) {
$actions = [
'delete' => sprintf(
'<a href="%s" onclick="return confirm('Delete?')">%s</a>',
esc_url( wp_nonce_url(
add_query_arg( [ 'action' => 'delete', 'id' => $item['id'] ] ),
'delete_lead_' . $item['id']
) ),
__( 'Delete', 'textdomain' )
),
];
return esc_html( $item['name'] ) . $this->row_actions( $actions );
}
public function get_bulk_actions() {
return [ 'bulk_delete' => __( 'Delete', 'textdomain' ) ];
}
public function prepare_items() {
global $wpdb;
$table = $wpdb->prefix . 'form_leads';
$per_page = $this->get_items_per_page( 'leads_per_page', 20 );
$current = $this->get_pagenum();
$orderby = sanitize_sql_orderby( $_REQUEST['orderby'] ?? 'created_at' ) ?: 'created_at';
$order = ( strtoupper( $_REQUEST['order'] ?? 'DESC' ) === 'ASC' ) ? 'ASC' : 'DESC';
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table" );
$this->items = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM $table ORDER BY $orderby $order LIMIT %d OFFSET %d",
$per_page, ( $current - 1 ) * $per_page
), ARRAY_A );
$this->set_pagination_args( [
'total_items' => $total,
'per_page' => $per_page,
'total_pages' => ceil( $total / $per_page ),
] );
$this->_column_headers = [ $this->get_columns(), [], $this->get_sortable_columns() ];
}
}
// Register the admin page
add_action( 'admin_menu', function () {
add_menu_page(
'Form Leads', 'Form Leads', 'manage_options',
'form-leads', 'render_leads_page', 'dashicons-email', 25
);
} );
function render_leads_page() {
$table = new Leads_List_Table();
$table->prepare_items();
echo '<div class="wrap"><h1>' . esc_html__( 'Form Leads', 'textdomain' ) . '</h1>';
echo '<form method="post">';
$table->display();
echo '</form></div>';
}
NOTE: WP_List_Table is marked as private API and the constructor emits a _doing_it_wrong() notice if called directly. Always subclass it rather than instantiating it directly. The require_once guard at the top is necessary because class-wp-list-table.php is only auto-loaded on admin list screens — your custom page needs to load it manually. Also, the orderby value from $_REQUEST must be sanitized with sanitize_sql_orderby() or validated against a whitelist before use in a query to prevent SQL injection.