Custom Post Types and Taxonomies

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WordPress stores every piece of content as a row of wp_posts with a post_type column, and classifies it with terms of a taxonomy. Posts and pages are only the two types registered for blogs and static pages. A plugin registers more types when the content has its own fields, screens, permissions and URLs, such as the book catalogue of Lumen Press, and its own taxonomies when the content needs its own classification, such as a book’s genre and series. This page registers the lp_book post type, an "Out of print" status and the lp_genre and lp_series taxonomies of the lumen-catalog plugin, explains every argument that matters, and shows how the result appears in the admin, the block editor, the REST API and the site’s URLs.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0 and WPCS 3.4.1, and checked on 2026-10-28. WordPress 7.2 (in beta, due in December 2026) is not covered. The registration functions are old and stable: template and template_lock arrived in 5.0, array arguments for supports in 5.3, default_term in 5.5, rest_namespace in 5.9, and Notes support for a post type in 6.9. Every command output below comes from a fresh 7.1.3 site on the standard local stack (see Local Development Environments) with Twenty Twenty-Five, the lumen-catalog plugin, and the test users maria.editor (Editor, ID 2) and tom.author (Author, ID 3). The outputs were recorded over plain HTTP on that stack and are shown with the site’s canonical https://lumenpress.test address.

Post types, taxonomies and where they belong

A post type is a kind of content: it decides which editing screen is used, which fields the editor shows, who may edit it, which URLs it answers to and whether it appears in the REST API. A taxonomy is a way of grouping content with terms: categories and tags are the two built-in ones for posts. Terms live in wp_terms, wp_term_taxonomy and wp_term_relationships; Database Schema shows the tables.

WordPress 7.1.3 itself registers 16 post types (besides posts and pages: attachment, revision, nav_menu_item, wp_block, wp_template, wp_template_part, wp_global_styles, wp_navigation, wp_font_family, wp_font_face, custom_css, customize_changeset, oembed_cache, user_request) and 7 taxonomies (category, post_tag, nav_menu, post_format, wp_theme, wp_template_part_area, wp_pattern_category); plugin keys must not collide.

Need Tool

Content with its own fields, list screen, permissions or URLs (books, events, authors' profiles)

A custom post type

A grouping that readers browse and that has an archive page (genre, series, imprint)

A custom taxonomy, attached to the post types it classifies

A grouping of blog posts only (News, Reviews)

The built-in categories and tags (see Categories, Tags and Comments)

A single value per item that is not browsed (ISBN, page count)

Post meta (see Metadata and Custom Fields)

Post types and taxonomies are plugin territory. A theme that registers them takes the content away when the site changes theme: the books would still be in the database but would have no screens, no URLs and no REST endpoint. The theme only provides templates for them.

The lp_book model

The catalogue consists of the post type lp_book (archive at /books/, books at /books/<slug>/), the public status lp_out_of_print for titles no longer sold, the hierarchical taxonomy lp_genre (Fiction > Crime fiction, at /books/genre/<path>/) and the flat taxonomy lp_series (at /books/series/<slug>/), with capabilities of their own (edit_lp_books, publish_lp_books, …​, manage_lp_terms) for the Catalogue Manager role. The meta fields lp_isbn, lp_pages and lp_published_on are registered on Metadata and Custom Fields. The code extends the skeleton of Plugin Fundamentals (Composer autoloading of Lumen\Catalog, hooks added in Plugin::boot(), activation in Installer): this page fills in src/PostTypes.php and adds src/Taxonomies.php.

Registering the post type

The registration code

<?php
/**
 * Content types: the lp_book post type and its custom status.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Registers lp_book and the lp_out_of_print status. */
final class PostTypes {

	/** Registers the post type and the status. Hooked to init, and called directly on activation. */
	public static function register(): void {
		register_post_type(
			'lp_book',
			array(
				'labels'          => array(
					'name'               => _x( 'Books', 'post type general name', 'lumen-catalog' ),
					'singular_name'      => _x( 'Book', 'post type singular name', 'lumen-catalog' ),
					'menu_name'          => _x( 'Catalogue', 'admin menu', 'lumen-catalog' ),
					'add_new_item'       => __( 'Add New Book', 'lumen-catalog' ),
					'edit_item'          => __( 'Edit Book', 'lumen-catalog' ),
					'all_items'          => __( 'All Books', 'lumen-catalog' ),
					'featured_image'     => __( 'Cover', 'lumen-catalog' ),
					'set_featured_image' => __( 'Set cover', 'lumen-catalog' ),
					'item_published'     => __( 'Book published.', 'lumen-catalog' ),
				),
				'description'     => __( 'Titles in the Lumen Press catalogue.', 'lumen-catalog' ),
				'public'          => true,
				'show_in_rest'    => true,
				'menu_icon'       => 'dashicons-book-alt',
				'menu_position'   => 20,
				'has_archive'     => true,
				'rewrite'         => array(
					'slug'       => 'books',
					'with_front' => false,
				),
				'supports'        => array(
					'title',
					'editor' => array( 'notes' => true ),
					'excerpt',
					'thumbnail',
					'custom-fields',
					'revisions',
				),
				'taxonomies'      => array( 'lp_genre', 'lp_series' ),
				'capability_type' => array( 'lp_book', 'lp_books' ),
				'map_meta_cap'    => true,
				'template'        => array(
					array(
						'core/paragraph',
						array( 'placeholder' => __( 'Blurb: two or three sentences for the catalogue.', 'lumen-catalog' ) ),
					),
					array(
						'core/heading',
						array(
							'level'   => 2,
							'content' => __( 'About the author', 'lumen-catalog' ),
						),
					),
					array(
						'core/paragraph',
						array( 'placeholder' => __( 'A short biography.', 'lumen-catalog' ) ),
					),
				),
				'template_lock'   => 'insert',
			)
		);

		register_post_status(
			'lp_out_of_print',
			array(
				'label'                     => _x( 'Out of print', 'book status', 'lumen-catalog' ),
				/* translators: %s: Number of books. */
				'label_count'               => _n_noop( 'Out of print <span class="count">(%s)</span>', 'Out of print <span class="count">(%s)</span>', 'lumen-catalog' ),
				'public'                    => true,
				'exclude_from_search'       => false,
				'show_in_admin_all_list'    => true,
				'show_in_admin_status_list' => true,
			)
		);
	}

	/**
	 * Refuses the public lp_out_of_print status to users who cannot publish books.
	 * Hooked to rest_pre_insert_lp_book, which the block editor's saves go through.
	 *
	 * @param \stdClass|\WP_Error $prepared_post Post about to be inserted or updated.
	 * @return \stdClass|\WP_Error
	 */
	public static function guard_status( $prepared_post ) {
		if ( is_wp_error( $prepared_post ) || ! isset( $prepared_post->post_status ) ) {
			return $prepared_post;
		}
		if ( 'lp_out_of_print' === $prepared_post->post_status && ! current_user_can( 'publish_lp_books' ) ) {
			return new \WP_Error(
				'lp_cannot_set_status',
				__( 'Sorry, you are not allowed to mark books as out of print.', 'lumen-catalog' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return $prepared_post;
	}
}

Plugin::boot() hooks the registrations to init, taxonomies first (the reason is in Rewrite rules and flushing):

	// Taxonomies first: their rewrite rules must precede the post type's catch-all rules.
	add_action( 'init', array( Taxonomies::class, 'register' ) );
	add_action( 'init', array( PostTypes::class, 'register' ) );
	add_filter( 'rest_pre_insert_lp_book', array( PostTypes::class, 'guard_status' ) );

Registration is not stored: it runs on every request, on init. Registering earlier runs before the rewrite object and translations are ready, and since 6.7 a translation call before init triggers a "loaded too early" notice.

The arguments that matter

Argument Default Effect

labels / label

from post or page

Texts of the admin screens, menus, editor and notices; see below

public

false

Default for publicly_queryable, show_ui, show_in_nav_menus and the inverse of exclude_from_search. A non-public type has no front-end URLs

show_ui / show_in_menu

public

Admin list and edit screens; show_in_menu may be a parent slug such as tools.php to nest the menu

exclude_from_search

! public

Leaves the type out of front-end search and out of 'post_type' => 'any' queries

show_in_rest

false

REST routes under /wp/v2/<rest_base> and the block editor. Without it, the classic editor is used

rest_base, rest_namespace, rest_controller_class

key, wp/v2, WP_REST_Posts_Controller

Route name, namespace and controller of the REST endpoint

menu_icon, menu_position

posts icon, below Comments

A Dashicon name, an SVG data URI or an image URL; 20 places Catalogue below Pages

hierarchical

false

Parent/child items like pages (and page-attributes support for the Parent select)

supports

title, editor

Editor features; see below

taxonomies

none

Taxonomies attached to the type (same as passing the type to register_taxonomy())

has_archive

false

true gives an archive at the rewrite slug; a string sets its own slug

rewrite

true (slug = key)

slug, with_front, feeds, pages, ep_mask; false disables pretty permalinks for the type

capability_type, capabilities, map_meta_cap

post, generated, null

Permissions; see Capabilities: capability_type and map_meta_cap

template, template_lock

none, false

Initial blocks of a new item and how locked they are

The key must be 1 to 20 characters of lowercase letters, digits, - and _; a longer key returns a WP_Error (post_type_length_invalid) and triggers _doing_it_wrong. A prefix such as lp_ avoids collisions with core types and other plugins, and the key should not be a reserved query variable (author, name, type, year, …​).

Labels

Labels not given fall back to the generic ones of posts (or pages for hierarchical types): wp post-type get lp_book --format=yaml still shows insert_into_item as "Insert into post", while name_admin_bar ("Book") and template_name ("Single item: Book", the Site Editor’s name for single-lp_book) are derived from singular_name. Labels are translated with the plugin’s text domain (see Internationalization).

supports and Notes

supports lists editor features. Each one switches on a panel or a meta box and is checked with post_type_supports():

Feature Effect

title, editor

The title field and the content editor (block or classic)

excerpt

The Excerpt panel; the excerpt is used by the Post Excerpt block and in feeds

thumbnail

The Featured Image panel (relabelled "Cover" here); the theme also needs add_theme_support( 'post-thumbnails' ), which block themes have by default

custom-fields

The Custom Fields panel and, in the REST API, the meta field for registered meta

revisions

Revisions and the revisions timeline; autosave is added automatically with editor

author, comments, trackbacks, page-attributes, post-formats

Author select, discussion settings, menu order and parent, post formats

Since 5.3 a feature may carry arguments, and since 6.9 the editor feature accepts 'notes' => true, which turns on Notes (block-level editorial comments) for the type. Core registers posts and pages the same way, and wp post-type get lp_book shows the value stored as a list under editor.

A note on a book is a comment of type note. A POST /wp/v2/comments with post=5 and type=note, sent as maria.editor, is accepted for lp_book; on a type without this support the endpoint answers "Sorry, this post type does not support notes.", as the Notes developer note describes. add_post_type_support( 'lp_book', 'editor', array( 'notes' => true ) ) replaces the editor arguments rather than merging them, so a plugin adding Notes to a type registered elsewhere reads the existing ones first. Block Editor for Authors covers Notes from the author’s side.

show_in_rest and the block editor

The block editor is a JavaScript application that loads and saves through the REST API. A post type without 'show_in_rest' => true therefore has no /wp/v2/ route and always opens in the classic editor, whatever the editor support says; Notes, block templates and headless front ends cannot use it either. A minimal registration without the argument shows both effects:

wp eval 'register_post_type( "lp_legacy", array( "public" => true, "supports" => array( "title", "editor" ) ) );
  var_dump( use_block_editor_for_post_type( "lp_legacy" ), use_block_editor_for_post_type( "lp_book" ) );'
curl -s -o /dev/null -w "%{http_code}\n" https://lumenpress.test/wp-json/wp/v2/lp_legacy
bool(false)
bool(true)
404

Older books register their custom post type examples without show_in_rest. That code still runs, but on WordPress 5.0 and later it produces a type edited with the classic editor and invisible to the REST API. Add the argument; the use_block_editor_for_post_type filter is only for the opposite case, keeping a REST-enabled type on the classic editor. The defaults rest_base and rest_namespace are kept; the plugin’s own endpoints live in lumen/v1 (see REST API: Extending It).

has_archive and rewrite

'rewrite' => array( 'slug' => 'books' ) gives single books the URL /books/<slug>/, and 'has_archive' => true gives the type an archive at the same slug, /books/, with feeds (/books/feed/) and pagination (/books/page/2/). with_front matters when the permalink structure has a static prefix such as /blog/%postname%/: with the default true, books would live under /blog/books/; false keeps them at /books/. get_post_type_object( 'lp_book' )->rewrite holds the result: slug books, with_front false, and the defaults pages and feeds true and ep_mask EP_PERMALINK.

A slug shared with a page (a page called "Books" at /books/) loses: the post type archive rule matches first, and the page becomes unreachable. Pick the archive slug before the editors create pages, or set has_archive to a different string.

Capabilities: capability_type and map_meta_cap

With the default 'capability_type' => 'post', anyone who may edit posts may edit books. Lumen Press needs book permissions of their own for its Catalogue Manager role, so the type passes a singular and plural base, array( 'lp_book', 'lp_books' ), and WordPress generates the capability names:

wp post-type get lp_book --field=cap --format=json
{"edit_post":"edit_lp_book","read_post":"read_lp_book","delete_post":"delete_lp_book",
 "edit_posts":"edit_lp_books","edit_others_posts":"edit_others_lp_books","delete_posts":"delete_lp_books",
 "publish_posts":"publish_lp_books","read_private_posts":"read_private_lp_books","read":"read",
 "delete_private_posts":"delete_private_lp_books","delete_published_posts":"delete_published_lp_books",
 "delete_others_posts":"delete_others_lp_books","edit_private_posts":"edit_private_lp_books",
 "edit_published_posts":"edit_published_lp_books","create_posts":"edit_lp_books"}

Two kinds of capability appear. Meta capabilities (edit_lp_book, read_lp_book, delete_lp_book) are about one book and are never granted to a role. current_user_can( 'edit_post', $book_id ) asks for one, and map_meta_cap() translates it into the primitive capabilities the situation requires (the author, the status, whether the book is published). Primitive capabilities (edit_lp_books, edit_others_lp_books, publish_lp_books, …​) are what roles hold.

'map_meta_cap' => true turns that translation on for the new names. Without it, a custom capability_type leaves edit_lp_book unmapped, so only a user given edit_lp_book itself (which then covers every book) can edit anything. For the published book 5, owned by maria.editor, map_meta_cap( 'edit_post', 3, 5 ) returns edit_others_lp_books and edit_published_lp_books for tom.author, while for the owner it returns only edit_published_lp_books.

No role has the new capabilities until something grants them, and that includes administrators: right after activation, the Catalogue menu would be missing for everyone. Capabilities are stored in the wp_user_roles option (lp_user_roles with this site’s table prefix), so they are granted once, on activation, not on every request. Installer::activate() gives Administrators and Editors the full set:

	/**
	 * Gives administrators and editors every lp_book capability (stored in the roles option, so once is enough).
	 */
	public static function grant_capabilities(): void {
		$caps = array_values( (array) get_post_type_object( 'lp_book' )->cap );
		$caps = array_diff( $caps, array( 'read', 'edit_lp_book', 'read_lp_book', 'delete_lp_book' ) );

		$caps[] = 'manage_lp_terms';

		foreach ( array( 'administrator', 'editor' ) as $role_name ) {
			$role = get_role( $role_name );
			if ( ! $role ) {
				continue;
			}
			foreach ( array_unique( $caps ) as $cap ) {
				$role->add_cap( $cap );
			}
		}
	}

wp cap list editor | grep lp_ then lists ten primitive capabilities plus manage_lp_terms. The Catalogue Manager role and uninstall clean-up are on Roles and Capabilities API. WPCS 3.4.1 warns about custom names in current_user_can() until they are listed in its custom_capabilities property.

Block template and template_lock

template is the list of blocks a new book starts with: each entry is a block name, its attributes and, optionally, inner blocks. The lp_book template opens the editor with a blurb paragraph, an "About the author" heading and a biography paragraph, both paragraphs showing their placeholders. template_lock decides what the author may change:

Value What the author can do

false (default)

Anything: the template is only a starting point

'insert'

Edit and move the template’s blocks, but not add or remove blocks

'all'

Edit the content only; no adding, removing or moving

'contentOnly'

Edit text and media of the blocks only; the blocks are not selectable for anything else and their settings are hidden

Lumen Press uses 'insert': catalogue entries stay uniform, and authors can still reorder the sections. Cover, ISBN and page count are not blocks of the template; they are the featured image and meta fields, which keeps them out of the content and available to the REST API and the theme’s blocks. The template only applies to items created after it was set; existing books keep their content. wp-admin/edit-form-blocks.php passes template and templateLock to the editor settings only when the type has a template.

A block’s own templateLock attribute changes the lock for its children (see Attributes, Supports and InnerBlocks).

Custom post statuses

Core statuses are publish, future, draft, pending, private, trash, auto-draft and inherit. register_post_status() adds one. Lumen Press marks books it no longer sells as Out of print: the page stays online, but the list screen can filter them. The arguments, as registered in PostTypes::register() above:

Argument Value here Effect

label, label_count

"Out of print"

Name in list screens; label_count is the "Out of print (2)" link above the list

public

true

Items are visible on the front end to everyone, like publish

exclude_from_search

false

Excluded from 'post_status' => 'any' queries when true; it does not affect the front-end search

show_in_admin_all_list, show_in_admin_status_list

true

Counted in "All" and shown as a filter link on the list screen

What a custom status does and does not do, checked on 7.1.3:

  • It is global: a status is not tied to a post type, so lp_out_of_print is valid for posts and pages too.

  • The block editor does not offer it: the Status panel’s options (Draft, Pending, Private, Scheduled, Published) are fixed in its JavaScript. Set it by code, WP-CLI (wp post update 7 --post_status=lp_out_of_print) or REST.

  • REST lists only publish by default: status=lp_out_of_print needs edit_lp_books (anonymous requests get rest_forbidden_status), but the single public book /wp/v2/lp_book/7 is readable by anyone.

  • The front end shows it wherever a published book appears (archives, home page, search), because front-end queries include every public status; a listing that must hide it sets post_status itself.

  • 'post_status' => 'any' honours exclude_from_search. With true, wp post list --post_status=any and any WP_Query asking for any silently skip the status.

A custom public status bypasses the publish check

The REST posts controller checks publish_posts only when the requested status is publish, future or private; any other registered status is accepted from anyone who may create the item. On a site where Authors were given edit_lp_books without publish_lp_books, tom.author gets rest_cannot_publish for status=publish, but can put a book online through the public status:

curl -s -u "tom.author:$APP_PASSWORD" -X POST https://lumenpress.test/wp-json/wp/v2/lp_book \
  -d title=Sneaky -d status=lp_out_of_print -d "_fields=id,status,link"
{"id":9,"status":"lp_out_of_print","link":"https:\/\/lumenpress.test\/books\/sneaky\/"}

PostTypes::guard_status(), hooked to the dynamic filter rest_pre_insert_lp_book, closes the gap for the REST API, which every block-editor save goes through. With it in place the same request is refused:

{"code":"lp_cannot_set_status","message":"Sorry, you are not allowed to mark books as out of print.","data":{"status":403}}

Code that calls wp_insert_post() directly (an importer, a WP-CLI command) does its own capability check; see Security for Developers.

Registering the taxonomies

The registration code

<?php
/**
 * Taxonomies: lp_genre (hierarchical) and lp_series (flat).
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Registers the catalogue's two taxonomies for lp_book. */
final class Taxonomies {

	/** Hooked to init before PostTypes::register(), and called directly on activation. */
	public static function register(): void {
		$capabilities = array(
			'manage_terms' => 'manage_lp_terms',
			'edit_terms'   => 'manage_lp_terms',
			'delete_terms' => 'manage_lp_terms',
			'assign_terms' => 'edit_lp_books',
		);

		register_taxonomy(
			'lp_genre',
			array( 'lp_book' ),
			array(
				'labels'            => array(
					'name'              => _x( 'Genres', 'taxonomy general name', 'lumen-catalog' ),
					'singular_name'     => _x( 'Genre', 'taxonomy singular name', 'lumen-catalog' ),
					'all_items'         => __( 'All Genres', 'lumen-catalog' ),
					'parent_item'       => __( 'Parent Genre', 'lumen-catalog' ),
					'parent_item_colon' => __( 'Parent Genre:', 'lumen-catalog' ),
					'add_new_item'      => __( 'Add New Genre', 'lumen-catalog' ),
				),
				'hierarchical'      => true,
				'public'            => true,
				'show_in_rest'      => true,
				'show_admin_column' => true,
				'rewrite'           => array(
					'slug'         => 'books/genre',
					'with_front'   => false,
					'hierarchical' => true,
				),
				'capabilities'      => $capabilities,
				'default_term'      => array(
					'name' => __( 'Uncategorised', 'lumen-catalog' ),
					'slug' => 'uncategorised',
				),
			)
		);

		register_taxonomy(
			'lp_series',
			array( 'lp_book' ),
			array(
				'labels'            => array(
					'name'                       => _x( 'Series', 'taxonomy general name', 'lumen-catalog' ),
					'singular_name'              => _x( 'Series', 'taxonomy singular name', 'lumen-catalog' ),
					'all_items'                  => __( 'All Series', 'lumen-catalog' ),
					'add_new_item'               => __( 'Add New Series', 'lumen-catalog' ),
					'separate_items_with_commas' => __( 'Separate series with commas', 'lumen-catalog' ),
					'choose_from_most_used'      => __( 'Choose from the most used series', 'lumen-catalog' ),
				),
				'hierarchical'      => false,
				'public'            => true,
				'show_in_rest'      => true,
				'show_admin_column' => true,
				'rewrite'           => array(
					'slug'       => 'books/series',
					'with_front' => false,
				),
				'capabilities'      => $capabilities,
				'sort'              => true,
			)
		);
	}
}

Hierarchical and flat taxonomies

Hierarchical (lp_genre, like categories) Flat (lp_series, like tags)

Terms

Have parents: Fiction > Crime fiction

No parents

Editor panel

A checkbox tree with "Add New Genre"

A token field with suggestions; terms are created by typing

REST term field

parent

No parent

Archive URL with 'hierarchical' => true in rewrite

/books/genre/fiction/crime/ (the full path)

/books/series/harbour-lights/

A term of a hierarchical taxonomy is not implicitly attached to its ancestors: a book tagged "Crime fiction" is not in the "Fiction" term’s count, but the Fiction archive shows it because taxonomy queries include children by default (include_children).

Other taxonomy arguments

The visibility arguments (public, publicly_queryable, show_ui, show_in_menu, show_in_nav_menus, query_var, rewrite) mean the same as for post types; the term screens appear under the Catalogue menu. show_in_rest matters even more here: without it the block editor shows no panel for the taxonomy. show_admin_column adds Genres and Series columns to the books list, show_in_quick_edit and show_tagcloud control Quick Edit and the Tag Cloud block, and meta_box_cb replaces the classic editor’s meta box (the block editor ignores it). capabilities maps manage_terms, edit_terms, delete_terms and assign_terms, which default to manage_categories for the first three and edit_posts for assigning. sort keeps terms in the order they were assigned. The key may be up to 32 characters; avoid reserved query variable names such as type, name or author, which break front-end queries without an error message.

Attaching taxonomies to post types

There are three equivalent ways: pass the post types to register_taxonomy(), list the taxonomies in the post type’s taxonomies argument, or call register_taxonomy_for_object_type() after both exist. The lp_book registration uses the first two, which is harmless and documents the link in both places. The same functions attach a built-in taxonomy to a custom type (register_taxonomy_for_object_type( 'post_tag', 'lp_book' )), but Lumen Press keeps the blog’s tags away from the catalogue on purpose (see Categories, Tags and Comments).

The default term and term capabilities

default_term creates "Uncategorised" on the first registration and stores its ID in the default_term_lp_genre option. It is assigned when a book is saved without a genre by a user who may assign genres: wp_insert_post() applies terms only if current_user_can() passes the taxonomy’s assign_terms check. A WP-CLI command run without --user has no user, so the default term is skipped: on the test site a draft created without --user had no genre, and the same command with --user=maria.editor got "Uncategorised".

The custom term capabilities separate who curates the vocabulary (manage_lp_terms, granted to Administrators and Editors) from who tags books (edit_lp_books). An Author with edit_lp_books can choose existing genres but cannot create one: POST /wp/v2/lp_genre as tom.author returns rest_cannot_create ("Sorry, you are not allowed to create terms in this taxonomy.").

Rewrite rules and flushing

Pretty permalinks work by matching the requested path against an ordered list of regular expressions, the rewrite rules, stored in the rewrite_rules option. Registering a post type or taxonomy adds its rules to the in-memory list, but the stored list is rebuilt only when the rules are flushed: by flush_rewrite_rules(), by saving Settings > Permalinks, or by wp rewrite flush. Until then, new URLs return 404 and changed slugs keep the old ones. On the test site, changing the book slug to catalogue with the register_post_type_args filter left /catalogue/the-lantern-keeper/ at 404 and the old URL at 200; after wp rewrite flush the new URL answered 200 and the old one redirected to it with a 301.

Flushing is expensive (it regenerates every rule and writes an option, and a hard flush rewrites .htaccess), so it runs only on activation and deactivation, never on init or another per-request hook. On activation the plugin has not yet run its init callbacks, so Installer::activate() registers the types itself before flushing; on deactivation it unregisters them first, so their URLs stop answering:

	// In Lumen\Catalog\Installer.
	public static function activate(): void {
		self::upgrade();

		Taxonomies::register();
		PostTypes::register();
		self::grant_capabilities();
		flush_rewrite_rules( false );
	}

	public static function deactivate(): void {
		wp_clear_scheduled_hook( 'lp_catalog_sync_stock' );

		unregister_post_type( 'lp_book' );
		unregister_taxonomy( 'lp_genre' );
		unregister_taxonomy( 'lp_series' );
		flush_rewrite_rules( false );
	}

flush_rewrite_rules( false ) is a soft flush: it updates the option without touching .htaccess, which the plugin does not need. After deactivation /books/ and /books/genre/fiction/ return 404; after reactivation both return 200 again. A plugin updated in place (not reactivated) that changes a slug calls flush_rewrite_rules() once from its versioned upgrade routine (see Plugin Fundamentals).

Registration order matters when slugs nest. Each post type adds an attachment rule, books/[^/]+/([^/]+)/?$, that matches any two segments after books/. Registered after the post type, the genre rules come too late and /books/genre/fiction/ is read as an attachment called "fiction":

wp rewrite list --match=/books/genre/fiction/ --fields=match,query,source --format=table
match                    query                              source
books/[^/]+/([^/]+)/?$   index.php?attachment=$matches[1]   lp_book
books/genre/(.+?)/?$     index.php?lp_genre=$matches[1]     lp_genre

In that order /books/genre/fiction/ and /books/series/harbour-lights/ return 404, while /books/genre/fiction/crime/ (three segments) still works, which makes the bug easy to miss. Registering the taxonomies first, as Plugin::boot() does, puts books/genre/(.+?)/?$ at the top. The Rewrite API and custom rules are covered on Rewrite API and Permalinks.

Templates for books and genres

Registering lp_book adds slots to the template hierarchy that a theme can fill; nothing in the plugin has to render HTML. In a block theme the files are HTML templates in templates/; in a classic theme, PHP files:

Request Templates tried, most specific first

A single book

single-lp_book-the-lantern-keeper, single-lp_book, single, singular, index

The archive /books/

archive-lp_book, archive, index

A genre /books/genre/fiction/crime/

taxonomy-lp_genre-crime, taxonomy-lp_genre, taxonomy, archive, index

A series /books/series/harbour-lights/

taxonomy-lp_series-harbour-lights, taxonomy-lp_series, taxonomy, archive, index

The lumen theme ships archive-lp_book.html and single-lp_book.html (see Templates and Template Parts), and the plugin can provide a fallback for themes that do not with register_block_template() (6.7+), shown on the same page. The full lookup order is on Template Hierarchy.

Querying books

A custom post type is queried like any other, with WP_Query and 'post_type' => 'lp_book'. A tax_query on lp_genre with the slug fiction also returns "Crime fiction" books, because include_children defaults to true. A custom type does not appear on the blog home page, which shows posts only; a pre_get_posts callback that checks is_main_query() and is_home() and sets 'post_type' => array( 'post', 'lp_book' ) adds books there (on the test site the home page then listed both books and "Hello world!"). Query patterns are on WP_Query and the Database API.

Verifying the registration

After activation, WP-CLI, the REST API and plain HTTP requests confirm each part. The taxonomies:

wp taxonomy list --object_type=lp_book --fields=name,label,hierarchical,public,show_in_rest,object_type --format=table
name       label   hierarchical  public  show_in_rest  object_type
lp_genre   Genres  1             1       1             lp_book
lp_series  Series                1       1             lp_book

The test content is a genre "Fiction" (term 3) with the child "Crime fiction" (term 4, slug crime), a series "Harbour Lights" (term 5), created with wp term create, and the published book "The Lantern Keeper" (post 5), tagged with wp post term set.

The REST API describes the type and serves the books and terms anonymously:

curl -s "https://lumenpress.test/wp-json/wp/v2/types/lp_book?_fields=slug,rest_base,rest_namespace,taxonomies,hierarchical,has_archive"
curl -s "https://lumenpress.test/wp-json/wp/v2/lp_book?_fields=id,slug,status,link,lp_genre,lp_series"
curl -s "https://lumenpress.test/wp-json/wp/v2/lp_genre?_fields=id,name,slug,parent,count"
{"hierarchical":false,"has_archive":true,"slug":"lp_book","taxonomies":["lp_genre","lp_series"],"rest_base":"lp_book","rest_namespace":"wp\/v2"}
[{"id":5,"slug":"the-lantern-keeper","status":"publish","link":"https:\/\/lumenpress.test\/books\/the-lantern-keeper\/","lp_genre":[4],"lp_series":[5]}]
[{"id":4,"count":1,"name":"Crime fiction","slug":"crime","parent":3},{"id":3,"count":0,"name":"Fiction","slug":"fiction","parent":0},{"id":2,"count":0,"name":"Uncategorised","slug":"uncategorised","parent":0}]

Each term array in a book holds term IDs; ?_embed or a second request resolves them. The permalinks:

for u in /books/ /books/the-lantern-keeper/ /books/genre/fiction/ /books/genre/fiction/crime/ \
         /books/series/harbour-lights/ /books/feed/; do
  printf "%s " "$u"; curl -s -o /dev/null -w "%{http_code}\n" "https://lumenpress.test$u"
done
/books/ 200
/books/the-lantern-keeper/ 200
/books/genre/fiction/ 200
/books/genre/fiction/crime/ 200
/books/series/harbour-lights/ 200
/books/feed/ 200

The genre pages render with the term as their title ("Fiction – Lumen Press", "Crime fiction – Lumen Press").

Errors and pitfalls

Symptom Cause and fix

/books/ and every book return 404 right after activation

The rules were not flushed, or were flushed before the type was registered. Register in the activation callback, then call flush_rewrite_rules(); on an existing site run wp rewrite flush

/books/genre/fiction/ returns 404 but /books/genre/fiction/crime/ works

Taxonomy registered after the post type whose slug it nests under; register taxonomies first and flush

Books open in the classic editor and /wp/v2/lp_book is 404

show_in_rest is missing

The Genres panel is missing from the block editor

The taxonomy has no show_in_rest, or the user lacks its assign_terms capability

The Catalogue menu disappears for administrators after switching to custom capabilities

No role was granted the new capabilities; grant them on activation

"Post type names must be between 1 and 20 characters in length."

The key is too long; renaming a key later means updating post_type in every existing row

Where older books are outdated

Older claim or example Current behaviour (WordPress 7.1)

A custom post type registered with public, labels and supports only (no show_in_rest)

Works, but the type uses the classic editor and has no REST endpoint. Add 'show_in_rest' => true so that the block editor, Notes, templates and headless front ends work

flush_rewrite_rules() inside the init callback that registers the type

Flush once on activation and deactivation; per-request flushing is slow and can race between requests

Custom post types and taxonomies registered in a theme’s functions.php

They belong in a plugin, so the content survives a theme switch; the theme supplies single-lp_book and archive-lp_book templates

A custom post status selectable in the editor after register_post_status()

The block editor’s Status panel shows only core statuses; set custom statuses from code, WP-CLI or REST, and check permissions for public ones