Skip to content
Skip to the article
In WordPress: 10 articles
WordPress

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.

Updated
Applies to
  • WordPress 6.x
  • ACF 6.x
Tags
  • wordpress
  • custom-post-types
  • acf
  • wp-admin
Reading time
5 min

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 name and singular_name labels are really needed. WordPress fills in the rest from defaults, which read as "Post", so set any labels you care about.

  • With show_in_rest set to false, the post type uses the classic edit screen. That's fine for ACF-only data. Set it to true if you want the block editor, which also makes the posts available through the REST API to users who can edit them.

  • supports can be false to remove even the title box. Keep title if 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:

HookPurpose
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'],
	];
} );

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.