WordPress
Article

Using WP_List_Table to Create WordPress Admin Tables

By Collins Agbonghama

In the WordPress dashboard, the tables that displays the posts, pages and user data are all created internally by WordPress using the WP_List_Table PHP class.

Below are a couple of screenshots of the post and user admin pages:

Image-2 - WordPress post and user admin pages

Image 1 - WordPress post and user admin pages

As a plugin developer, the need to build a custom table that will contain a given data might arise. Rather than code your own table design, it’s best you use that of WordPress in order for your plugin settings page to conform to WordPress UI.

While you might be tempted to copy the HTML and CSS table design by viewing the source code of WordPress, you shouldn’t because the WP_List_Table class is there to help.

My acquaintance with WP_List_Table stemmed from my experience building the ProfilePress plugin. I actually used it to display the list of created user account forms in table form.

image-3 - ProfilePress catalog

You’ve probably used plugins that use WP_List_Table, for example, the popular Contact Form 7 plugin uses the class to display the list of created contact forms.

Getting Familiar with WP_List_Table

We’ll build a plugin to demonstrate how to display the dummy customer database below in a table format using WP_List_Table class.

Image 4 - Sample customer database

The plugin is comprised of two classes: a child class of WP_List_Table and the plugin settings class.

Extending WP_List_Table

To build a WordPress UI table, the WP_List_Table will have to be extended with a couple of its methods overridden by a child class.

Firstly, we include the class in the plugin.

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

We then create a child class that extends WP_List_Table. The child class will be called Customers_List since we are dealing with a database of customers.

class Customers_List extends WP_List_Table {

	/** Class constructor */
	public function __construct() {

		parent::__construct( [
			'singular' => __( 'Customer', 'sp' ), //singular name of the listed records
			'plural'   => __( 'Customers', 'sp' ), //plural name of the listed records
			'ajax'     => false //should this table support ajax?

		] );

	}

Let’s create some helper methods that the child class will use.

The get_customers() method below query the customer database and return the data in array format.

/**
 * Retrieve customer’s data from the database
 *
 * @param int $per_page
 * @param int $page_number
 *
 * @return mixed
 */
public static function get_customers( $per_page = 5, $page_number = 1 ) {

  global $wpdb;

  $sql = "SELECT * FROM {$wpdb->prefix}customers";

  if ( ! empty( $_REQUEST['orderby'] ) ) {
    $sql .= ' ORDER BY ' . esc_sql( $_REQUEST['orderby'] );
    $sql .= ! empty( $_REQUEST['order'] ) ? ' ' . esc_sql( $_REQUEST['order'] ) : ' ASC';
  }

  $sql .= " LIMIT $per_page";

  $sql .= ' OFFSET ' . ( $page_number - 1 ) * $per_page;


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

  return $result;
}

If you’ve built a PHP/MySQL application that includes pagination, the OFFSET and LIMIT SQL syntax should be familiar to you.

The $per_page and $page_number arguments defines the SQL limit and the current page number.

To learn more about paginating data set, See these tutorials.

The delete_customer() method takes care of deleting a given record from the database.

/**
 * Delete a customer record.
 *
 * @param int $id customer ID
 */
public static function delete_customer( $id ) {
  global $wpdb;

  $wpdb->delete(
    "{$wpdb->prefix}customers",
    [ 'ID' => $id ],
    [ '%d' ]
  );
}

The record_count() simply returns the number of customers in the database.

/**
 * Returns the count of records in the database.
 *
 * @return null|string
 */
public static function record_count() {
  global $wpdb;

  $sql = "SELECT COUNT(*) FROM {$wpdb->prefix}customers";

  return $wpdb->get_var( $sql );
}

We are done creating our helper methods, all succeeding methods are aimed at overriding their respective parent methods.

For example, WP_List_Table has a no_items() method that returns No items found when no data is found.

To include a custom message, we have to create the same method in our child class but with a different return message.

/** Text displayed when no customer data is available */
public function no_items() {
  _e( 'No customers avaliable.', 'sp' );
}

The column_name method renders the name column of the table. A delete link is added below the customer name by passing an array containing the link key and value to row_action method.

/**
 * Method for name column
 *
 * @param array $item an array of DB data
 *
 * @return string
 */
function column_name( $item ) {

  // create a nonce
  $delete_nonce = wp_create_nonce( 'sp_delete_customer' );

  $title = '<strong>' . $item['name'] . '</strong>';

  $actions = [
    'delete' => sprintf( '<a href="?page=%s&action=%s&customer=%s&_wpnonce=%s">Delete</a>', esc_attr( $_REQUEST['page'] ), 'delete', absint( $item['ID'] ), $delete_nonce )
  ];

  return $title . $this->row_actions( $actions );
}

Since the data output of the address and city column won’t be modified, the column_default method whose function is to render a column when no specific method exists for that column will handle their output.

/**
 * Render a column when no column specific method exists.
 *
 * @param array $item
 * @param string $column_name
 *
 * @return mixed
 */
public function column_default( $item, $column_name ) {
  switch ( $column_name ) {
    case 'address':
    case 'city':
      return $item[ $column_name ];
    default:
      return print_r( $item, true ); //Show the whole array for troubleshooting purposes
  }
}

The column_cb method is used to render the checkbox responsible for carrying out bulk data operations.

/**
 * Render the bulk edit checkbox
 *
 * @param array $item
 *
 * @return string
 */
function column_cb( $item ) {
  return sprintf(
    '<input type="checkbox" name="bulk-delete[]" value="%s" />', $item['ID']
  );
}

The method get_columns() returns an array of columns that are going to be used in your table.

/**
 *  Associative array of columns
 *
 * @return array
 */
function get_columns() {
  $columns = [
    'cb'      => '<input type="checkbox" />',
    'name'    => __( 'Name', 'sp' ),
    'address' => __( 'Address', 'sp' ),
    'city'    => __( 'City', 'sp' )
  ];

  return $columns;
}

The get_sortable_columns() method defines the columns to make sortable. Thus, WordPress will add a link to the title of the column which when clicked, changes the order of data presentation.

/**
 * Columns to make sortable.
 *
 * @return array
 */
public function get_sortable_columns() {
  $sortable_columns = array(
    'name' => array( 'name', true ),
    'city' => array( 'city', false )
  );

  return $sortable_columns;
}

get_bulk_actions() should return an associative array containing all the bulk actions available for the table.

/**
 * Returns an associative array containing the bulk action
 *
 * @return array
 */
public function get_bulk_actions() {
  $actions = [
    'bulk-delete' => 'Delete'
  ];

  return $actions;
}

The prepare_items method is where the data query and filter, sort handling, pagination, and any other data-manipulation required prior to rendering is carried out.

Note: the method must include a call to the items parent class properties and the store the array of database data saved against it.

/**
 * Handles data query and filter, sorting, and pagination.
 */
public function prepare_items() {

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

  /** Process bulk action */
  $this->process_bulk_action();

  $per_page     = $this->get_items_per_page( 'customers_per_page', 5 );
  $current_page = $this->get_pagenum();
  $total_items  = self::record_count();

  $this->set_pagination_args( [
    'total_items' => $total_items, //WE have to calculate the total number of items
    'per_page'    => $per_page //WE have to determine how many items to show on a page
  ] );


  $this->items = self::get_customers( $per_page, $current_page );
}

Notice a call to process_bulk_action()? This method takes care of the deleting customers record either when the delete link is clicked or when a group of records is checked and the delete option is selected from the bulk action.

public function process_bulk_action() {

  //Detect when a bulk action is being triggered...
  if ( 'delete' === $this->current_action() ) {

    // In our file that handles the request, verify the nonce.
    $nonce = esc_attr( $_REQUEST['_wpnonce'] );

    if ( ! wp_verify_nonce( $nonce, 'sp_delete_customer' ) ) {
      die( 'Go get a life script kiddies' );
    }
    else {
      self::delete_customer( absint( $_GET['customer'] ) );

      wp_redirect( esc_url( add_query_arg() ) );
      exit;
    }

  }

  // If the delete bulk action is triggered
  if ( ( isset( $_POST['action'] ) && $_POST['action'] == 'bulk-delete' )
       || ( isset( $_POST['action2'] ) && $_POST['action2'] == 'bulk-delete' )
  ) {

    $delete_ids = esc_sql( $_POST['bulk-delete'] );

    // loop over the array of record IDs and delete them
    foreach ( $delete_ids as $id ) {
      self::delete_customer( $id );

    }

    wp_redirect( esc_url( add_query_arg() ) );
    exit;
  }
}

We are done extending the WP_List_Table class for our plugin, up next is building the plugin settings page that will display the data table of customers.

Building the Settings Page

We create the class for the settings page populated with the constructor method and properties.

class SP_Plugin {

	// class instance
	static $instance;

	// customer WP_List_Table object
	public $customers_obj;

	// class constructor
	public function __construct() {
		add_filter( 'set-screen-option', [ __CLASS__, 'set_screen' ], 10, 3 );
		add_action( 'admin_menu', [ $this, 'plugin_menu' ] );
	}

Below are the callback methods for set-screen-option filter and admin_menu action hook.

public static function set_screen( $status, $option, $value ) {
	return $value;
}

public function plugin_menu() {

	$hook = add_menu_page(
		'Sitepoint WP_List_Table Example',
		'SP WP_List_Table',
		'manage_options',
		'wp_list_table_class',
		[ $this, 'plugin_settings_page' ]
	);

	add_action( "load-$hook", [ $this, 'screen_option' ] );

}

The plugin_menu() methods that creates the settings page includes a callback screen_option() method to create the screen option for setting the default number of data to display in the table.

image-5 - Screen option

/**
* Screen options
*/
public function screen_option() {

	$option = 'per_page';
	$args   = [
		'label'   => 'Customers',
		'default' => 5,
		'option'  => 'customers_per_page'
	];

	add_screen_option( $option, $args );

	$this->customers_obj = new Customers_List();
}

Take note: we instantiated the Customers_List child class and saved the object to the customers_obj property defined earlier at the class declaration.

Below is the plugin_settings_page callback method that displays the content of the settings page.

/**
* Plugin settings page
*/
public function plugin_settings_page() {
	?>
	<div class="wrap">
		<h2>WP_List_Table Class Example</h2>

		<div id="poststuff">
			<div id="post-body" class="metabox-holder columns-2">
				<div id="post-body-content">
					<div class="meta-box-sortables ui-sortable">
						<form method="post">
							<?php
							$this->customers_obj->prepare_items();
							$this->customers_obj->display(); ?>
						</form>
					</div>
				</div>
			</div>
			<br class="clear">
		</div>
	</div>
<?php
}

From the code above, the customer table is displayed by first calling prepare_items() to prepare the data and then display() to display the table content.

To ensure only one object instance exists, here’s the singleton method.

/** Singleton instance */
public static function get_instance() {
	if ( ! isset( self::$instance ) ) {
		self::$instance = new self();
	}

	return self::$instance;
}

Finally, we call the singleton method when all plugins have been loaded by WordPress.

add_action( 'plugins_loaded', function () {
	SP_Plugin::get_instance();
} );

When the plugin is installed and activated, you’ll see the customer data display in the WordPress table UI as depicted in the image below.

image 6 - Customers data in WordPress table UI

Summary

In this tutorial, we’ve covered how to display custom data retrieved from the database in tabular format using the powerful WP_List_Table PHP Class.

The plugin source code is available on GitHub. Download, install it in your WordPress powered site and explore the code.

If you have any questions or contributions, let us know in the comments.

Free Guide:

7 Habits of Successful CTOs

"What makes a great CTO?" Engineering skills? Business savvy? An innate tendency to channel a mythical creature (ahem, unicorn)? All of the above? Discover the top traits of the most successful CTOs in this free guide.

Comments
barry

Hi,

Great tutorial/template for a custom db table, thanks

1/ I notice you are using php 5.4 i am still working with php 5.3, do you have sourcecode which doesn't use 5.4 code ( i.e. Array dereferencing". PHP 5.4. )
-found a solutiion by replacing the [] with array()

2/ How do we add/enable a searchbox?

3/How to style with even and uneven row colors?

regards,

collizo4sky

@barry If you use an IDE like PhpStorm, switch PHP language level to 5.3 then use its built-in tool-tip bulb to automatically convert 5.4 arrays to 5.3 and to spot all 5.4 features like anonymous functions.

I didn't include the search box in order to keep the tutorial at a reasonable length for easy assimilation.
You might want to check out the link below for the search box and row styling
http://wpengineer.com/2426/wp_list_table-a-step-by-step-guide/

All the best.

dea

Hi,
when I try to delete the selected options I get the 'You do not have sufficient permissions to access this page.' message. I also notice that in the url in the place where it should be the id (costumer[id]), I just have it blank. Is it because I'm using custom table? I don't know what I'm doing wrong, so any help would be appreciated.

collizo4sky

You are getting that error probably because the page the delete action is redirecting to doesn't exist or not properly registered with add_menu_page() or add_submenu_page() functions.

Do you need the sql dump for the customer database i used?

dea

I have tried this on my own code, where I use custom post type and custom database table. I have changed the code a little, but still get the same error. This is the code for the dele:

function column_student($item){
        $actions=array(
        'edit'=>sprintf('<a href="?post_type=%s&page=%s&action=%s&student=%s">Edit</a>',$_REQUEST['post_type'], $_REQUEST['page'], 'edit', $item->students_id),
        'delete'=>sprintf('<a href="?post_type=%s&page=%s&action=%s&student=%s">Delete</a>', $_REQUEST['post_type'], $_REQUEST['page'], 'delete', $item->students_id),
        
        );
        //Return the title contents
        return sprintf('%1$s <span style="color:silver"></span>%2$s',
            /*$1%s*/ $item->students_name,
            /*$2%s*/ $this->row_actions($actions)
        );
    }

function process_bulk_action(){
        if( 'delete'===$this->current_action() ) {
                $this->bsp_delete_student(($_REQUEST['student']));
        }
    }
    function bsp_delete_student($id){
        global $wpdb;
        $tablename=$wpdb->prefix."students"; //geting our table name with prefix
        $result=$wpdb->query("DELETE FROM '.$tablename.' WHERE students_id='.$id'");
    }
    
    function column_cb($item){
        return sprintf('<input type="checkbox" name="%1$s" value="%2$s" />', 
        $this->_args['singular'],  //$1%s   we use the tables singular label
        $item->students_id        //$2%s    the value of the chechbox is the students id
        );
    }

The rollover edit and delete are not showing anymore and I don't know why. But when I click on the apply for bulk action it just redirects me to You do not have sufficient permissions to access this page. In the URL I now see the correct id's, but nothing happens.
It's also interesting that when I use your code and just change the database and it's fields to mine, the mouse over delete "button" is not showing. Is this because I'm working on localhost? And when I try to delete the selected items it gives my an Warning: Cannot modify header information - headers already sent by (...wp-includes\pluggable.php on line 1196, but when I click again on the menu the record is deleted. Any ideas?

dea

I have managed to not get an error for not having permission. I just changed the method from get to post in the form. But now every time I try to delete a record using your code for deletion, I just get the Go get a life script kiddies, which means that the nonce isn't going trough. And if I comment out the nonce check, I get a warning headers already sent and no deletion of a record happens. Any ideas?

collizo4sky

Double check to ensure your nonce created with wp_create_nonce() is sp_delete_customer

If you are using a different nonce value, be sure to replace sp_delete_customer with your nonce value in

if ( ! wp_verify_nonce( $nonce, 'sp_delete_customer' ) )

dea

Yes, I checked it. Still getting the else statement. It seems like the function custom_name where the nonce is created isn't called or something. Because it does not create the nonce with that name. And I don't get the mouse over action of delete (row actions).
Does it got something to do that it is in a submenu inside a custom post type menu?

collizo4sky

Are you sure all columns in your table are all defined in get_columns() method?

Ensure you add the exit keyword after every wp_redirect like so wp_redirect('url here'); exit; to prevent any header already sent error.

You might want to create a GitHub gist with your code for me to peruse.

dea

I have all the columns I want to be shown defined in the get_columns() method. Because i have a date when the data is added and an ID which I don't show in the table. I have the exit at the end of wp_redirect. This is the github gist https://gist.github.com/anonymous/2fbf94727533c768109f with the code for wp_list_table. I have created a custom post and custom taxonomies and submenus to add to the table and to show the data from the table. So if you also need that, I can add that too. Thanks for helping me!

dea

I removed the wp_redirect() and now I don't get any headers errors and the delete works. Now I only have the problem with the nonce. It always fails.

Phil_Newman

Great tutorial!! I've setup an admin page where the bulk actions selected are saved as meta data for each user. Is there a way to change the bulk_actions select list to a multiple selection? I overrode the bulk_actions method to change to -- However, how do I read the list of selections? I only get the last one.

Thanks again for a fantastic write up!

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in WordPress, once a week, for free.