Claude Skill

wp-admin-list-table

Build WordPress admin tables by extending `WP_List_Table`.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-admin-list-table-52f6020.zip · 8 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-admin-list-table
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress Admin List Table (WP_List_Table)

WP_List_Table is the base class behind every WP admin list — Posts, Pages, Users, Comments, Plugins. It is not declared abstract, but it is designed to be subclassed. Extending it gets you sortable columns, bulk actions, search, pagination, view filters, row actions, screen options, and the WP-native look — for free, with a few required overrides.

Two things make this hard for plugins. First, the class is in wp-admin/includes/ and is NOT autoloaded — you must require_once it. Second, the bulk-action flow has a security gap that most plugins miss, producing the canonical "delete any record by visiting a crafted URL" CSRF.

When to use this skill

Trigger when ANY of the following is true:

  • The user is building an admin screen that lists plugin records (license keys, queued jobs, log entries, custom CPT meta dashboards, audit trails, sync history) and wants the WP-native table look.
  • Code references WP_List_Table, prepare_items, get_columns, column_default, column_cb, get_sortable_columns, get_bulk_actions, process_bulk_action, set_pagination_args, row_actions, extra_tablenav, search_box, screen_option, manage_$screen_columns_hidden.
  • The user says "I want a table like the Posts screen" / "with bulk delete" / "sortable by date" / "per-page in Screen Options".
  • A code review surfaces a bulk-delete or bulk-anything path that doesn't check_admin_referer( 'bulk-…' ).

The contract — what you MUST override, what you CAN override

Method Required? Purpose
prepare_items() YES Query your data, set $this->items, call set_pagination_args(), set $this->_column_headers
get_columns() YES Return [ slug => label ] map of columns to render
column_default( $item, $col ) Recommended Fallback renderer for any column without its own method
column_<slug>( $item ) Optional Per-column renderer for the column named <slug>
column_cb( $item ) Required IF you have bulk actions Renders the row checkbox
get_primary_column_aria_label( $item ) Recommended on WP 7.1+ Concise accessible name for the primary row-header cell
get_sortable_columns() Optional Return [ col => [ orderby_slug, default_desc ] ]
get_bulk_actions() Optional Return [ action_slug => label ] to show the bulk dropdown
process_bulk_action() Optional — but you write it if you have bulk actions Read current_action() and act; this is where the CSRF lives
extra_tablenav( $which ) Optional Adds filters above the table (status dropdown, date filter)
no_items() Optional Custom "no records" text
get_views() Optional The All | Active | Archived filter links above the table

The full bootstrap

1. Require the class

This class is in wp-admin/includes/ and is NOT autoloaded outside of admin screens that already include it. ALWAYS:

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

The cleanest place: at the top of the file that defines your subclass, OR inside the page-render callback if you only render the table conditionally.

2. The subclass

Implement a subclass that sets singular / plural, fills $this->items in prepare_items(), sets _column_headers, renders a checkbox column when bulk actions exist, and verifies the bulk nonce inside process_bulk_action().

The full production-style subclass example lives in reference.md. Keep this security shape in the main skill:

protected function process_bulk_action(): void {
    $action = $this->current_action();
    if ( ! $action ) {
        return;
    }

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

    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'You are not allowed to do that.', 'myplugin' ), 403 );
    }

    $ids = array_filter( array_map( 'absint', (array) ( $_REQUEST['license'] ?? array() ) ) );
    // Act on $ids here.
}

3. The page render

Wrap a table with state-changing bulk actions in a <form method="post"> so the generated _wpnonce, selected IDs, and action post back to the same page. Keep the page field so admin routing reaches the right callback. A separate GET search/filter form is also valid when bookmarkable filter URLs matter.

function myplugin_render_licenses_page(): void {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'You are not allowed to access this page.', 'myplugin' ), 403 );
    }

    $table = new MyPlugin_License_Table();
    $table->prepare_items();
    ?>
    <div class="wrap">
        <h1 class="wp-heading-inline"><?php esc_html_e( 'Licenses', 'myplugin' ); ?></h1>
        <a href="<?php echo esc_url( admin_url( 'admin.php?page=myplugin-licenses&action=add' ) ); ?>" class="page-title-action">
            <?php esc_html_e( 'Add new', 'myplugin' ); ?>
        </a>

        <form method="post">
            <?php
            // Keep the page query var so the form action stays on this screen.
            // The bulk-action nonce field is emitted automatically inside ->display().
            ?>
            <input type="hidden" name="page" value="myplugin-licenses" />
            <?php
            $table->search_box( __( 'Search licenses', 'myplugin' ), 'license' );
            $table->display();
            ?>
        </form>
    </div>
    <?php
}

4. Screen Options — per-page count

The "Screen Options" tab at the top of admin pages can let users pick how many rows per page. WP persists this to user meta automatically. Register on the screen load hook.

add_action( 'load-toplevel_page_myplugin-licenses', static function (): void {
    add_screen_option( 'per_page', array(
        'label'   => __( 'Licenses per page', 'myplugin' ),
        'default' => 20,
        'option'  => 'myplugin_licenses_per_page',
    ) );
} );

// Persist only this option through its scoped dynamic filter.
add_filter(
    'set_screen_option_myplugin_licenses_per_page',
    static function ( $status, string $option, $value ): int {
        return max( 1, min( 200, (int) $value ) );
    },
    10,
    3
);

get_items_per_page( $option, $default ) (inherited from WP_List_Table) reads the per-user value back. Match the slug exactly.

The bulk-action security gap — the #1 plugin CSRF

When WP_List_Table::display() renders the form, it emits a hidden _wpnonce field with action 'bulk-' . $this->_args['plural']. The class itself does NOT verify this nonce — your subclass's process_bulk_action() must.

// WRONG — accepts any GET request and deletes records
protected function process_bulk_action(): void {
    if ( 'delete' === $this->current_action() ) {
        MyPlugin_Repo::bulk_delete( $_POST['ids'] ?? array() );
    }
}

// RIGHT — verify nonce, then capability, then sanitize
protected function process_bulk_action(): void {
    $action = $this->current_action();
    if ( ! $action ) {
        return;
    }
    check_admin_referer( 'bulk-' . $this->_args['plural'] );
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'You are not allowed to do that.', 'myplugin' ), 403 );
    }
    $ids = array_map( 'absint', (array) ( $_REQUEST['license'] ?? array() ) );
    // ...
}

The nonce action string is constructed from your plural constructor arg. If you set 'plural' => 'licenses', the nonce action is 'bulk-licenses'. If you set it inconsistently (one place licenses, another license), nonce verification silently fails. Pick one and use it.

Row actions are the same vulnerability surface. Core has legacy nonced GET action links; if you use that pattern, wp_nonce_url() each record and verify it before acting. For new destructive UI, prefer a small POST form or an authenticated REST request so GET remains safe/idempotent:

$revoke_url = wp_nonce_url(
    add_query_arg( array( 'action' => 'revoke', 'id' => $item['id'] ), admin_url( 'admin.php?page=myplugin-licenses' ) ),
    'revoke-license-' . $item['id']
);

// On the receiving side:
if ( isset( $_GET['action'] ) && 'revoke' === $_GET['action'] ) {
    $id = absint( $_GET['id'] ?? 0 );
    check_admin_referer( 'revoke-license-' . $id );
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'Not allowed.', 'myplugin' ), 403 );
    }
    MyPlugin_License_Repo::revoke( $id );
    wp_safe_redirect( admin_url( 'admin.php?page=myplugin-licenses' ) );
    exit;
}

Views and filters

Use get_views() for All | Active | Archived links above the table and extra_tablenav( 'top' ) for dropdown filters between bulk actions and the table header. See reference.md for complete examples. prepare_items() then reads the selected $_GET vars and applies them to the query.

AJAX list tables

WP_List_Table supports AJAX (ajax => true in the constructor) but the docs are thin and you have to wire it manually — handle the wp_ajax_* callback, return the rendered table HTML, swap on the client. For 95% of plugin use cases, don't bother with AJAX — a regular form post is faster to ship and faster for users (one round-trip vs JS scaffolding). Add AJAX later if you genuinely need inline updates.

WordPress 7.1 row-header semantics

WordPress 7.1 renders the primary column as <th scope="row"> and the checkbox column as <td>. The base class adds get_primary_column_aria_label( $item ); override it with a short, plain item identifier so assistive technology does not derive a noisy name from row actions, status text, and excerpts:

protected function get_primary_column_aria_label( $item ): string {
    return isset( $item['name'] ) ? (string) $item['name'] : '';
}

The base implementation returns an empty string, so the aria-label is omitted unless a subclass opts in. Escaping is handled by the base renderer; return text, not markup or pre-escaped HTML.

If a subclass overrides single_row_columns() or a private-style _column_<slug>() renderer, it bypasses some base markup and must emit the same semantics itself. Do not produce multiple scope="row" cells, do not place the bulk checkbox in a row header, and keep every row's primary identifier visible or otherwise programmatically named. CSS/JS selectors that assumed every body cell was a td must target column classes instead of element names.

Critical rules

  • require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php' before extending. The class is NOT autoloaded everywhere.
  • check_admin_referer( 'bulk-' . $this->_args['plural'] ) before acting on any bulk action. This is the #1 plugin CSRF surface.
  • Every per-row mutation needs request-intent verification. Prefer POST; when maintaining a legacy GET link, nonce it with a per-record action and verify the identical action before the mutation.
  • current_user_can() is NOT a substitute for a nonce. The nonce catches CSRF; the cap check catches privilege escalation. You need both.
  • Sanitize orderby against a whitelist, never pass directly to SQL ORDER BY. Either compare against get_sortable_columns() or use a hardcoded in_array().
  • Set _column_headers to a 4-element array when you want hidden columns / primary column to work — [ columns, hidden, sortable, primary ]. The 3-element shorthand still works but you lose the row-actions hover anchor.
  • On WordPress 7.1+, treat the primary column as a row header. Override get_primary_column_aria_label() and avoid selectors that require td.column-primary.
  • Pluralize plural consistently. Core uses it for the table classes and the bulk-action nonce suffix (bulk-{$plural}). Row checkbox names are your responsibility in column_cb(); the usual convention is name="{$singular}[]".
  • Don't query the DB inside column_<slug>(). Those run per-row; an N+1 query happens silently. Resolve all needed joins in prepare_items().
  • Don't render anything before display(). prepare_items() reads $_GET/$_REQUEST, but the actual <form> and <table> come from display(). If you echo headings in between, fine; just don't dump rows.

Common AI mistakes

See reference.md for before/after examples: missing require_once, missing bulk nonce/cap checks, raw orderby SQL injection, N+1 column renderers, and calling set_pagination_args() before counting items.

Cross-references

  • See wp-admin-settings-api when a list-table page also has a settings form on the same screen — typical for "Records" + "Settings" tabs.
  • See wp-plugin-assets-loading for the $hook_suffix enqueue gate (relevant when adding inline-edit JS or custom column scripts).
  • See wp-security-audit for a broader sweep of admin CSRF / capability check patterns; this skill is the list-table-specific subset.
  • See wp-admin-postbox-sortable when the list-table page lives alongside metaboxes (rare but happens).

What this skill does NOT cover

  • AJAX list tables. Possible (ajax => true + a wp_ajax_* handler that returns rendered HTML) but rarely worth the complexity over a standard form-post page.
  • Inline edit / quick edit. That's a inline-edit-post.js topic — distinct API, not a WP_List_Table method.
  • Replacing the core Posts list table. Filterable but messy; use manage_{$post_type}_posts_columns + manage_{$post_type}_posts_custom_column for column additions instead.
  • React-rendered admin lists. If you've committed to a React island, use @wordpress/components <Table> or @tanstack/table — WP_List_Table is server-rendered PHP.

References

Files (wp-agent-skills)
  • reference.md 7.5 KB
    # WP_List_Table Reference Examples
    
    ## Production-Style Subclass
    
    ```php
    final class MyPlugin_License_Table extends WP_List_Table {
        public function __construct() {
            parent::__construct( array(
                'singular' => 'license',
                'plural'   => 'licenses',
                'ajax'     => false,
            ) );
        }
    
        public function get_columns(): array {
            return array(
                'cb'      => '<input type="checkbox" />',
                'key'     => __( 'License key', 'myplugin' ),
                'product' => __( 'Product', 'myplugin' ),
                'user'    => __( 'User', 'myplugin' ),
                'status'  => __( 'Status', 'myplugin' ),
                'expires' => __( 'Expires', 'myplugin' ),
            );
        }
    
        protected function get_sortable_columns(): array {
            return array(
                'product' => array( 'product', false ),
                'expires' => array( 'expires', true ),
            );
        }
    
        protected function get_bulk_actions(): array {
            return array(
                'revoke'     => __( 'Revoke', 'myplugin' ),
                'deactivate' => __( 'Deactivate', 'myplugin' ),
            );
        }
    
        public function prepare_items(): void {
            $per_page     = $this->get_items_per_page( 'myplugin_licenses_per_page', 20 );
            $current_page = $this->get_pagenum();
            $orderby_raw  = isset( $_GET['orderby'] )
                ? sanitize_key( wp_unslash( $_GET['orderby'] ) )
                : 'expires';
            $allowed_sort = array( 'product', 'expires' );
            $orderby      = in_array( $orderby_raw, $allowed_sort, true )
                ? $orderby_raw
                : 'expires';
            $order_raw    = isset( $_GET['order'] )
                ? strtolower( sanitize_key( wp_unslash( $_GET['order'] ) ) )
                : '';
            $order        = in_array( $order_raw, array( 'asc', 'desc' ), true )
                ? strtoupper( $order_raw )
                : 'DESC';
            $search       = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';
    
            $this->process_bulk_action();
    
            [ $items, $total_items ] = MyPlugin_License_Repo::find_paginated(
                $current_page,
                $per_page,
                $orderby,
                $order,
                $search
            );
    
            $this->items = $items;
            $this->_column_headers = array(
                $this->get_columns(),
                array(),
                $this->get_sortable_columns(),
                'key',
            );
    
            $this->set_pagination_args( array(
                'total_items' => $total_items,
                'per_page'    => $per_page,
                'total_pages' => (int) ceil( $total_items / $per_page ),
            ) );
        }
    
        protected function column_cb( $item ): string {
            return sprintf( '<input type="checkbox" name="license[]" value="%d" />', (int) $item['id'] );
        }
    
        protected function column_default( $item, $column_name ): string {
            return esc_html( (string) ( $item[ $column_name ] ?? '' ) );
        }
    
        protected function column_key( array $item ): string {
            $actions = array(
                'revoke' => sprintf(
                    '<a href="%s" class="submitdelete">%s</a>',
                    esc_url( wp_nonce_url(
                        add_query_arg(
                            array( 'page' => 'myplugin-licenses', 'action' => 'revoke', 'id' => $item['id'] ),
                            admin_url( 'admin.php' )
                        ),
                        'revoke-license-' . $item['id']
                    ) ),
                    esc_html__( 'Revoke', 'myplugin' )
                ),
            );
    
            return sprintf( '<strong>%s</strong> %s', esc_html( $item['key'] ), $this->row_actions( $actions ) );
        }
    
        protected function process_bulk_action(): void {
            $action = $this->current_action();
            if ( ! $action ) {
                return;
            }
    
            check_admin_referer( 'bulk-' . $this->_args['plural'] );
    
            if ( ! current_user_can( 'manage_options' ) ) {
                wp_die( __( 'You are not allowed to do that.', 'myplugin' ), 403 );
            }
    
            $ids = array_filter( array_map( 'absint', (array) ( $_REQUEST['license'] ?? array() ) ) );
            if ( ! $ids ) {
                return;
            }
    
            if ( 'revoke' === $action ) {
                MyPlugin_License_Repo::bulk_revoke( $ids );
            }
        }
    
        public function no_items(): void {
            esc_html_e( 'No licenses found.', 'myplugin' );
        }
    }
    ```
    
    ## View and Filter Snippets
    
    ```php
    protected function get_views(): array {
        $base   = admin_url( 'admin.php?page=myplugin-licenses' );
        $status = sanitize_key( $_GET['status'] ?? 'all' );
        $counts = MyPlugin_License_Repo::status_counts();
        $views  = array();
    
        foreach ( array( 'all' => __( 'All', 'myplugin' ), 'active' => __( 'Active', 'myplugin' ), 'expired' => __( 'Expired', 'myplugin' ) ) as $key => $label ) {
            $url   = 'all' === $key ? $base : add_query_arg( 'status', $key, $base );
            $class = $status === $key ? ' class="current"' : '';
            $views[ $key ] = sprintf(
                '<a href="%s"%s>%s <span class="count">(%d)</span></a>',
                esc_url( $url ),
                $class,
                esc_html( $label ),
                (int) ( $counts[ $key ] ?? 0 )
            );
        }
    
        return $views;
    }
    ```
    
    ```php
    protected function extra_tablenav( $which ): void {
        if ( 'top' !== $which ) {
            return;
        }
    
        $current  = sanitize_key( $_GET['product'] ?? '' );
        $products = MyPlugin_Product_Repo::all();
        ?>
        <div class="alignleft actions">
            <label class="screen-reader-text" for="filter-by-product"><?php esc_html_e( 'Filter by product', 'myplugin' ); ?></label>
            <select name="product" id="filter-by-product">
                <option value=""><?php esc_html_e( 'All products', 'myplugin' ); ?></option>
                <?php foreach ( $products as $product ) : ?>
                    <option value="<?php echo esc_attr( $product['slug'] ); ?>" <?php selected( $current, $product['slug'] ); ?>>
                        <?php echo esc_html( $product['name'] ); ?>
                    </option>
                <?php endforeach; ?>
            </select>
            <?php submit_button( __( 'Filter', 'myplugin' ), '', 'filter_action', false ); ?>
        </div>
        <?php
    }
    ```
    
    ## Common Mistakes
    
    ```php
    // WRONG: missing require.
    class My_Table extends WP_List_Table {}
    
    // RIGHT.
    if ( ! class_exists( 'WP_List_Table' ) ) {
        require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
    }
    class My_Table extends WP_List_Table {}
    ```
    
    ```php
    // WRONG: no nonce and no capability check.
    protected function process_bulk_action(): void {
        if ( 'delete' === $this->current_action() ) {
            $this->repo->delete( $_REQUEST['id'] );
        }
    }
    
    // RIGHT.
    protected function process_bulk_action(): void {
        if ( ! $this->current_action() ) {
            return;
        }
        check_admin_referer( 'bulk-' . $this->_args['plural'] );
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( 'Not allowed.', 403 );
        }
    }
    ```
    
    ```php
    // WRONG: raw orderby into SQL.
    $orderby = $_GET['orderby'];
    $sql     = "SELECT * FROM ... ORDER BY {$orderby}";
    
    // RIGHT: whitelist.
    $allowed = array( 'created', 'expires', 'product' );
    $orderby = in_array( $_GET['orderby'] ?? '', $allowed, true ) ? $_GET['orderby'] : 'created';
    ```
    
    ```php
    // WRONG: one query per row.
    protected function column_user( array $item ): string {
        $user = get_user_by( 'id', $item['user_id'] );
        return esc_html( $user->display_name );
    }
    
    // RIGHT: prefetch in prepare_items().
    public function prepare_items(): void {
        $rows = $this->repo->find_paginated();
        cache_users( array_column( $rows, 'user_id' ) );
        $this->items = $rows;
    }
    ```
    
  • SKILL.md 15 KB
    ---
    name: wp-admin-list-table
    description: Build WordPress admin tables by extending `WP_List_Table`.
      Covers the required `require_once`, constructor `singular` / `plural` /
      `ajax` args, `prepare_items()`, `get_columns()`, `column_cb()`,
      `column_default()`, `get_sortable_columns()`, `get_bulk_actions()`,
      `get_primary_column_aria_label()`, semantic row headers,
      `process_bulk_action()`, `extra_tablenav()`, pagination with
      `set_pagination_args()`, row actions, search, views, Screen Options
      per-page settings, sortable `orderby` / `order`, and the plugin CSRF gap,
      calling `check_admin_referer()` with the plural bulk-action value before
      acting on `current_action()`. Use for license keys, jobs, logs, audit
      records, subscriptions, or any plugin record list needing WP-native UI.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress Admin List Table (`WP_List_Table`)
    
    `WP_List_Table` is the base class behind every WP admin list — Posts, Pages, Users, Comments, Plugins. It is not declared `abstract`, but it is designed to be subclassed. Extending it gets you sortable columns, bulk actions, search, pagination, view filters, row actions, screen options, and the WP-native look — for free, with a few required overrides.
    
    Two things make this hard for plugins. First, the class is in `wp-admin/includes/` and is NOT autoloaded — you must `require_once` it. Second, the bulk-action flow has a security gap that most plugins miss, producing the canonical "delete any record by visiting a crafted URL" CSRF.
    
    ## When to use this skill
    
    Trigger when ANY of the following is true:
    
    - The user is building an admin screen that lists plugin records (license keys, queued jobs, log entries, custom CPT meta dashboards, audit trails, sync history) and wants the WP-native table look.
    - Code references `WP_List_Table`, `prepare_items`, `get_columns`, `column_default`, `column_cb`, `get_sortable_columns`, `get_bulk_actions`, `process_bulk_action`, `set_pagination_args`, `row_actions`, `extra_tablenav`, `search_box`, `screen_option`, `manage_$screen_columns_hidden`.
    - The user says "I want a table like the Posts screen" / "with bulk delete" / "sortable by date" / "per-page in Screen Options".
    - A code review surfaces a bulk-delete or bulk-anything path that doesn't `check_admin_referer( 'bulk-…' )`.
    
    ## The contract — what you MUST override, what you CAN override
    
    | Method | Required? | Purpose |
    |---|---|---|
    | `prepare_items()` | YES | Query your data, set `$this->items`, call `set_pagination_args()`, set `$this->_column_headers` |
    | `get_columns()` | YES | Return `[ slug => label ]` map of columns to render |
    | `column_default( $item, $col )` | Recommended | Fallback renderer for any column without its own method |
    | `column_<slug>( $item )` | Optional | Per-column renderer for the column named `<slug>` |
    | `column_cb( $item )` | Required IF you have bulk actions | Renders the row checkbox |
    | `get_primary_column_aria_label( $item )` | Recommended on WP 7.1+ | Concise accessible name for the primary row-header cell |
    | `get_sortable_columns()` | Optional | Return `[ col => [ orderby_slug, default_desc ] ]` |
    | `get_bulk_actions()` | Optional | Return `[ action_slug => label ]` to show the bulk dropdown |
    | `process_bulk_action()` | Optional — but you write it if you have bulk actions | Read `current_action()` and act; this is where the CSRF lives |
    | `extra_tablenav( $which )` | Optional | Adds filters above the table (status dropdown, date filter) |
    | `no_items()` | Optional | Custom "no records" text |
    | `get_views()` | Optional | The `All | Active | Archived` filter links above the table |
    
    ## The full bootstrap
    
    ### 1. Require the class
    
    This class is in `wp-admin/includes/` and is NOT autoloaded outside of admin screens that already include it. ALWAYS:
    
    ```php
    if ( ! class_exists( 'WP_List_Table' ) ) {
        require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
    }
    ```
    
    The cleanest place: at the top of the file that defines your subclass, OR inside the page-render callback if you only render the table conditionally.
    
    ### 2. The subclass
    
    Implement a subclass that sets `singular` / `plural`, fills `$this->items` in `prepare_items()`, sets `_column_headers`, renders a checkbox column when bulk actions exist, and verifies the bulk nonce inside `process_bulk_action()`.
    
    The full production-style subclass example lives in `reference.md`. Keep this security shape in the main skill:
    
    ```php
    protected function process_bulk_action(): void {
        $action = $this->current_action();
        if ( ! $action ) {
            return;
        }
    
        check_admin_referer( 'bulk-' . $this->_args['plural'] );
    
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( __( 'You are not allowed to do that.', 'myplugin' ), 403 );
        }
    
        $ids = array_filter( array_map( 'absint', (array) ( $_REQUEST['license'] ?? array() ) ) );
        // Act on $ids here.
    }
    ```
    
    ### 3. The page render
    
    Wrap a table with state-changing bulk actions in a `<form method="post">` so
    the generated `_wpnonce`, selected IDs, and action post back to the same page.
    Keep the `page` field so admin routing reaches the right callback. A separate
    GET search/filter form is also valid when bookmarkable filter URLs matter.
    
    ```php
    function myplugin_render_licenses_page(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( __( 'You are not allowed to access this page.', 'myplugin' ), 403 );
        }
    
        $table = new MyPlugin_License_Table();
        $table->prepare_items();
        ?>
        <div class="wrap">
            <h1 class="wp-heading-inline"><?php esc_html_e( 'Licenses', 'myplugin' ); ?></h1>
            <a href="<?php echo esc_url( admin_url( 'admin.php?page=myplugin-licenses&action=add' ) ); ?>" class="page-title-action">
                <?php esc_html_e( 'Add new', 'myplugin' ); ?>
            </a>
    
            <form method="post">
                <?php
                // Keep the page query var so the form action stays on this screen.
                // The bulk-action nonce field is emitted automatically inside ->display().
                ?>
                <input type="hidden" name="page" value="myplugin-licenses" />
                <?php
                $table->search_box( __( 'Search licenses', 'myplugin' ), 'license' );
                $table->display();
                ?>
            </form>
        </div>
        <?php
    }
    ```
    
    ### 4. Screen Options — per-page count
    
    The "Screen Options" tab at the top of admin pages can let users pick how many rows per page. WP persists this to user meta automatically. Register on the screen load hook.
    
    ```php
    add_action( 'load-toplevel_page_myplugin-licenses', static function (): void {
        add_screen_option( 'per_page', array(
            'label'   => __( 'Licenses per page', 'myplugin' ),
            'default' => 20,
            'option'  => 'myplugin_licenses_per_page',
        ) );
    } );
    
    // Persist only this option through its scoped dynamic filter.
    add_filter(
        'set_screen_option_myplugin_licenses_per_page',
        static function ( $status, string $option, $value ): int {
            return max( 1, min( 200, (int) $value ) );
        },
        10,
        3
    );
    ```
    
    `get_items_per_page( $option, $default )` (inherited from `WP_List_Table`) reads the per-user value back. Match the slug exactly.
    
    ## The bulk-action security gap — the #1 plugin CSRF
    
    When `WP_List_Table::display()` renders the form, it emits a hidden `_wpnonce` field with action `'bulk-' . $this->_args['plural']`. The class itself does NOT verify this nonce — your subclass's `process_bulk_action()` must.
    
    ```php
    // WRONG — accepts any GET request and deletes records
    protected function process_bulk_action(): void {
        if ( 'delete' === $this->current_action() ) {
            MyPlugin_Repo::bulk_delete( $_POST['ids'] ?? array() );
        }
    }
    
    // RIGHT — verify nonce, then capability, then sanitize
    protected function process_bulk_action(): void {
        $action = $this->current_action();
        if ( ! $action ) {
            return;
        }
        check_admin_referer( 'bulk-' . $this->_args['plural'] );
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( __( 'You are not allowed to do that.', 'myplugin' ), 403 );
        }
        $ids = array_map( 'absint', (array) ( $_REQUEST['license'] ?? array() ) );
        // ...
    }
    ```
    
    The nonce action string is constructed from your `plural` constructor arg. If you set `'plural' => 'licenses'`, the nonce action is `'bulk-licenses'`. If you set it inconsistently (one place `licenses`, another `license`), nonce verification silently fails. Pick one and use it.
    
    **Row actions are the same vulnerability surface**. Core has legacy nonced
    GET action links; if you use that pattern, `wp_nonce_url()` each record and
    verify it before acting. For new destructive UI, prefer a small POST form or
    an authenticated REST request so GET remains safe/idempotent:
    
    ```php
    $revoke_url = wp_nonce_url(
        add_query_arg( array( 'action' => 'revoke', 'id' => $item['id'] ), admin_url( 'admin.php?page=myplugin-licenses' ) ),
        'revoke-license-' . $item['id']
    );
    
    // On the receiving side:
    if ( isset( $_GET['action'] ) && 'revoke' === $_GET['action'] ) {
        $id = absint( $_GET['id'] ?? 0 );
        check_admin_referer( 'revoke-license-' . $id );
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( __( 'Not allowed.', 'myplugin' ), 403 );
        }
        MyPlugin_License_Repo::revoke( $id );
        wp_safe_redirect( admin_url( 'admin.php?page=myplugin-licenses' ) );
        exit;
    }
    ```
    
    ## Views and filters
    
    Use `get_views()` for `All | Active | Archived` links above the table and `extra_tablenav( 'top' )` for dropdown filters between bulk actions and the table header. See `reference.md` for complete examples. `prepare_items()` then reads the selected `$_GET` vars and applies them to the query.
    
    ## AJAX list tables
    
    `WP_List_Table` supports AJAX (`ajax => true` in the constructor) but the docs are thin and you have to wire it manually — handle the `wp_ajax_*` callback, return the rendered table HTML, swap on the client. For 95% of plugin use cases, **don't bother with AJAX** — a regular form post is faster to ship and faster for users (one round-trip vs JS scaffolding). Add AJAX later if you genuinely need inline updates.
    
    ## WordPress 7.1 row-header semantics
    
    WordPress 7.1 renders the primary column as `<th scope="row">` and the checkbox
    column as `<td>`. The base class adds
    `get_primary_column_aria_label( $item )`; override it with a short, plain item
    identifier so assistive technology does not derive a noisy name from row
    actions, status text, and excerpts:
    
    ```php
    protected function get_primary_column_aria_label( $item ): string {
        return isset( $item['name'] ) ? (string) $item['name'] : '';
    }
    ```
    
    The base implementation returns an empty string, so the `aria-label` is omitted
    unless a subclass opts in. Escaping is handled by the base renderer; return text,
    not markup or pre-escaped HTML.
    
    If a subclass overrides `single_row_columns()` or a private-style
    `_column_<slug>()` renderer, it bypasses some base markup and must emit the same
    semantics itself. Do not produce multiple `scope="row"` cells, do not place the
    bulk checkbox in a row header, and keep every row's primary identifier visible
    or otherwise programmatically named. CSS/JS selectors that assumed every body
    cell was a `td` must target column classes instead of element names.
    
    ## Critical rules
    
    - **`require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'`** before extending. The class is NOT autoloaded everywhere.
    - **`check_admin_referer( 'bulk-' . $this->_args['plural'] )`** before acting on any bulk action. This is the #1 plugin CSRF surface.
    - **Every per-row mutation needs request-intent verification**. Prefer POST;
      when maintaining a legacy GET link, nonce it with a per-record action and
      verify the identical action before the mutation.
    - **`current_user_can()` is NOT a substitute for a nonce**. The nonce catches CSRF; the cap check catches privilege escalation. You need both.
    - **Sanitize `orderby` against a whitelist**, never pass directly to SQL `ORDER BY`. Either compare against `get_sortable_columns()` or use a hardcoded `in_array()`.
    - **Set `_column_headers` to a 4-element array** when you want hidden columns / primary column to work — `[ columns, hidden, sortable, primary ]`. The 3-element shorthand still works but you lose the row-actions hover anchor.
    - **On WordPress 7.1+, treat the primary column as a row header.** Override `get_primary_column_aria_label()` and avoid selectors that require `td.column-primary`.
    - **Pluralize `plural` consistently**. Core uses it for the table classes and the bulk-action nonce suffix (`bulk-{$plural}`). Row checkbox names are your responsibility in `column_cb()`; the usual convention is `name="{$singular}[]"`.
    - **Don't query the DB inside `column_<slug>()`**. Those run per-row; an N+1 query happens silently. Resolve all needed joins in `prepare_items()`.
    - **Don't render anything before `display()`**. `prepare_items()` reads `$_GET`/`$_REQUEST`, but the actual `<form>` and `<table>` come from `display()`. If you echo headings in between, fine; just don't dump rows.
    
    ## Common AI mistakes
    
    See `reference.md` for before/after examples: missing `require_once`, missing bulk nonce/cap checks, raw `orderby` SQL injection, N+1 column renderers, and calling `set_pagination_args()` before counting items.
    
    ## Cross-references
    
    - See **`wp-admin-settings-api`** when a list-table page also has a settings form on the same screen — typical for "Records" + "Settings" tabs.
    - See **`wp-plugin-assets-loading`** for the `$hook_suffix` enqueue gate (relevant when adding inline-edit JS or custom column scripts).
    - See **`wp-security-audit`** for a broader sweep of admin CSRF / capability check patterns; this skill is the list-table-specific subset.
    - See **`wp-admin-postbox-sortable`** when the list-table page lives alongside metaboxes (rare but happens).
    
    ## What this skill does NOT cover
    
    - AJAX list tables. Possible (`ajax => true` + a `wp_ajax_*` handler that returns rendered HTML) but rarely worth the complexity over a standard form-post page.
    - Inline edit / quick edit. That's a `inline-edit-post.js` topic — distinct API, not a `WP_List_Table` method.
    - Replacing the core Posts list table. Filterable but messy; use `manage_{$post_type}_posts_columns` + `manage_{$post_type}_posts_custom_column` for column additions instead.
    - React-rendered admin lists. If you've committed to a React island, use `@wordpress/components` `<Table>` or `@tanstack/table` — `WP_List_Table` is server-rendered PHP.
    
    ## References
    
    - `wp-admin/includes/class-wp-list-table.php` — constructor, pagination, bulk actions, row actions, primary-column semantics, and `get_items_per_page()`.
    - `reference.md` — complete subclass, view/filter snippets, and common mistakes.
    - Official documentation: <https://developer.wordpress.org/reference/classes/wp_list_table/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/add_screen_option/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/check_admin_referer/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related