Custom Post Type Plugin Generator
A complete installable plugin for a post type: every label, optional taxonomies, and an activation hook that registers before it flushes so singles do not 404.
<?php
/**
* Plugin Name: Book Library
* Description: Registers the Book post type, with genres and tags.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Author: Your Name
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: book-library
*
* The post type is registered on init. The activation hook registers it and then
* flushes the rewrite rules, in that order: flushing before the rules exist is
* what leaves single entries returning 404 until someone visits Settings →
* Permalinks.
*/
defined( 'ABSPATH' ) || exit;
/**
* Registers the book post type.
*
* @return void
*/
function book_library_register_post_type() {
$labels = array(
'name' => _x( 'Books', 'post type general name', 'book-library' ),
'singular_name' => _x( 'Book', 'post type singular name', 'book-library' ),
'menu_name' => _x( 'Books', 'admin menu', 'book-library' ),
'name_admin_bar' => _x( 'Book', 'add new on admin bar', 'book-library' ),
'add_new' => __( 'Add New', 'book-library' ),
'add_new_item' => __( 'Add New Book', 'book-library' ),
'new_item' => __( 'New Book', 'book-library' ),
'edit_item' => __( 'Edit Book', 'book-library' ),
'view_item' => __( 'View Book', 'book-library' ),
'view_items' => __( 'View Books', 'book-library' ),
'all_items' => __( 'All Books', 'book-library' ),
'search_items' => __( 'Search Books', 'book-library' ),
'parent_item_colon' => __( 'Parent Book:', 'book-library' ),
'not_found' => __( 'No Books found.', 'book-library' ),
'not_found_in_trash' => __( 'No Books found in Trash.', 'book-library' ),
'archives' => __( 'Book Archives', 'book-library' ),
'attributes' => __( 'Book Attributes', 'book-library' ),
'featured_image' => __( 'Featured Image', 'book-library' ),
'set_featured_image' => __( 'Set featured image', 'book-library' ),
'remove_featured_image' => __( 'Remove featured image', 'book-library' ),
'use_featured_image' => __( 'Use as featured image', 'book-library' ),
'insert_into_item' => __( 'Insert into Book', 'book-library' ),
'uploaded_to_this_item' => __( 'Uploaded to this Book', 'book-library' ),
'filter_items_list' => __( 'Filter Books list', 'book-library' ),
'items_list_navigation' => __( 'Books list navigation', 'book-library' ),
'items_list' => __( 'Books list', 'book-library' ),
'item_published' => __( 'Book published.', 'book-library' ),
'item_updated' => __( 'Book updated.', 'book-library' ),
'item_scheduled' => __( 'Book scheduled.', 'book-library' ),
'item_reverted_to_draft' => __( 'Book reverted to draft.', 'book-library' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_nav_menus' => true,
'show_in_rest' => true,
'hierarchical' => false,
'has_archive' => true,
'exclude_from_search' => false,
'capability_type' => 'post',
'map_meta_cap' => true,
'menu_position' => 20,
'menu_icon' => 'dashicons-book-alt',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'rewrite' => array(
'slug' => 'books',
'with_front' => false,
),
);
register_post_type( 'book', $args );
}
add_action( 'init', 'book_library_register_post_type' );
/**
* Registers the hierarchical genre taxonomy.
*
* Hierarchical means it behaves like categories: terms have parents, the editor
* shows checkboxes, and nobody can invent a term by typing it.
*
* @return void
*/
function book_library_register_category_taxonomy() {
$labels = array(
'name' => _x( 'Genres', 'taxonomy general name', 'book-library' ),
'singular_name' => _x( 'Genre', 'taxonomy singular name', 'book-library' ),
'search_items' => __( 'Search Genres', 'book-library' ),
'all_items' => __( 'All Genres', 'book-library' ),
'parent_item' => __( 'Parent Genre', 'book-library' ),
'parent_item_colon' => __( 'Parent Genre:', 'book-library' ),
'edit_item' => __( 'Edit Genre', 'book-library' ),
'update_item' => __( 'Update Genre', 'book-library' ),
'add_new_item' => __( 'Add New Genre', 'book-library' ),
'new_item_name' => __( 'New Genre Name', 'book-library' ),
'not_found' => __( 'No Genres found.', 'book-library' ),
'back_to_items' => __( 'Back to Genres', 'book-library' ),
'menu_name' => __( 'Genres', 'book-library' ),
);
register_taxonomy(
'genre',
array( 'book' ),
array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'genre' ),
)
);
}
add_action( 'init', 'book_library_register_category_taxonomy' );
/**
* Flushes the rewrite rules once, on the request after activation.
*
* Flushing inside the activation hook itself is the usual advice and it is
* fragile: that request has already passed init, so the rules that get saved are
* only the ones registered by whatever happened to run. Under WP-CLI it saves a
* rule set with nothing in it, and single entries 404 until someone visits
* Settings -> Permalinks. Setting a flag and flushing on the next init means the
* flush happens in a request where every plugin has registered its rules.
*
* @return void
*/
function book_library_maybe_flush_rewrite_rules() {
if ( ! get_option( 'book_library_flush_rewrite_rules' ) ) {
return;
}
delete_option( 'book_library_flush_rewrite_rules' );
flush_rewrite_rules();
}
add_action( 'init', 'book_library_maybe_flush_rewrite_rules', 20 );
/**
* Asks for a flush on the next request.
*
* @return void
*/
function book_library_activate() {
update_option( 'book_library_flush_rewrite_rules', 1 );
}
register_activation_hook( __FILE__, 'book_library_activate' );
/**
* Cleans up the rewrite rules on deactivation.
*
* Nothing else is deleted. The entries stay in the database as posts of a type
* nothing registers: invisible in the admin, and intact if the plugin comes back.
*
* @return void
*/
function book_library_deactivate() {
delete_option( 'book_library_flush_rewrite_rules' );
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'book_library_deactivate' );
Output is valid and updates as you type.
Fix the highlighted fields to update the output.
A snippet that calls register_post_type() belongs in a plugin, not in the theme’s functions.php. Put
it in a theme and the content disappears the day someone switches themes: the posts stay in the
database, and nothing registers the type that would show them.
This writes the plugin. One file, a proper header, every label filled in, optional taxonomies, and an activation hook that does the thing almost every tutorial gets wrong.
How to use
- Name the plugin and set the function prefix. Every function in the file gets that prefix, because PHP has a single global namespace for functions and a collision is a fatal error on somebody else’s site.
- Set the post type key, singular and plural. The key is at most 20 characters and is never changed afterwards: it is stored on every row.
- Choose what it supports, whether it is public, and what the URL base is.
- Add a hierarchical taxonomy, a flat one, both, or neither.
- Download the file, put it in
wp-content/plugins/your-plugin/, and activate it.
Activate rather than paste. The activation hook is where the rewrite rules get flushed.
Example
A Book type with genres, at /books/:
<?php
/**
* Plugin Name: Book Library
* Description: Registers the Book post type, with genres and tags.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Text Domain: book-library
*/
defined( 'ABSPATH' ) || exit;
function book_library_register_post_type() {
$labels = array(
'name' => _x( 'Books', 'post type general name', 'book-library' ),
'singular_name' => _x( 'Book', 'post type singular name', 'book-library' ),
// 28 more, including the ones nobody remembers.
);
$args = array(
'labels' => $labels,
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
'rewrite' => array(
'slug' => 'books',
'with_front' => false,
),
);
register_post_type( 'book', $args );
}
add_action( 'init', 'book_library_register_post_type' );
function book_library_maybe_flush_rewrite_rules() {
if ( ! get_option( 'book_library_flush_rewrite_rules' ) ) {
return;
}
delete_option( 'book_library_flush_rewrite_rules' );
flush_rewrite_rules();
}
add_action( 'init', 'book_library_maybe_flush_rewrite_rules', 20 );
function book_library_activate() {
update_option( 'book_library_flush_rewrite_rules', 1 );
}
register_activation_hook( __FILE__, 'book_library_activate' );
The last two functions are the point of the file. Read on.
Pitfalls
Flushing inside the activation hook is why single entries 404. flush_rewrite_rules() saves whatever
rules are registered at the moment it runs, and by the time an activation hook fires, init has already
passed for that request: the post type is not registered, and the flush writes a rule set without it.
Every entry then 404s until somebody visits Settings → Permalinks, which silently flushes again. The
common advice is to register the post type again inside the activation callback before flushing. That
helps and it is still fragile, because the same request is missing whatever other plugins register on
init as well. This generates the version that sets an option and flushes on the next init at priority
20, when everything has registered.
Flush once, not on every request. flush_rewrite_rules() regenerates every rule on the site and writes
them to the database. The generated code deletes its flag before flushing, so it runs once after
activation and once on deactivation, never on an ordinary page load. A plugin that flushes on every init
is slow in a way that is hard to find.
The post type key is permanent and limited to 20 characters. It is stored in wp_posts.post_type, so
changing it later orphans every existing entry. Keep it singular, lowercase, prefixed if there is any
chance of a collision, and never one of core’s: post, page, attachment, revision,
nav_menu_item, wp_block, wp_template.
A page and an archive cannot share a slug. If a page called Books exists, /books/ resolves to the
page and the archive is unreachable. Pick a different base, or delete the page and let the archive have
it.
with_front is off in the generated code on purpose. With it on and a permalink structure starting
/blog/, the archive becomes /blog/books/, which is almost never what anyone wanted.
Deactivating hides the content, it does not delete it. Posts of an unregistered type stay in the
database and vanish from the admin. That is recoverable and it is also how people conclude a plugin ate
their data. Do not add a register_uninstall_hook that deletes posts unless you genuinely mean it.
Taxonomies go on the same hook as the post type. Both register on init at the default priority, which
is why the deferred flush runs at 20: it has to see them, and it has to see the rules of every other
plugin doing the same thing.
Hierarchical post types need page-attributes in supports to show the parent selector, and
hierarchical taxonomies stop authors inventing terms, which is usually the reason to choose one over a
flat one.
show_in_rest is what gives you the block editor. Off, the type falls back to the classic editor even
on a site running blocks everywhere else. It also controls whether the type appears in the REST API at
all, which matters for anything headless.
Compatibility
Generated in your browser. Nothing is uploaded, and the file is yours.
The output targets WordPress 6.5 and PHP 7.4 and up, both stated in the plugin header so the Plugins
screen can refuse to activate it on something older. Everything it calls has been in core for years:
register_post_type() since 2.9, show_in_rest since 4.7, item_published and its neighbours since
4.7 as well.
Verified on WordPress 7.1.2: the generated plugin registers the type and taxonomy, the archive and a single entry both return 200, and the rewrite rules saved by the deferred flush contain 23 rules for the post type. Flushing inside the activation hook was tried first on the same install and saved a rule set with none of them, which is the bug the flag exists to avoid.
The escaping is done by the template engine per context, not by hand: strings go through single-quoted
PHP escaping, the plugin header through a comment filter that neutralises */ and ?>, and identifiers
are reduced to [A-Za-z0-9_]. The test fixtures include a hostile case with quotes, backslashes,
newlines, */, ?> and ${ in every field, and the suite checks that the output still passes php -l
and produces no new PHP token types compared to the default render.
Full label sets are generated for the post type and each taxonomy, including item_published,
item_scheduled and item_reverted_to_draft, which are the ones hand-written registrations usually
leave as “Post published.”
Frequently asked questions
Plugin or theme?
Why is a class not used?
My archive still 404s after activating.
archive.php at all.Can I add custom fields to this?
register_post_meta() so they appear in REST and the block editor.
The meta box generator writes the classic-editor version of the same thing.How do I move existing posts into the new type?
wp post list --post_type=post --format=ids piped into wp post update --post_type=book does it in one
line. Take a database backup first: the change is easy to make and tedious to undo.