Register a Taxonomy without attaching it to any Post Type

As I found out, WordPress does not allow the creation of taxonomies independently, without binding to any post type. I mean, we can register a taxonomy without binding it to any post type, but when we go to the page for creating new terms (elements) of this taxonomy, we will be in the "Posts" tab of the Admin Panel. But we want to have our own, separated taxonomy page in the Admin Panel. I will explain that further by steps...

Task

I need to keep some data (strings) with a possibility to add more data. Further, that data (strings) will be used for WordPress users (users will have skills; for example, a user is able to cook, wash, clean).

In order to avoid writing a bunch of code and to be able to create, modify and delete those skills (the number of which may exceed 2000), I made a decision to create a custom WordPress taxonomy for this purpose.

Advantages: it is very easy to register a taxonomy, so we immediately get a table with pagination and search; the ability to add, change or delete data, as well as the ability to expand the data with terms metadata. Moreover, we get a whole package of WP functions to display and manage our taxonomy elements If, for example, we need to keep the data in a separate table or in WP Options. Managing all of them requires writing additional code for everything: from the creation of the page in the Admin Panel to the functions for outputting our data — utilizing the taxonomy registering we get all this at once.

Disadvantages: unused fields in the taxonomy table, but this is a trifle, so actually there are no disadvantages!

So, the task is to create a taxonomy that is not tied to any post type and has its own menu item in the Admin Panel.

Solution

Register a taxonomy. We need it only for data storage, so it is not public (it's invisible on the frontend of the website), and it doesn't have any typical taxonomy parameters:

## create taxonomy skills
add_action( 'init', function (){
	register_taxonomy( 'skills', null, array(
		'labels'                => array(
			'name'          => 'Skills',
			'singular_name' => 'Skill',
			'add_new_item'  => 'Add new Skill',
		),
		'public'                => false,
		'show_ui'               => true,  // equal to the argument public
		'show_in_rest'          => false, // add to REST API
		'hierarchical'          => false,
		'update_count_callback' => '__return_null',
	) );
}, 20 );

## add a taxonomy menu item to the admin menu
add_action( 'admin_menu', 'add_skills_menu_item' );
function add_skills_menu_item(){
	add_menu_page( 'Skills', 'Skills', 'manage_options', "edit-tags.php?taxonomy=skills", null, 'dashicons-awards', 9 );
}

As a result, we get the following taxonomy page in the Admin Panel:

But when we go to the skills taxonomy page, we are in the "Posts" menu tab.

Now the second moment is to make an active menu item to be the taxonomy, not the "Posts" tab. There are no built-in hooks in WordPress to do it simply so we will use a workaround for that.

Let's do it by replacing our previous code around adding menu item:

## add a taxonomy menu item to the admin menu
add_action( 'admin_menu', 'add_skills_menu_item' );
function add_skills_menu_item(){
	$taxname = 'skills';

	$is_skills = isset($_GET['taxonomy']) && $_GET['taxonomy'] === $taxname;

	// cancel 'current' for posts (taxonomy attaches there by default, even if not set post_type when taxonomy is registered
	$is_skills && add_filter( 'parent_file', function($parent_file){
		return false;
	} );

	// add a taxonomy menu item
	$menu_title = 'Skills';
	add_menu_page( 'Skills', $menu_title, 'manage_options', "edit-tags.php?taxonomy=$taxname", null, 'dashicons-awards', 9 );
	// fix some parameters of the added menu item
	$menu_item = & $GLOBALS['menu'][ key(wp_list_filter( $GLOBALS['menu'], [$menu_title] )) ];
	foreach( $menu_item as & $val ){
		// add 'current' class if need
		if( false !== strpos($val, 'menu-top') )
			$val = 'menu-top'. ( $is_skills ? ' current' : '' );

		$val = preg_replace('~toplevel_page[^ ]+~', "toplevel_page_$taxname", $val );
	}
}

Result:

That's it!

-

My task required me to hide unnecessary fields and to add a field to bulk add skills.

Whole previous code, including the code for my further tasks:

<?php

// create taxonomy skills
add_action( 'init', function (){
	register_taxonomy( 'skills', null, array(
		'labels'                => array(
			'name'          => 'Skills',
			'singular_name' => 'Skill',
			'add_new_item'  => 'Add new Skill',
		),
		'public'                => false,
		'show_ui'               => true,  // equal to the argument public
		'show_in_rest'          => false, // add to REST API
		'hierarchical'          => false,
		'update_count_callback' => '__return_null',
	) );

	massadd_skills_handler();
}, 20 );

## add a taxonomy menu item to the admin menu
add_action( 'admin_menu', 'add_skills_menu_item' );
function add_skills_menu_item(){
	$taxname = 'skills';

	$is_skills = isset($_GET['taxonomy']) && $_GET['taxonomy'] === $taxname;

	// cancel 'current' for posts (taxonomy attaches there by default, even if not set post_type when taxonomy is registered
	$is_skills && add_filter( 'parent_file', function($parent_file){
		return false;
	} );

	// add a taxonomy menu item
	$menu_title = 'Skills';
	add_menu_page( 'Skills', $menu_title, 'manage_options', "edit-tags.php?taxonomy=$taxname", null, 'dashicons-awards', 9 );
	// fix some parameters of the added menu item
	$menu_item = & $GLOBALS['menu'][ key(wp_list_filter( $GLOBALS['menu'], [$menu_title] )) ];
	foreach( $menu_item as & $val ){
		// add 'current' class if need
		if( false !== strpos($val, 'menu-top') )
			$val = 'menu-top'. ( $is_skills ? ' current' : '' );

		$val = preg_replace('~toplevel_page[^ ]+~', "toplevel_page_$taxname", $val );
	}
}

## request to bulk add skills
function massadd_skills_handler(){
	if( empty($_POST['massadd_skills']) || ! trim($_POST['massadd_skills']) || ! current_user_can('manage_options') )
		return; // only admin

	$new_skills = wp_unslash( trim($_POST['massadd_skills']) );
	$new_skills = array_filter( array_map( 'trim', explode( "\n", $new_skills ) ) );

	$err_names = [];
	foreach( $new_skills as $skill_name ){
		$data = wp_insert_term( $skill_name, 'skills' );
		if( is_wp_error($data) )
			$err_names[ $skill_name ] = $data->get_error_message();
	}

	// the message about the query result
	add_action( 'admin_notices', function() use ($err_names, $new_skills){
		$added_count = count($new_skills) - count($err_names);
		$message = "<p>Skills added: $added_count</p>";

		if( $err_names ){
			$message .= '<p style="color:red;">';
			$message .= 'Failed to add: <br>';
			foreach( $err_names as $skill_name => $err_msg )
				$message .= '<b>'. esc_html($skill_name) . "</b>: $err_msg <br>";
			$message .= "</p>";
		}

		echo '<div class="notice notice-success is-dismissible"><div>'. $message .'</div></div>';
	} );

}

## form of Bulk skills adding
add_action( 'skills'.'_add_form', 'massadd_skills_form' );
function massadd_skills_form(){
	if( ! current_user_can('manage_options') ) return; // only admin

	// the code is displayed inside the existing form, so close it and open new
	?>
	</form>

	<form method="POST" action="">
		<div class="form-field massadd-skills-wrap">
			<h2>Bulk skills adding </h2>
			<p>The list of skills each on a new line.</p>
			<textarea name="massadd_skills" rows="5" style="width:95%"></textarea>
		</div>
	<?php
	submit_button( 'Add all skills' );
}

## its styles on the taxonomy skills page and on the edit item skills page
## hide no need fields
add_action( 'admin_head', 'hide_unwanted_skill_field' );
function hide_unwanted_skill_field(){
	if( get_current_screen()->id === 'edit-skills' ){
		echo '
		<style>
			.form-field.term-slug-wrap{ display:none; }
			.form-field.term-description-wrap{ display:none; }
		</style>';
	}
}

## Remove no need columns
add_filter( 'manage_'.'edit-skills'.'_columns', function( $columns ){
	unset( $columns['description'], $columns['posts'] );
	return $columns;
});

Result: