How To Modify Admin Post Lists In WordPress

About The Author

Hello, I’m Daniel and I make things for the web. I’m the CTO at Kinsta and I write for a number of amazing publications like Smashing Magazine and … More about Daniel ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

In this article, you’ll look how to modify admin post lists with WordPress. Daniel Pataki will focus on how to extend existing tables. You’ll do this using an example from a theme that he and his team recently built, named Rock Band. Rock Band includes event management, which means that they needed some custom event-specific interface elements and details to make the admin section more useful!

Have you ever created a custom post type and then found that only the titles and dates of your posts are displayed in the admin lists? While WordPress will add taxonomies for you, that’s the most it can do. Adding relevant at-a-glance information is easy; in this article, we’ll look how to modify admin post lists with WordPress.

To make sure we’re on the same page, an admin list is the table of posts shown in the admin section when you click on “Posts,” “Pages” or another custom post type. Before we delve in, it is worth noting that admin tables are created using the WP_List_Table class. Jeremy Desvaux de Marigny has written a great article on native admin tables that explains how to make these from scratch.

We’ll focus in this article on how to extend existing tables. We’ll do this using an example from a theme that we recently built, named Rock Band. Rock Band includes event management, which means that we needed some custom event-specific interface elements and details to make the admin section more useful!

Creating A Custom Post Type

This process is fairly straightforward and is documented well in “The Complete Guide to Custom Post Types.” All we need is a definition of the labels that we’re going to use and a few settings. Open up your functions.php file and drop the following into it.

add_action( 'init', 'bs_post_types' );
function bs_post_types() {

  $labels = array(
    'name'                => __( 'Events', THEMENAME ),
    'singular_name'       => __( 'Event', THEMENAME ),
    'add_new'             => __( 'Add New', THEMENAME ),
    'add_new_item'        => __( 'Add New Event', THEMENAME ),
    'edit_item'           => __( 'Edit Event', THEMENAME ),
    'new_item'            => __( 'New Event', THEMENAME ),
    'all_items'           => __( 'All Event', THEMENAME ),
    'view_item'           => __( 'View Event', THEMENAME ),
    'search_items'        => __( 'Search Events', THEMENAME ),
    'not_found'           => __( 'No events found', THEMENAME ),
    'not_found_in_trash'  => __( 'No events found in Trash', THEMENAME ),
    'menu_name'           => __( 'Events', THEMENAME ),

  $supports = array( 'title', 'editor' );

  $slug = get_theme_mod( 'event_permalink' );
  $slug = ( empty( $slug ) ) ? 'event' : $slug;

  $args = array(
    'labels'              => $labels,
    'public'              => true,
    'publicly_queryable'  => true,
    'show_ui'             => true,
    'show_in_menu'        => true,
    'query_var'           => true,
    'rewrite'             => array( 'slug' => $slug ),
    'capability_type'     => 'post',
    'has_archive'         => true,
    'hierarchical'        => false,
    'menu_position'       => null,
    'supports'            => $supports,

  register_post_type( 'event', $args );


Quick Tip

By pulling the permalink from the theme settings, you can make sure that users of your theme are able to set their own permalinks. This is important for multilingual websites, on which administrators might want to make sure that URLs are readable by their users.


What we get is the post list above. It’s better than nothing, but it has no at-a-glance information at all. Event venue, start time and ticket status would be great additions, so let’s get cracking!

Adding Custom Table Headers

Throughout this whole process, we will never have to touch the WP_Lists_Table class directly. This is wonderful news! Because we’ll be doing everything with hooks, our code will be nice and modular, easily customizable.

Adding the header is as simple as modifying the value of an array. This sounds like a job for a filter!

add_filter('manage_event_posts_columns', 'bs_event_table_head');
function bs_event_table_head( $defaults ) {
    $defaults['event_date']  = 'Event Date';
    $defaults['ticket_status']    = 'Ticket Status';
    $defaults['venue']   = 'Venue';
    $defaults['author'] = 'Added By';
    return $defaults;

Note the name of the filter: It corresponds to the name of the post type we have created. This means you can modify the table of any post type, not only your custom ones. Just use manage_post_posts_columns to modify the columns for the table of regular posts.

Once this code has been placed in our functions file, you should see the four new table headers. The fields don’t have any content yet; it is for us to decide what goes in them.

Fill ’er Up!

Adding data for each column is about as “complex” as it was to create the columns.

add_action( 'manage_event_posts_custom_column', 'bs_event_table_content', 10, 2 );

function bs_event_table_content( $column_name, $post_id ) {
    if ($column_name == 'event_date') {
    $event_date = get_post_meta( $post_id, '_bs_meta_event_date', true );
      echo  date( _x( 'F d, Y', 'Event date format', 'textdomain' ), strtotime( $event_date ) );
    if ($column_name == 'ticket_status') {
    $status = get_post_meta( $post_id, '_bs_meta_event_ticket_status', true );
    echo $status;

    if ($column_name == 'venue') {
    echo get_post_meta( $post_id, '_bs_meta_event_venue', true );


As is obvious from the structure of this function, it gets called separately for each column. Because of this, we need to check which column is currently being displayed and then spit out the corresponding data.

The data we need for this is stored in the postmeta table. Here’s how to do it:

  • The event’s date is stored using the _bs_meta_event_date key.
  • The ticket’s status uses the _bs_meta_event_ticket_status key.
  • The venue is stored using the _bs_meta_event_venue meta key.

Because these are all postmeta values, we just need to use the get_post_meta() function to retrieve them. With the exception of the date, we can echo these values right away.

This brings us to an important point. You are not restricted to showing individual snippets of data or showing links. Whatever you output will be shown. With sufficient time, you could attach a calendar to the dates, which would be shown on hover. You could create flyout menus that open up on click, and so on.


As you can see, this is much better. The event’s date, ticket status, venue and author can be seen, which makes this table actually informative, rather than just a way to get to edit pages. However, we can do more.

Ordering Columns

Enabling column ordering takes two steps but is fairly straightforward. First, use a filter to specify which of your columns should be sortable by adding it to an array. Then, create a filter for each column to modify the query when a user clicks to sorts the column.

add_filter( 'manage_edit-event_sortable_columns', 'bs_event_table_sorting' );
function bs_event_table_sorting( $columns ) {
  $columns['event_date'] = 'event_date';
  $columns['ticket_status'] = 'ticket_status';
  $columns['venue'] = 'venue';
  return $columns;

add_filter( 'request', 'bs_event_date_column_orderby' );
function bs_event_date_column_orderby( $vars ) {
    if ( isset( $vars['orderby'] ) && 'event_date' == $vars['orderby'] ) {
        $vars = array_merge( $vars, array(
            'meta_key' => '_bs_meta_event_date',
            'orderby' => 'meta_value'
        ) );

    return $vars;

add_filter( 'request', 'bs_ticket_status_column_orderby' );
function bs_ticket_status_column_orderby( $vars ) {
    if ( isset( $vars['orderby'] ) && 'ticket_status' == $vars['orderby'] ) {
        $vars = array_merge( $vars, array(
            'meta_key' => '_bs_meta_event_ticket_status',
            'orderby' => 'meta_value'
        ) );

    return $vars;

add_filter( 'request', 'bs_venue_column_orderby' );
function bs_venue_column_orderby( $vars ) {
    if ( isset( $vars['orderby'] ) && 'venue' == $vars['orderby'] ) {
        $vars = array_merge( $vars, array(
            'meta_key' => '_bs_meta_event_venue',
            'orderby' => 'meta_value'
        ) );

    return $vars;

Here is what’s happening in each of these cases. Whenever posts are listed, an array of arguments is passed that determines what is shown — things like how many to show per page, which post type to display, and so on. WordPress knows how to construct the array of arguments for each of its built-in features.

When we say, “order by venue,” WordPress doesn’t know what this means. Results are ordered before they are displayed, not after the fact. Therefore, WordPress needs to know what order to pull posts in before it actually retrieves them. Thus, we tell WordPress which meta_key to filter by and how to treat the values (meta_value for strings, meta_value_num for integers).

As with displaying data, you can go nuts here. You can use all of the arguments that WP_Query takes to perform taxonomy filtering, meta field queries and so on.

By adding the code above, we can now click to order based on date, status and venue. We’re almost there. One more thing would help out a lot, especially when dealing with hundreds of events.

Data Filtering

Setting up the filters is analogous to setting up ordering. First, we tell WordPress which controls we want to use. Then, we need to make sure those controls actually do something. Let’s get started.

add_action( 'restrict_manage_posts', 'bs_event_table_filtering' );
function bs_event_table_filtering() {
  global $wpdb;
  if ( $screen->post_type == 'event' ) {

    $dates = $wpdb->get_results( "SELECT EXTRACT(YEAR FROM meta_value) as year,  EXTRACT( MONTH FROM meta_value ) as month FROM $wpdb->postmeta WHERE meta_key = '_bs_meta_event_date' AND post_id IN ( SELECT ID FROM $wpdb->posts WHERE post_type = 'event' AND post_status != 'trash' ) GROUP BY year, month " ) ;

    echo ’;
      echo ’ . __( 'Show all event dates', 'textdomain' ) . ’;
    foreach( $dates as $date ) {
      $month = ( strlen( $date->month ) == 1 ) ? 0 . $date->month : $date->month;
      $value = $date->year . '-' . $month . '-' . '01 00:00:00';
      $name = date( 'F Y', strtotime( $value ) );

      $selected = ( !empty( $_GET['event_date'] ) AND $_GET['event_date'] == $value ) ? 'selected="select"' : ’;
      echo ’ . $name . ’;
    echo ’;

    $ticket_statuses = get_ticket_statuses();
    echo ’;
      echo ’ . __( 'Show all ticket statuses', 'textdomain' ) . ’;
    foreach( $ticket_statuses as $value => $name ) {
      $selected = ( !empty( $_GET['ticket_status'] ) AND $_GET['ticket_status'] == $value ) ? 'selected="selected"' : ’;
      echo ’ . $name . ’;
    echo ’;


I know, this is a bit scarier! Initially, all we are doing is making sure that we add filters to the right page. As you can see from the hook, this is not specific to the post’s type, so we need to check manually.

Once we’re sure that we’re on the events page, we add two controls: a selector for event dates and a selector for ticket statuses.

We have one custom function in there, get_ticket_statuses(), which is used to retrieve a list of ticket statuses. These are all defined by the user, so describing how it works would be overkill. Suffice it to say that it contains an array with the key-value pairs that we need for the selector.


Once this is done, the table will reach its final form. We now have our filters along the top, but they don’t work yet. Let’s fix that, shall we?

Filtering data is simply a matter of adding arguments to the query again. This time, instead of ordering our data, we’ll add parameters to narrow down or broaden our returned list of posts.

add_filter( 'parse_query','bs_event_table_filter' );
function bs_event_table_filter( $query ) {
  if( is_admin() AND $query->query['post_type'] == 'event' ) {
    $qv = &$query->query_vars;
    $qv['meta_query'] = array();

    if( !empty( $_GET['event_date'] ) ) {
      $start_time = strtotime( $_GET['event_date'] );
      $end_time = mktime( 0, 0, 0, date( 'n', $start_time ) + 1, date( 'j', $start_time ), date( 'Y', $start_time ) );
      $end_date = date( 'Y-m-d H:i:s', $end_time );
      $qv['meta_query'][] = array(
        'field' => '_bs_meta_event_date',
        'value' => array( $_GET['event_date'], $end_date ),
        'compare' => 'BETWEEN',
        'type' => 'DATETIME'


    if( !empty( $_GET['ticket_status'] ) ) {
      $qv['meta_query'][] = array(
        'field' => '_bs_meta_event_ticket_status',
        'value' => $_GET['ticket_status'],
        'compare' => '=',
        'type' => 'CHAR'

    if( !empty( $_GET['orderby'] ) AND $_GET['orderby'] == 'event_date' ) {
      $qv['orderby'] = 'meta_value';
      $qv['meta_key'] = '_bs_meta_event_date';
      $qv['order'] = strtoupper( $_GET['order'] );


For each filter, we need to add rules to the query. When we’re filtering for events, we need to add a meta_query. This will return only results for which the custom field key is _bs_meta_event_ticket_status and the value is the given ticket’s status.

Once this final piece of the puzzle is added, we will have a customized WordPress admin list, complete with filtering, ordering and custom data. Well done!


Adding custom data to a table is a great way to draw information to the attention of users. Plugin developers can hook their functionality into posts without touching any other functionality, and theme authors can add advanced information about custom post types and other things to relevant places.

Showing the right information in the right place can make a huge difference in the salability and likability of any product. That being said, don’t overexploit your newfound power. Don’t add fields just because you can, especially to WordPress’ main tables.

Don’t forget that others know about this, too, and many developers of SEO plugins and similar products already add their own columns to posts. If you’re going to add things to the default post types, I suggest including settings to enable and disable them.

If you’ve used these techniques in one of your products or are wondering how to show some tidbit of information in a table, let us know in the comments!

Further Reading

Smashing Editorial (al, il, mrn)