Admin-only custom post types with custom list columns
Register a locked-down post type that exists only in wp-admin, and show ACF or post meta values as sortable columns in its list table.
On this page
Some data belongs in WordPress but should never appear on the front end, such as a price table, a list of locations, or a lookup table that other pages or forms read from. This guide registers that kind of admin-only post type and makes its list screen useful by showing field values as columns.
The examples use a post type called my_cpt with width, length, price, and related_item fields (ACF or plain post meta). Rename them to suit your site.
Put the code in a site plugin or must-use plugin rather than the theme, so the data stays reachable if the theme changes.
Register a Locked-Down Post Type
These arguments give the post type an admin menu and edit screen, and turn off everything public: no single pages, no archive, no search results, no menus, and no REST API.
<?php
add_action( 'init', function () {
register_post_type( 'my_cpt', [
'labels' => [
'name' => __( 'Price Sheets', 'my-site' ),
'singular_name' => __( 'Price Sheet', 'my-site' ),
'add_new_item' => __( 'Add New Price Sheet', 'my-site' ),
'edit_item' => __( 'Edit Price Sheet', 'my-site' ),
'all_items' => __( 'All Price Sheets', 'my-site' ),
'search_items' => __( 'Search Price Sheets', 'my-site' ),
'not_found' => __( 'Not found', 'my-site' ),
],
'description' => __( 'Internal pricing data.', 'my-site' ),
'supports' => [ 'title' ], // Fields come from ACF.
'hierarchical' => false,
// Admin UI only.
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => 21,
'menu_icon' => 'dashicons-money-alt',
'show_in_admin_bar' => false,
'show_in_nav_menus' => false,
// Nothing on the front end.
'publicly_queryable' => false,
'exclude_from_search' => true,
'has_archive' => false,
'rewrite' => false,
'query_var' => false,
// Not exposed over the REST API or in Tools > Export.
'show_in_rest' => false,
'can_export' => false,
// Same permissions as pages (editors and admins).
'capability_type' => 'page',
'map_meta_cap' => true,
] );
} );Notes:
Only
nameandsingular_namelabels are really needed. WordPress fills in the rest from defaults, which read as "Post", so set any labels you care about.With
show_in_restset tofalse, the post type uses the classic edit screen. That's fine for ACF-only data. Set it totrueif you want the block editor, which also makes the posts available through the REST API to users who can edit them.supportscan befalseto remove even the title box. Keeptitleif you want something readable in the list table.
Restrict Who Can Edit
capability_type => 'page' lets editors and admins manage the posts. To limit the post type to administrators, map every capability to manage_options instead:
'capabilities' => [
'edit_post' => 'manage_options',
'read_post' => 'manage_options',
'delete_post' => 'manage_options',
'edit_posts' => 'manage_options',
'edit_others_posts' => 'manage_options',
'delete_posts' => 'manage_options',
'publish_posts' => 'manage_options',
'read_private_posts' => 'manage_options',
'create_posts' => 'manage_options',
],Use this in place of capability_type and map_meta_cap.
Add Custom List Columns
Three hooks control the list table, all named after the post type:
| Hook | Purpose |
|---|---|
manage_{post_type}_posts_columns (filter) | Define which columns appear and in what order. |
manage_{post_type}_posts_custom_column (action) | Print the value for a custom column. |
manage_edit-{post_type}_sortable_columns (filter) | Mark columns as sortable and name their orderby value. |
Define the Columns
Returning a new array replaces the default columns. Keep cb (the bulk-select checkbox) and title so row actions still work.
add_filter( 'manage_my_cpt_posts_columns', function ( $columns ) {
return [
'cb' => $columns['cb'],
'title' => __( 'Name', 'my-site' ),
'related_item' => __( 'Related Item', 'my-site' ),
'width' => __( 'Width', 'my-site' ),
'length' => __( 'Length', 'my-site' ),
'price' => __( 'Price', 'my-site' ),
'date' => $columns['date'],
];
} );Print the Values
Escape everything you print. For ACF fields, get_post_meta() returns the raw stored value (an attachment ID, an array of post IDs, and so on), which is usually what you want in a list table. Use get_field() if you want ACF's formatted value.
add_action( 'manage_my_cpt_posts_custom_column', function ( $column, $post_id ) {
switch ( $column ) {
case 'related_item':
// ACF relationship/post object fields store an array of post IDs.
$ids = (array) get_post_meta( $post_id, 'related_item', true );
$id = reset( $ids );
echo $id ? esc_html( get_the_title( $id ) ) : '—';
break;
case 'width':
case 'length':
$value = get_post_meta( $post_id, $column, true );
echo '' !== $value ? esc_html( $value . '′' ) : '—';
break;
case 'price':
$value = get_post_meta( $post_id, 'price', true );
echo '' !== $value ? esc_html( '$' . number_format_i18n( (float) $value, 2 ) ) : '—';
break;
}
}, 10, 2 );Make Columns Sortable
The sortable filter maps a column ID to the orderby value WordPress puts in the URL. It doesn't sort anything by itself. Sorting by meta needs a pre_get_posts handler.
add_filter( 'manage_edit-my_cpt_sortable_columns', function ( $columns ) {
$columns['width'] = 'width';
$columns['length'] = 'length';
$columns['price'] = 'price';
return $columns;
} );
add_action( 'pre_get_posts', function ( WP_Query $query ) {
if ( ! is_admin() || ! $query->is_main_query() || 'my_cpt' !== $query->get( 'post_type' ) ) {
return;
}
$orderby = $query->get( 'orderby' );
if ( in_array( $orderby, [ 'width', 'length', 'price' ], true ) ) {
$query->set( 'meta_key', $orderby );
$query->set( 'orderby', 'meta_value_num' );
}
} );Note
Sorting by meta_key hides posts that don't have that meta key at all. Give the field a default value in ACF if every row should appear when sorted.
Older versions of this snippet passed the column-definition function to the sortable filter as well. That marks every column as sortable by its label text, which doesn't sort anything correctly, so keep the two filters separate.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.