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 |
|---|---|---|
|
from |
Texts of the admin screens, menus, editor and notices; see below |
|
|
Default for |
|
|
Admin list and edit screens; |
|
|
Leaves the type out of front-end search and out of |
|
|
REST routes under |
|
key, |
Route name, namespace and controller of the REST endpoint |
|
posts icon, below Comments |
A Dashicon name, an SVG data URI or an image URL; |
|
|
Parent/child items like pages (and |
|
|
Editor features; see below |
|
none |
Taxonomies attached to the type (same as passing the type to |
|
|
|
|
|
|
|
|
Permissions; see Capabilities: |
|
none, |
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 |
|---|---|
|
The title field and the content editor (block or classic) |
|
The Excerpt panel; the excerpt is used by the Post Excerpt block and in feeds |
|
The Featured Image panel (relabelled "Cover" here); the theme also needs |
|
The Custom Fields panel and, in the REST API, the |
|
Revisions and the revisions timeline; |
|
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 |
|---|---|
|
Anything: the template is only a starting point |
|
Edit and move the template’s blocks, but not add or remove blocks |
|
Edit the content only; no adding, removing or moving |
|
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 |
|---|---|---|
|
"Out of print" |
Name in list screens; |
|
|
Items are visible on the front end to everyone, like |
|
|
Excluded from |
|
|
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_printis 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
publishby default:status=lp_out_of_printneedsedit_lp_books(anonymous requests getrest_forbidden_status), but the single public book/wp/v2/lp_book/7is 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_statusitself. -
'post_status' => 'any'honoursexclude_from_search. Withtrue,wp post list --post_status=anyand anyWP_Queryasking foranysilently 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"
Source: REST API Handbook: Posts
{"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 |
|
No |
Archive URL with |
|
|
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
Source: WP-CLI: wp rewrite list
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 |
|
The archive |
|
A genre |
|
A series |
|
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
Source: WP-CLI: wp taxonomy list
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 |
|---|---|
|
The rules were not flushed, or were flushed before the type was registered. Register in the activation callback, then call |
|
Taxonomy registered after the post type whose slug it nests under; register taxonomies first and flush |
Books open in the classic editor and |
|
The Genres panel is missing from the block editor |
The taxonomy has no |
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 |
Where older books are outdated
| Older claim or example | Current behaviour (WordPress 7.1) |
|---|---|
A custom post type registered with |
Works, but the type uses the classic editor and has no REST endpoint. Add |
|
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 |
They belong in a plugin, so the content survives a theme switch; the theme supplies |
A custom post status selectable in the editor after |
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 |