A publisher keeps its catalogue as blog posts. Every book gets a post, a category called Books, and a hand-built archive page. The RSS feed fills with catalogue entries, search results mix reviews with product listings, and the ISBN sits in a line of bold text at the top of the post body because there was nowhere else to put it.
That is the problem a custom post type solves, and solving it takes about fifty lines of PHP. No database migration, no build step, no third-party dependency.
Most of the confusion around the feature comes from one misreading of what the code does. People assume register_post_type() creates something permanent. It does not create a table, a template, or a URL that stands on its own. It hands WordPress a description of a content type at runtime, on every single request, and everything you see in the admin and on the front end follows from that description being present.
A column value, not a table
A custom post type is a registered label applied to rows in the existing wp_posts table. Posts, pages, attachments, revisions, navigation menu items, reusable blocks and your new type all live in that one table, separated only by the value of the post_type column. Any custom post type WordPress serves on the front end is still a row in wp_posts and nothing more. The column is declared varchar(20) in wp-admin/includes/schema.php, which is why register_post_type() returns a WP_Error for any key longer than twenty characters.
That single fact explains most of the surprising behaviour people run into.
IDs are shared. Post 412 might be a page and post 413 a book, because the auto-increment counter belongs to the table, not to the type. Metadata is shared too: custom fields for every type sit in wp_postmeta, keyed by the same post_id.
Queries cross the boundary without meaning to. A WP_Query with 'post_type' => 'any' resolves to every type registered with exclude_from_search set to false, which for a public type is the default. A theme that hooks pre_get_posts without checking is_main_query() can drag your catalogue into places you never intended. The type is a filter on a shared pile of rows, and if nothing applies the filter, the rows come back.
And when the registration disappears, the content does not. Deactivate the plugin that registered book and the rows stay exactly where they were. But WordPress no longer knows what book means, so the admin menu vanishes, the items appear in no list table, and the front-end URLs 404. Re-register the type and everything reappears intact. Content survives; visibility does not.
Where the registration code belongs
register_post_type() belongs on the init hook. Core registers its own types the same way: wp-includes/default-filters.php hooks create_initial_post_types() to init at priority 0, and wp-settings.php calls the same function directly during load with a comment warning plugin authors that everything gets registered again on init. Hook your call at the default priority 10 and it lands in the same pass, after core’s types exist and before the request is parsed.
The more consequential decision is which file the call lives in. Dropping it into functions.php works, and on a site you will never re-theme it may be fine. But a post type registered in a theme is bound to that theme’s lifetime. Switch themes and the type stops being registered: the admin menu goes, the permalinks 404, and every URL you published for those items breaks. The content is still in wp_posts, invisible, waiting for something to claim it again.
Moving the code into a child theme does not fix the coupling. A child theme is still a theme, and still one activation click away from being replaced. The rule that helps is a rough separation of concerns: presentation belongs to the theme, content structure belongs to a plugin. If you are unsure which side something falls on, ask whether the site would still make sense if the design changed tomorrow. Book URLs would. A footer layout would not.
This is the textbook case for a small single-purpose plugin. It is one file in wp-content/plugins/, it takes a minute to create, and it survives every theme change the site will go through. The difference between functions.php and a site-specific plugin matters most for exactly this kind of code. A plugin also fails more gracefully: introduce a syntax error and you can rename the plugin folder over SFTP to get the site back, whereas a broken theme file can leave you looking at a white screen with fewer ways out.
The registration code
Create wp-content/plugins/site-book-library/site-book-library.php with the following. It is complete: activate it and the type works.
<?php
/**
* Plugin Name: Site Book Library
* Description: Registers the "book" post type and its taxonomies.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function sitelib_register_book_post_type() {
$labels = array(
'name' => 'Books',
'singular_name' => 'Book',
'menu_name' => 'Books',
'all_items' => 'All Books',
'add_new_item' => 'Add New Book',
'edit_item' => 'Edit Book',
'new_item' => 'New Book',
'view_item' => 'View Book',
'view_items' => 'View Books',
'search_items' => 'Search Books',
'not_found' => 'No books found.',
'not_found_in_trash' => 'No books found in Trash.',
'archives' => 'Book Archives',
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 20,
'menu_icon' => 'dashicons-book-alt',
'rewrite' => array(
'slug' => 'books',
'with_front' => false,
),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ),
);
register_post_type( 'book', $args );
}
add_action( 'init', 'sitelib_register_book_post_type' );
Two notes on the key itself, the string book. It should be lowercase, twenty characters or fewer, and made of letters, numbers, dashes and underscores. It also has to avoid the names core already uses or relies on as query variables: post, page, attachment, revision, nav_menu_item, wp_block, wp_template, and words like action, author, order, theme and type. On a client site, prefixing the key (acme_book) is cheap insurance against a future plugin claiming the same generic name.
The labels are the tedious part, and the part where the typos live. The generator below writes all twenty five of them from a singular and a plural, and you can override any one of them.
It also enforces the rules that bite later: the twenty character limit on the key, the reserved names, the taxonomy registered before the post type, and the flush that belongs in an activation hook rather than in init. Output as a functions.php block or as a small plugin.
Post type and taxonomy generator
Fill in a key, a singular and a plural name, and get the registration code for a custom post type and its taxonomy, labels included. Everything is worked out in this browser tab, nothing is sent anywhere.
The key is what ends up in the database, so it is fixed once you have content: up to 20 characters, lower case, letters, digits, underscores and hyphens. WordPress keeps post, page, attachment, revision and nav_menu_item for itself, along with everything that starts with wp_.
All 25 labels, each one overridable
Every field below is filled from the singular and the plural name. Type into one to override just that label, leave it empty to keep the derived text.
With show_in_rest off there is no block editor for this post type, and it stays out of the REST API. The classic editor is used instead.
The code block scrolls, both down and sideways. Copy hands you exactly what you see, the download adds an opening PHP tag to the functions.php version so the file is valid on its own.
What each argument changes
The defaults in WP_Post_Type::set_props() are conservative. An unadorned register_post_type( 'book' ) gives you a type that is private, invisible in the admin, and unreachable on the front end. Every argument below moves it away from that starting point.
'public' defaults to false and acts as the parent switch. It does little by itself; it supplies the fallback for the arguments you leave unset. Set it to true and publicly_queryable, show_ui and show_in_nav_menus all inherit true, while exclude_from_search inherits the inverse and becomes false. show_in_menu then follows show_ui, and show_in_admin_bar follows show_in_menu. Setting the ones you care about explicitly, as the snippet does, makes the intent readable and lets you change one without disturbing the rest.
'publicly_queryable' controls whether front-end queries for the type are allowed at all. Turn it off and single items and archives 404 no matter what your rewrite rules say: is_post_type_viewable() reads this argument and nothing else for a non-builtin type. This is the one you want false for a type that exists only as data (a slider slide, a testimonial rendered inside another page) while leaving show_ui on so editors can still manage the entries.
'show_ui' decides whether the admin screens exist at all. 'show_in_menu' decides whether they appear in the sidebar, and it defaults to whatever show_ui resolved to. Passing a string instead of true ('tools.php', say) tells the edit screens to highlight that parent menu, but core then stops adding a top-level entry for the type and you supply the link yourself with add_submenu_page().
'menu_position' places a top-level entry: 5 sits under Posts, 10 under Media, 20 under Pages, 25 under Comments. If the slot is already taken, wp-admin/menu.php increments until it finds a free one. Left at its null default, the type is appended to the object-menu group that begins at 25, so it lands just below Comments in registration order rather than at the very bottom of the sidebar.
'show_in_rest' defaults to false, and this is the single most common cause of “why is my custom post type stuck on the classic editor”. The block editor is a REST API client. use_block_editor_for_post_type() returns false outright when the post type object’s show_in_rest is falsy, so WordPress falls back to the classic editing screen. There is no separate “enable Gutenberg” argument; 'show_in_rest' => true is it. The same argument governs whether the type is visible at /wp-json/wp/v2/book to any other REST consumer, including a headless front end or a mobile app.
'has_archive' defaults to false, which surprises people who expect an index page for free. Set it to true and the archive appears at the rewrite slug: /books/ in the snippet above. Pass a string instead and the list gets a different path from the items: 'has_archive' => 'library' puts the index at /library/ while individual books stay at /books/dune/.
'rewrite' defaults to true, meaning the URL slug is taken from the post type key. Passing an array separates the two, which is worth doing whenever the key is prefixed: the key can stay acme_book while the URL reads /books/. The with_front sub-argument, which defaults to true, controls whether your permalink structure’s front base is prepended. If permalinks are set to /blog/%postname%/, the default produces /blog/books/dune/; 'with_front' => false keeps the catalogue at the root. None of the rewrite handling runs at all if the site is still on plain permalinks.
'supports' determines which editing panels the type gets, and defaults to array( 'title', 'editor' ). Core adds autosave alongside editor for you. The values that matter most in practice are title, editor, thumbnail, excerpt, custom-fields, revisions, author, comments and page-attributes. Two have conditions attached. thumbnail only produces a featured image control if the active theme has called add_theme_support( 'post-thumbnails' ), and support declared for a specific list of post types will exclude yours. And custom-fields is what puts the meta property into the type’s REST schema, so without it, meta registered with show_in_rest will not save from the block editor.
'menu_icon' accepts a Dashicons class such as dashicons-book-alt, a data:image/svg+xml;base64, URI, a URL to an image file, or the string 'none' if you intend to style the icon in CSS yourself. Left at its null default, your type borrows dashicons-admin-post, the pushpin used by Posts, which makes the sidebar genuinely harder to scan once you have three custom types.
'hierarchical' defaults to false. True makes the type behave like Pages: items can have parents, the editor gets a Page Attributes panel, and URLs nest along the parent chain. It also turns the admin list table into a tree view, which becomes slow and unhelpful past a few hundred items. Reach for it when the structure really is a hierarchy (documentation sections, a staff directory by department), not because you like the look of nested lists.
'labels' is the argument people skip and then regret. Omit it and the screens fall back to the Post defaults: All Posts, Edit Post, Search Posts, No posts found. Passing only the shorthand 'label' => 'Books' does less than it looks like it does: _get_custom_object_labels() cascades that one value into name, singular_name, menu_name, all_items and archives, then stops, so the buttons and empty states still say Post. Supply the full array once and the admin reads correctly forever.
One argument the snippet leaves alone: 'capability_type', which defaults to 'post'. The new type is governed by the same capabilities as blog posts: edit_posts, publish_posts, delete_others_posts. Anyone who can publish a post can publish a book. Separating them means a custom capability type plus code to grant the new capabilities to roles, which is a larger job than it first appears and rarely worth it without a real editorial reason.
A taxonomy to go with it
A post type on its own gives you a flat list. Categories and tags are not automatically available to a custom type: they are taxonomies registered against post, and yours has to be registered against book explicitly. Same init hook, same file.
function sitelib_register_book_taxonomies() {
register_taxonomy(
'genre',
array( 'book' ),
array(
'labels' => array(
'name' => 'Genres',
'singular_name' => 'Genre',
),
'public' => true,
'hierarchical' => true,
'show_ui' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array( 'slug' => 'genre' ),
)
);
register_taxonomy(
'book_topic',
array( 'book' ),
array(
'labels' => array(
'name' => 'Topics',
'singular_name' => 'Topic',
),
'public' => true,
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array( 'slug' => 'topic' ),
)
);
}
add_action( 'init', 'sitelib_register_book_taxonomies' );
The only structural decision is hierarchical, which defaults to false here as well. True gives you a category: terms can have parents, and the editing panel is a checkbox list where authors pick from what already exists. False gives you a tag: a flat set with a free-text input that creates new terms as they are typed. The rule of thumb is who controls the vocabulary. For a fixed, curated set, make it hierarchical even if you never nest anything, because the checkbox interface discourages inventing new terms. For authors adding terms freely, make it flat.
Note that register_taxonomy() defaults 'public' to true, the opposite of register_post_type(). show_in_rest still defaults to false and matters here for the same reason it does on the post type: without it, the taxonomy panel does not appear in the block editor sidebar. show_admin_column defaults to false and adds a column to the books list table, which costs nothing and saves a lot of clicking. Taxonomy keys are capped at thirty-two characters rather than twenty, and they generate rewrite rules too, which brings us to the part that catches everyone.
The permalink trap
You activate the plugin, add a book, click View, and get a 404. Nothing is wrong with the code.
WordPress does not rebuild rewrite rules from scratch on every request. It generates them once and stores the result in the rewrite_rules option in wp_options. Registering a post type adds rules to the in-memory WP_Rewrite object, but the stored copy, the one actually consulted when a URL comes in, still has no idea what /books/dune/ means. Until that option is regenerated, single items and archives 404 while the admin screens work perfectly, which is what makes the symptom confusing.
Visiting Settings > Permalinks and loading the page is enough. wp-admin/options-permalink.php calls flush_rewrite_rules() as the screen renders, so you do not need to change a setting or press Save. On the command line, wp rewrite flush does the same thing.
What you should not do is the advice you will find on a lot of forum threads: calling flush_rewrite_rules() from your init callback. It works, in the sense that the 404 goes away, and it is still a bad idea. The call rebuilds the entire rule set and writes it back to the options table, and because the $hard parameter defaults to true, it also rewrites .htaccess on Apache or web.config on IIS. WP_Rewrite::flush_rules() even re-hooks itself to wp_loaded when it is called before that point, so the write happens on every front-end request from every visitor, not just in the admin. It is one of the more effective ways to make a site slow for a reason nobody can find later.
The correct home for an automatic flush is the plugin activation hook, which runs exactly once. Call the registration functions first, then flush, so the rules being written already include yours.
function sitelib_activate() {
sitelib_register_book_post_type();
sitelib_register_book_taxonomies();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'sitelib_activate' );
function sitelib_deactivate() {
unregister_post_type( 'book' );
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'sitelib_deactivate' );
The deactivation half clears the stale rules back out so a disabled plugin does not leave /books/ rules sitting in the option. Neither hook touches a single row of content.
Templates and the main query
Once the rules are live, the template hierarchy does the rest. In a classic theme, a single book looks for single-book.php, then single.php, then singular.php, then index.php. The archive looks for archive-book.php, then archive.php, then index.php. In a block theme the equivalents are templates/single-book.html and templates/archive-book.html, and the Site Editor lists both under Add New Template once the type is registered.
Because everything shares wp_posts, pulling the new type into an existing loop is a matter of adjusting the query rather than merging data. Adding books to the blog home page looks like this. Note the two guards, which are what keep the change out of the admin and out of every secondary query on the page.
function sitelib_include_books_on_home( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'post_type', array( 'post', 'book' ) );
}
}
add_action( 'pre_get_posts', 'sitelib_include_books_on_home' );
When a plugin is the better answer
Writing the registration yourself is right for a lot of sites and wrong for plenty of others. The honest version of the trade-off is about who maintains the site after you.
Use a registration plugin when non-developers will need to add types later. If the marketing team wants a Case Studies type next quarter and nobody on staff edits PHP, a UI for that is worth more than the fifty lines you saved.
Use a fields plugin when the type carries substantial custom meta. Registering the post type is the easy part; building meta boxes, sanitising and saving each field, handling revisions, and exposing everything to REST is where the real work sits. A mature field plugin has solved all of that, and reimplementing it by hand is rarely the best use of a budget.
Write it by hand when the type is core to the site’s structure, the field requirements are light, and you want the definition in version control where a code review can see it. A hand-written type is also the one that survives a plugin being abandoned, which is a real consideration for a URL structure you intend to keep for a decade. Many sites end up doing both: the type registered in code, the fields managed by a plugin. That combination is not a compromise, it is usually the right answer.
Symptoms and their causes
Every single-item URL returns 404, but the admin works fine. The rules stored in the rewrite_rules option were generated before your type existed. Load Settings > Permalinks once, or run wp rewrite flush.
The type opens in the classic editor instead of the block editor. show_in_rest is missing or false: it defaults to false. The block editor cannot load a type the REST API does not expose, so WordPress falls back.
Single items work but the archive URL 404s. has_archive defaults to false and has to be set explicitly. If it is set and the archive still fails, the rules need flushing again.
Every button and heading in the admin says “Post”. No labels array was passed, so the Post defaults are in use. Passing only label fills the name-derived labels and leaves the action and empty-state strings untouched.
No featured image panel, even though thumbnail is in supports. The active theme has not called add_theme_support( 'post-thumbnails' ), or it declared support for a specific list of post types that does not include yours.
The type disappeared after a theme switch and the content is gone from the admin. The registration lived in the old theme’s functions.php. The rows are untouched in wp_posts; move the registration into a plugin and everything comes back.
Custom fields silently fail to save from the block editor. The post type is missing custom-fields in its supports array, which is what adds the meta property to the type’s REST schema in the first place.
What the code buys you
The fifty lines above are not really about avoiding a plugin. They are about knowing precisely what your site’s content structure is, in a file you can read, that changes only when you change it. When something behaves oddly two years from now, the answer is in one function rather than spread across a settings screen, a database option and someone else’s release notes.
The mental model worth keeping is the one from the beginning. Nothing you registered exists in the database as a structure. It exists as a description WordPress rebuilds on every request, applied to rows that were already there. That is why the code has to run on init, why it has to live somewhere permanent, and why removing the registration hides content instead of destroying it.
Get the type into a plugin, set show_in_rest, flush the rules once on activation, and the rest is ordinary WordPress. The catalogue stops fighting the blog, the URLs stay put through every redesign, and the ISBN finally has somewhere to live.