This is a tutorial in how to create a custom post type and a custom taxonomy in WordPress by code. We’ll look at common pitfalls and which arguments to use for minimum but sufficient creation. Full example included at the end.

Where to add the code

Creation of custom post types (CPTs) and custom taxonomies in WordPress can be done inside a theme’s functions.php file or inside a plugin. Keep in mind that the custom post type and custom taxonomy will disappear if you switch theme or deactivate the plugin. So it’s safe to temporarily remove the CPT registration from the theme, and move it into a plugin – as long as you keep the same custom post type or taxonomy identifier slug/ID.

For creating (and modifying) a CPT or taxonomy, always use the init hook. Placing it in root of functions.php (outside a hook) or any other hook will cause problems.

Creating a custom post type

For creating a custom post type you use the register_post_type function. It accepts two parameters; first the post type identifier and second an array with all arguments.

The post type identifier is a slug version name of your post type. For example WordPress’ built-in post types posts and pages are identified as ‘post‘ and ‘page‘. The identifier must be unique, it must follow a set of rules (lowercase, no spaces etc) and not be one of WordPress’ reserved slugs.

This is what I have learned to be the minimum but perfectly good enough arguments for registering a post type; considering that it’s a normal public CPT, and you wish to override any labels that says “post” or “page” with the actual name of your CPT:

add_action('init', function() {
	register_post_type('book', [
		'label' => __('Books', 'txtdomain'),
		'public' => true,
		'menu_position' => 5,
		'menu_icon' => 'dashicons-book',
		'supports' => ['title', 'editor', 'thumbnail', 'author', 'revisions', 'comments'],
		'show_in_rest' => true,
		'rewrite' => ['slug' => 'book'],
		'labels' => [
			'singular_name' => __('Book', 'txtdomain'),
			'add_new_item' => __('Add new book', 'txtdomain'),
			'new_item' => __('New book', 'txtdomain'),
			'view_item' => __('View book', 'txtdomain'),
			'not_found' => __('No books found', 'txtdomain'),
			'not_found_in_trash' => __('No books found in trash', 'txtdomain'),
			'all_items' => __('All books', 'txtdomain'),
			'insert_into_item' => __('Insert into book', 'txtdomain')
		],		
	]);
});

An overview of the arguments

Be aware that some of the arguments inherit values from other arguments. Unless they are explicitly set they might default to the same value or the opposite as another. Several arguments inherit the same or the opposite value of the argument public. Read the documentation to see what the default value is for each argument and if you need to override it.

If you are fine with having texts in admin that refers to your post type as “post” or “page”, you can skip defining the label arguments. You will probably be fine with only label (plural name) and inside the labels array just singular_name (singular name).

If you don’t explicitly set show_in_rest to true, your custom pos type will use the old classic editor. If wish to use Gutenberg editor for your custom post type you need to set show_in_rest to true.

The supports argument tells which elements are available when editing a post in your post type. As minimum you probably want the title, the editor, and the featured post image.

The rewrite argument with the minimum of array element slug tells WordPress to rewrite all singular posts of your post type to use this prefix slug. In the above example, a singular book post would get an URL like; “http://example.com/book/i-robot/”. If you are interested in how to add a permalink rule setting in admin to allow theme users to decide this slug themselves, take a look at this post.

The argument for menu icon (menu_icon)can be any of the following Dashicons, or you can leave it empty to keep the default. The default is the same icon as Posts. It is however a good idea to clearly separate your custom post types.

Menu position (menu_position) allows you to decide the position of your custom post type in admin menu. The documentation lists out all admin menu positions, so you can adjust; position 5 is right after ‘Posts’.

There’s another argument (taxonomies) for attaching a taxonomy to the post type. We will go through how to add a custom taxonomy later in this post. For adding taxonomies to your post type, add this argument to the above array;

'taxonomies' => ['book_author'],

A note on permalinks and 404 not found errors

After you have added your code for registering a custom post type, you will notice that viewing a single post will return “404 not found” error. This is because you need to “refresh permalinks”.

Go to Settings > Permalinks, and just click the “Save changes” button (no need to change anything).

Keep in mind that whenever you change the rewrite attribute you will need to refresh permalinks again.

Creating a custom taxonomy

A custom taxonomy can be attached to one of WordPress’ post types (posts, pages), or to a custom post type. You can also attach multiple taxonomies to a post type. When you register a taxonomy, you need to provide the post type(s) you want it to be attached to.

A taxonomy can either be hierarchical (like post categories where you can make a tree-based structure) or tag-based (like post tags). This is really the only consideration you need to know beforehand, with the exception of its identifier slug. As with CPTs, the identifying slug to a taxonomy needs to be unique and follow a set of rules.

For registering a custom taxonomy you use the register_taxonomy function. The register_taxonomy accepts the taxonomy unique identifier slug as first argument, an array of post types to attach it to as second, and finally an array with all the rest of the arguments. There are a lot of arguments, but this is what I have experienced to be the minimum but sufficient for registering a custom taxonomy (this adds a tag-type/non-hierarchical taxonomy):

add_action('init', function() {
	register_taxonomy('book_author', ['book'], [
		'label' => __('Authors', 'txtdomain'),
		'hierarchical' => false,
		'rewrite' => ['slug' => 'book-author'],
		'show_admin_column' => true,
		'show_in_rest' => true,
		'labels' => [
			'singular_name' => __('Author', 'txtdomain'),
			'all_items' => __('All Authors', 'txtdomain'),
			'edit_item' => __('Edit Author', 'txtdomain'),
			'view_item' => __('View Author', 'txtdomain'),
			'update_item' => __('Update Author', 'txtdomain'),
			'add_new_item' => __('Add New Author', 'txtdomain'),
			'new_item_name' => __('New Author Name', 'txtdomain'),
			'search_items' => __('Search Authors', 'txtdomain'),
			'popular_items' => __('Popular Authors', 'txtdomain'),
			'separate_items_with_commas' => __('Separate authors with comma', 'txtdomain'),
			'choose_from_most_used' => __('Choose from most used authors', 'txtdomain'),
			'not_found' => __('No Authors found', 'txtdomain'),
		]
	]);
});

It is recommended to add a function call right after the register_taxonomy, to make sure it gets properly “attached” to the CPT: register_taxonomy_for_object_type. Define your taxonomy as first argument and the CPT as second:

register_taxonomy_for_object_type('book_author', 'book');

Similarly as post type above, register_taxonomy accepts a lot more arguments, and many of those inherits or depends on the value of other arguments. Read the documentation to see what the default value is for each argument and if you need to override it.

An overview of the arguments

If you are fine with having texts that refers to you taxonomy as “tag” (if hierarchical is false) or “category” (if hierarchical is true), you can probably skip all of the labels array with the exception of perhaps singular_name.

The show_admin_column is handy for adding a column showing the associated terms in your taxonomy in your CPT admin screen. Just like in Posts, you see a column showing associated categories. This argument is default set to false (don’t show column), so I like to override it.

Setting show_in_rest to true is necessary for having your taxonomy visible in Post edit in Gutenberg editor, since Gutenberg relies on REST API.

Likewise as with custom post types, you will probably get “404 not found” errors on your custom taxonomy. Go to Settings > Permalinks and just click the “Save changes” button.

Full example code

Here is a full example of creating a CPT for books and attaching two custom taxonomies; genre (hierarchical) and book author (tag).

add_action('init', function() {
	register_post_type('book', [
		'label' => __('Books', 'txtdomain'),
		'public' => true,
		'menu_position' => 5,
		'menu_icon' => 'dashicons-book',
		'supports' => ['title', 'editor', 'thumbnail', 'author', 'revisions', 'comments'],
		'show_in_rest' => true,
		'rewrite' => ['slug' => 'book'],
		'taxonomies' => ['book_author', 'book_genre'],
		'labels' => [
			'singular_name' => __('Book', 'txtdomain'),
			'add_new_item' => __('Add new book', 'txtdomain'),
			'new_item' => __('New book', 'txtdomain'),
			'view_item' => __('View book', 'txtdomain'),
			'not_found' => __('No books found', 'txtdomain'),
			'not_found_in_trash' => __('No books found in trash', 'txtdomain'),
			'all_items' => __('All books', 'txtdomain'),
			'insert_into_item' => __('Insert into book', 'txtdomain')
		],		
	]);

	register_taxonomy('book_genre', ['book'], [
		'label' => __('Genres', 'txtdomain'),
		'hierarchical' => true,
		'rewrite' => ['slug' => 'book-genre'],
		'show_admin_column' => true,
		'show_in_rest' => true,
		'labels' => [
			'singular_name' => __('Genre', 'txtdomain'),
			'all_items' => __('All Genres', 'txtdomain'),
			'edit_item' => __('Edit Genre', 'txtdomain'),
			'view_item' => __('View Genre', 'txtdomain'),
			'update_item' => __('Update Genre', 'txtdomain'),
			'add_new_item' => __('Add New Genre', 'txtdomain'),
			'new_item_name' => __('New Genre Name', 'txtdomain'),
			'search_items' => __('Search Genres', 'txtdomain'),
			'parent_item' => __('Parent Genre', 'txtdomain'),
			'parent_item_colon' => __('Parent Genre:', 'txtdomain'),
			'not_found' => __('No Genres found', 'txtdomain'),
		]
	]);
	register_taxonomy_for_object_type('book_genre', 'book');

	register_taxonomy('book_author', ['book'], [
		'label' => __('Authors', 'txtdomain'),
		'hierarchical' => false,
		'rewrite' => ['slug' => 'book-author'],
		'show_admin_column' => true,
		'labels' => [
			'singular_name' => __('Author', 'txtdomain'),
			'all_items' => __('All Authors', 'txtdomain'),
			'edit_item' => __('Edit Author', 'txtdomain'),
			'view_item' => __('View Author', 'txtdomain'),
			'update_item' => __('Update Author', 'txtdomain'),
			'add_new_item' => __('Add New Author', 'txtdomain'),
			'new_item_name' => __('New Author Name', 'txtdomain'),
			'search_items' => __('Search Authors', 'txtdomain'),
			'popular_items' => __('Popular Authors', 'txtdomain'),
			'separate_items_with_commas' => __('Separate authors with comma', 'txtdomain'),
			'choose_from_most_used' => __('Choose from most used Authors', 'txtdomain'),
			'not_found' => __('No Authors found', 'txtdomain'),
		]
	]);
	register_taxonomy_for_object_type('book_author', 'book');
});