This WordPress tutorial shows how to implement a list table using the WP_List_Table class, with features like searching, sorting, and pagination. At Diversify India, we collect subscriber email addresses using a newsletter subscription form located at the bottom of our pages. To manage and display these email addresses, we utilize the WP_List_Table class. By following this tutorial, you’ll be able to easily implement a similar list table, as demonstrated in the following image.

WP List Table - Tutorial
List table implemented using the WP List Table class

In this tutorial, we will cover the following points:

  1. Screen option to enable or disable a specific column.
  2. Screen option to adjust the number of entries displayed per page.
  3. Implementing a search bar to filter data.
  4. Sortable columns to perform sorting in ascending or descending order.
  5. Bulk actions for performing bulk operations, such as deleting selected entries from the database.
  6. Custom action on hover for adding actions like deleting an entry from the database upon hovering.
  7. Adding custom actions in the extra table navigation section, such as clearing all data from the database.
  8. Implementing pagination for the list table.

Adding Code to the functions.php File

The most effective way to add the list table code is by creating a separate plugin or using the Code Snippets plugin, ensuring that the functionality persists even after theme updates. For simplicity, this tutorial will use the functions.php file. Be sure to insert the code in the functions.php file of a child theme to prevent losing your customizations when updating your theme.

Creating a Menu Page

First, we need to create a menu page to display our list table. This can be done using the admin_menu hook. Add the following code to your functions.php file to create a custom page within the WordPress admin area:

add_action('admin_menu', 'my_subscribers');

function my_subscribers() {
    add_menu_page(__('Subscribers', 'theme-name'), __('Subscribers', 'theme-name'), 'manage_options', 'subscribers', 'init_subscribers', 'dashicons-email', 110);
}

function init_subscribers() {
    echo "My Subscribers";
}

Inherit the WP_List_Table class

WordPress doesn’t load the WP_List_Table class automatically, so we need to ensure it’s available by including the class-wp-list-table.php file in our code. This can be done by checking if the class exists before including the file. Then we have to extend this class and override the prepare_items and get_columns methods. Also, ensure that the get_columns method returns an array.

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

// extend WP_List_Table
class Subscribers_List_Table extends WP_List_Table {
    public function __construct( $args = array() ) {
        if(empty($args)) {
            $args = array(
                'plural'   => __('subscribers', 'theme-name'),
                'singular' => __('subscriber', 'theme-name'),
            );
        }
        parent::__construct($args);
    }

    // override prepare_items() and get_columns() method
    public function prepare_items() {}
    public function get_columns() {
        return array(); // Ensure this method returns an array
    }
}

In the init_subscribers method (created in the previous step), instantiate your class and pass an array with keys plural and singular. The values of these keys will be used for labels, CSS class names, and the objects being listed. Then, call the prepare_items method to prepare the list of items and the display method to render the table.

function init_subscribers() {
    echo "My Subscribers";

    // create an instance of class
    $table = new Subscribers_List_Table(array(
        'plural'   => __('subscribers', 'theme-name'),
        'singular' => __('subscriber', 'theme-name'),
    ));

    $table->prepare_items();
    $table->display();
}

If you run this program, you will see an empty table with the message No items found.

Displaying Columns

First of all, create a database table named $wpdb->prefix . 'subscribers' with the following columns:

idsubscribers_emaildate_of_subscription
1admin@diversifyindia.in10-12-2023
2contact@diversifyindia.in03-05-2024
Table for storing subscriber’s data

To display column names in the header and footer of the table, return an associative array from the get_columns method. If the checkbox is missing in the thead and tfoot sections, ensure the array key cb is set in the get_columns method, as this renders the checkbox. Replace the get_columns method, which was overridden in the previous step, with the following code:

public function get_columns() {
    return array(
        'cb' => '<input type="checkbox" />',  // display checkbox
        'subscribers-email' => __('Subscribers Email', 'theme-name'),
        'date' => __('Date', 'theme-name')
    );
}

Adding Sortable Columns

To implement sorting, override the get_sortable_columns method of the WP_List_Table class. The first parameter of an array should match the column name in your database, as it will be used in the ORDER BY clause.

protected function get_sortable_columns() {
    return array(
        'date' => array('date_of_subscription', true, __( 'Date' ), __( 'Table ordered by Date.' ))
    );
}

In the above code, date is the column name assigned to the date_of_subscription column in our database. By passing true as the second parameter, the initial sorting order for this column will be set to descending.

To display our columns, we need to set the value of the _column_headers property of the WP_List_Table class. This property is configured in the prepare_items method. The _column_headers property accepts an array with three elements:

  1. An array of column names.
  2. An array of hidden columns, which can be set through screen options.
  3. An array of sortable columns.

Replace the prepare_items method from step 3 with the following code:

public function prepare_items() {
    $meta_key = 'managetoplevel_page_' . $_REQUEST['page'] . 'columnshidden';
    // Retrieves preferences related to hidden columns from the usermeta column of the database
    $hidden = (is_array(get_user_meta(get_current_user_id(), $meta_key, true))) ? get_user_meta(get_current_user_id(), $meta_key, true) : array();

    $this->_column_headers = [
        $this->get_columns(),
        $hidden,
        $this->get_sortable_columns(),
    ];
}

After refreshing the page, you should see the column names along with arrows indicating sortable columns.

Adding Support for Screen Options

To add support for screen options, you need to modify the my_subscribers and init_subscribers methods defined in the creating a menu page step.

function my_subscribers() {
    // create global page variable
    global $subscribers_page;

    // store page value return from add_menu_page() into global page var
    $subscribers_page = add_menu_page(__('Subscribers', 'theme-name'), __('Subscribers', 'theme-name'), 'manage_options', 'subscribers', 'init_subscribers', 'dashicons-email', 110);

    add_action("load-$subscribers_page", "subscribers_screen_options");
}

function init_subscribers() {
    // define $table as a global variable
    global $table;

    echo '<div class="wrap"><h1 class="wp-heading-inline">My Subscribers</h1>';
    $table->prepare_items();

    $table->display();
    echo '</div>';
}

Then, define the subscribers_screen_options callback method, used by the load-$subscribers_page hook.

function subscribers_screen_options() {
    // declare $subscribers_page and $table as a global variable
    global $subscribers_page, $table;

    // return if not on our settings page
    $screen = get_current_screen();
    if(!is_object($screen) || $screen->id !== $subscribers_page) {
        return;
    }

    $args = array(
        'label' => __('Subscribers per page', 'theme-name'),
        'default' => 20,
        'option' => 'subscribers_per_page'
    );
    add_screen_option('per_page', $args);

    // create an instance of class
    $table = new Subscribers_List_Table(array(
        'plural'   => __('subscribers', 'theme-name'),
        'singular' => __('subscriber', 'theme-name'),
    ));
}

Now verify that the screen options and the show/hide columns functionality work correctly.

Implementing Pagination

Now we will add functionality to fetch data from the database with pagination support. To do this, call the get_table_data function within the prepare_items method. Paste the following code in step 3 and replace the prepare_items method with it:

private function get_table_data($args = array()) {
    global $wpdb;
    $orderby = isset( $_REQUEST['orderby'] ) ? $_REQUEST['orderby'] : 'id';
    $order   = isset( $_REQUEST['order'] ) ? $_REQUEST['order'] : 'desc';

    return $wpdb->get_results("SELECT * FROM " . $wpdb->prefix . "subscribers ORDER BY {$orderby} {$order} LIMIT {$args['offset']}, {$args['per_page']}");
}

private function get_total_items() {
    global $wpdb;
    return (int) $wpdb->get_var("SELECT COUNT(`id`) FROM " . $wpdb->prefix . "subscribers");
}

public function prepare_items() {
    $meta_key = 'managetoplevel_page_' . $_REQUEST['page'] . 'columnshidden';
    // Retrieves preferences related to hidden columns from usermeta column of database
    $hidden = (is_array(get_user_meta(get_current_user_id(), $meta_key, true))) ? get_user_meta(get_current_user_id(), $meta_key, true) : array();

    $this->_column_headers = [
        $this->get_columns(),
        $hidden,
        $this->get_sortable_columns(),
    ];

    // get per page value from screen options
    $items_per_page = $this->get_items_per_page('subscribers_per_page');

    // get total number of items from database
    $total_items = $this->get_total_items();

    // get current page number
    // get_pagenum() method is available in WP_List_Table class
    $page = $this->get_pagenum();

    // calculate offset
    $offset = ( $page - 1 ) * $items_per_page;

    // call set_pagination_args() method to add pagination support
    $this->set_pagination_args(
        array(
            'total_items' => $total_items,
            'per_page'    => $items_per_page,
        )
    );

    // fetch data from database
    $this->items = $this->get_table_data(array(
        'offset' => $offset,
        'per_page' => $items_per_page
    ));
}

If the table appears empty, override the column_default method of the WP_List_Table class to fix this issue.

protected function column_default($item, $column_name) {
    if($column_name === 'subscribers-email') {
        // items key name should match with database column name
        return $item->subscribers_email;
    } elseif($column_name === 'date') {
        return $item->date_of_subscription;
    } else {
        return $item->{$column_name};
    }
}

Now that our data is showing up in the table, you may notice that the checkbox is missing. To display this checkbox, override the column_cb method of the WP_List_Table class.

protected function column_cb($item) {
    return '<input id="cb-select-' . $item->id . '" type="checkbox" name="subscribers[]" value="' . $item->id . '" />';
}

Now check whether all column’s data is displaying correctly and verify if pagination is working. If you notice that the pagination form is not functioning, enclose the table within a <form> tag. Replace the init_subscribers method from the create a menu page step with the following code:

function init_subscribers() {
    // define $table as a global variable
    global $table;

    echo '<div class="wrap"><h1 class="wp-heading-inline">My Subscribers</h1>';
    echo '<form id="subscribers-form" method="post">';
    $table->prepare_items();

    $table->display();
    echo '</form></div>';
}

Adding Bulk Actions

To add bulk action support, override the get_bulk_actions method of the WP_List_Table class.

protected function get_bulk_actions() {
    return array('delete_all' => __('Delete permanently', 'theme-name'));
}

Adding Custom Buttons to Extra Table Navigation

To include an extra button in the table navigation, override the extra_tablenav method.

protected function extra_tablenav( $which ) {
    $page = $_REQUEST['page'];
    echo '<div class="alignleft actions"><a href="?page=' . $page . '&action=clear&_wpnonce=' . esc_attr(wp_create_nonce("delete-table")) . '" aria-label="' . __('Clear this table data', 'theme-name') . '" role="button" class="button action clear-all" style="background-color: #cf2e2e; color: #ffffff;">' . __('Clear All', 'theme-name') . '</a></div>';
}

Adding Action Links to Columns

To display action links in any column, create a method named column_columnName, which will display an action link when you hover over the row. In this example, we will enable an action link for the date column.

protected function column_date($item) {
    $_wpnonce = esc_attr(wp_create_nonce("delete-entry"));
    $actions = array(
        'delete' => sprintf('<a href="?page=%s&action=%s&id=%s&_wpnonce=%s" aria-label="%s" role="button">%s</a>',
            $_REQUEST['page'], 'delete', $item->id, $_wpnonce, __('Delete this contact permanently', 'theme-name'), __('Delete Permanently', 'theme-name')
        )
    );
    return $item->date_of_subscription . $this->row_actions($actions);
}

Adding Search Bar Support

To add a search bar, call the search_box method in the init_subscribers method from the create a menu page step.

function init_subscribers() {
    // define $table as a global variable
    global $table;

    echo '<div class="wrap"><h1 class="wp-heading-inline">My Subscribers</h1>';
    echo '<form id="subscribers-form" method="post">';
    $table->prepare_items();
    $table->search_box('search', 'subscribers');
    $table->display();
    echo '</form></div>';
}

Then, modify the get_table_data method from the pagination implementation step to return results based on the search query.

private function get_table_data($args = array()) {
    global $wpdb;
    $orderby = isset( $_REQUEST['orderby'] ) ? $_REQUEST['orderby'] : 'id';
    $order   = isset( $_REQUEST['order'] ) ? $_REQUEST['order'] : 'desc';

    if(isset($_POST['s']) && $_POST['s']) {
        return $wpdb->get_results("SELECT * FROM " . $wpdb->prefix . "subscribers WHERE subscribers_email LIKE '%{$_POST['s']}%'");
    }
    return $wpdb->get_results("SELECT * FROM " . $wpdb->prefix . "subscribers ORDER BY {$orderby} {$order} LIMIT {$args['offset']}, {$args['per_page']}");
}

Handling Actions

Finally, handle the delete actions by defining a new method called process_bulk_action, which should be called within the prepare_items method.

public function process_bulk_action() {
    global $wpdb;
    if(isset($_POST['_wpnonce']) && !empty($_POST['_wpnonce'])) {
        $nonce = filter_input(INPUT_POST, '_wpnonce', FILTER_SANITIZE_STRING);
        $action = 'bulk-' . $this->_args['plural'];
        if(!wp_verify_nonce($nonce, $action)) {
            wp_die(__('Security check failed!', 'theme-name'));
        }
        if((isset($_POST['action']) && $_POST['action'] === 'delete_all') || (isset($_POST['action2']) && $_POST['action2'] === 'delete_all')) {
            $ids = $_POST['subscribers'];
            foreach($ids as $id) {
                $wpdb->delete($wpdb->prefix . "subscribers", array("id" => $id), "%d");
            }
        }
    } else if(isset($_GET['action']) && (isset($_GET['_wpnonce']) && !empty($_GET['_wpnonce']))) {
        $nonce = filter_input(INPUT_GET, '_wpnonce', FILTER_SANITIZE_STRING);
        if($_GET['action'] === 'delete' && isset($_GET['id'])) {
            if(!wp_verify_nonce($nonce, "delete-entry")) {
                wp_die(__('Security check failed!', 'theme-name'));
            }
            $id = $_GET['id'];
            $wpdb->delete($wpdb->prefix . "subscribers", array("id" => $id), "%d");
        } else if($_GET['action'] === 'clear') {
            if(!wp_verify_nonce($nonce, "delete-table")) {
                wp_die(__('Security check failed!', 'theme-name'));
            }
            $wpdb->query("DELETE FROM " . $wpdb->prefix . "subscribers");
        }
    }
}

By implementing all these steps, you’ll have a fully functional list table with all the necessary features. You can further optimize the code or add additional features as needed.

Complete WP_List_Table Program

Here’s the complete code for creating an email list table using the WP_List_Table class:

add_action('admin_menu', 'my_subscribers');

function my_subscribers() {
    // create global page variable
    global $subscribers_page;

    // store page value return from add_menu_page() into global page var
    $subscribers_page = add_menu_page(__('Subscribers', 'theme-name'), __('Subscribers', 'theme-name'), 'manage_options', 'subscribers', 'init_subscribers', 'dashicons-email', 110);

    add_action("load-$subscribers_page", "subscribers_screen_options");
}

function subscribers_screen_options() {
    // declare $subscribers_page and $table as a global variable
    global $subscribers_page, $table;

    // return if not on our settings page
    $screen = get_current_screen();
    if(!is_object($screen) || $screen->id !== $subscribers_page) {
        return;
    }

    $args = array(
        'label' => __('Subscribers per page', 'theme-name'),
        'default' => 20,
        'option' => 'subscribers_per_page'
    );
    add_screen_option('per_page', $args);

    // create an instance of class
    $table = new Subscribers_List_Table(array(
        'plural'   => __('subscribers', 'theme-name'),
        'singular' => __('subscriber', 'theme-name'),
    ));
}

function init_subscribers() {
    // define $table as a global variable
    global $table;

    echo '<div class="wrap"><h1 class="wp-heading-inline">My Subscribers</h1>';
    echo '<form id="subscribers-form" method="post">';
    $table->prepare_items();
    $table->search_box('search', 'subscribers');
    $table->display();
    echo '</form></div>';
}

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

// extend WP_List_Table and override
// prepare_items() and get_columns() method
class Subscribers_List_Table extends WP_List_Table {
    public function __construct( $args = array() ) {
        if(empty($args)) {
            $args = array(
                'plural'   => __('subscribers', 'theme-name'),
                'singular' => __('subscriber', 'theme-name'),
            );
        }
        parent::__construct($args);
    }

    public function prepare_items() {
        $meta_key = 'managetoplevel_page_' . $_REQUEST['page'] . 'columnshidden';
        // Retrieves preferences related to hidden columns from usermeta column of database
        $hidden = (is_array(get_user_meta(get_current_user_id(), $meta_key, true))) ? get_user_meta(get_current_user_id(), $meta_key, true) : array();

        $this->_column_headers = [
            $this->get_columns(),
            $hidden,
            $this->get_sortable_columns(),
        ];

        $this->process_bulk_action();

        // get per page value from screen options
        $items_per_page = $this->get_items_per_page('subscribers_per_page');

        // get total number of items from database
        $total_items = $this->get_total_items();

        // get current page number
        // get_pagenum() method is available in WP_List_Table class
        $page = $this->get_pagenum();

        // calculate offset
        $offset = ( $page - 1 ) * $items_per_page;

        // call set_pagination_args() method to add pagination support
        $this->set_pagination_args(
            array(
                'total_items' => $total_items,
                'per_page'    => $items_per_page,
            )
        );

        // fetch data from database
        $this->items = $this->get_table_data(array(
            'offset' => $offset,
            'per_page' => $items_per_page
        ));
    }

    public function get_columns() {
        return array(
            'cb' => '<input type="checkbox" />',  // display checkbox
            'subscribers-email' => __('Subscribers Email', 'theme-name'),
            'date' => __('Date', 'theme-name')
        );
    }

    protected function get_sortable_columns() {
        return array(
            'date' => array('date_of_subscription', true, __( 'Date' ), __('Table ordered by Date.' ))
        );
    }

    private function get_table_data($args = array()) {
        global $wpdb;
        $orderby = isset( $_REQUEST['orderby'] ) ? $_REQUEST['orderby'] : 'id';
        $order   = isset( $_REQUEST['order'] ) ? $_REQUEST['order'] : 'desc';

        if(isset($_POST['s']) && $_POST['s']) {
            return $wpdb->get_results("SELECT * FROM " . $wpdb->prefix . "subscribers WHERE subscribers_email LIKE '%{$_POST['s']}%'");
        }
        return $wpdb->get_results("SELECT * FROM " . $wpdb->prefix . "subscribers ORDER BY {$orderby} {$order} LIMIT {$args['offset']}, {$args['per_page']}");
    }

    private function get_total_items() {
        global $wpdb;
        return (int) $wpdb->get_var("SELECT COUNT(`id`) FROM " . $wpdb->prefix . "subscribers");
    }

    protected function column_default($item, $column_name) {
        if($column_name === 'subscribers-email') {
            // items key name should match with database column name
            return $item->subscribers_email;
        } elseif($column_name === 'date') {
            return $item->date_of_subscription;
        } else {
            return $item->{$column_name};
        }
    }

    protected function column_cb($item) {
        return '<input id="cb-select-' . $item->id . '" type="checkbox" name="subscribers[]" value="' . $item->id . '" />';
    }

    protected function get_bulk_actions() {
        return array('delete_all' => __('Delete permanently', 'theme-name'));
    }

    protected function extra_tablenav( $which ) {
        $page = $_REQUEST['page'];
        echo '<div class="alignleft actions"><a href="?page=' . $page . '&action=clear&_wpnonce=' . esc_attr(wp_create_nonce("delete-table")) . '" aria-label="' . __('Clear this table data', 'theme-name') . '" role="button" class="button action clear-all" style="background-color: #cf2e2e; color: #ffffff;">' . __('Clear All', 'theme-name') . '</a></div>';
    }

    protected function column_date($item) {
        $_wpnonce = esc_attr(wp_create_nonce("delete-entry"));
        $actions = array(
            'delete' => sprintf('<a href="?page=%s&action=%s&id=%s&_wpnonce=%s" aria-label="%s" role="button">%s</a>',
                $_REQUEST['page'], 'delete', $item->id, $_wpnonce, __('Delete this contact permanently', 'theme-name'), __('Delete Permanently', 'theme-name')
            )
        );
        return $item->date . $this->row_actions($actions);
    }

    public function process_bulk_action() {
        global $wpdb;
        if(isset($_POST['_wpnonce']) && !empty($_POST['_wpnonce'])) {
            $nonce = filter_input(INPUT_POST, '_wpnonce', FILTER_SANITIZE_STRING);
            $action = 'bulk-' . $this->_args['plural'];
            if(!wp_verify_nonce($nonce, $action)) {
                wp_die(__('Security check failed!', 'theme-name'));
            }
            if((isset($_POST['action']) && $_POST['action'] === 'delete_all') || (isset($_POST['action2']) && $_POST['action2'] === 'delete_all')) {
                $ids = $_POST['subscribers'];
                foreach($ids as $id) {
                    $wpdb->delete($wpdb->prefix . "subscribers", array("id" => $id), "%d");
                }
            }
        } else if(isset($_GET['action']) && (isset($_GET['_wpnonce']) && !empty($_GET['_wpnonce']))) {
            $nonce = filter_input(INPUT_GET, '_wpnonce', FILTER_SANITIZE_STRING);
            if($_GET['action'] === 'delete' && isset($_GET['id'])) {
                if(!wp_verify_nonce($nonce, "delete-entry")) {
                    wp_die(__('Security check failed!', 'theme-name'));
                }
                $id = $_GET['id'];
                $wpdb->delete($wpdb->prefix . "subscribers", array("id" => $id), "%d");
            } else if($_GET['action'] === 'clear') {
                if(!wp_verify_nonce($nonce, "delete-table")) {
                    wp_die(__('Security check failed!', 'theme-name'));
                }
                $wpdb->query("DELETE FROM " . $wpdb->prefix . "subscribers");
            }
        }
    }
}

Read our article on how to create newsletter forms without using a plugin to collect the data used in the above table.

Frequently Asked Questions

How do I fix the column screen option not showing in the WP List Table?

The main cause of this problem is either not using the global $table variable or creating a new list table object in the callback function used in the add_menu_page function. If you are using the code provided above, ensure that you are not reinitializing the table object in the init_subscribers function. Refer to the Adding Support for Screen Options section in the tutorial above.

How do I fix the responsiveness of a table created using the WP_List_Table class or the table overflowing on the right side?

To make tables created using the WP List Table class responsive, wrap them inside a <div> tag with the class ‘wrap’.

echo '<div class="wrap"><form id="subscribers-form" method="post">';
$table->prepare_items();
$table->display();
echo '</form></div>';