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.

Live output

Enable JavaScript to customise; default output below.

What appears on the Plugins screen.

Conventionally the plugin folder name.

Every function in the file starts with this. PHP has one global namespace for functions, so a short prefix is how you avoid a fatal error on someone else's site.

More options Show
Live preview book-post-type.php
<?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.

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

  1. 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.
  2. 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.
  3. Choose what it supports, whether it is public, and what the URL base is.
  4. Add a hierarchical taxonomy, a flat one, both, or neither.
  5. 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?
Plugin, unless the content only makes sense with that one theme. Content outlives themes.
Why is a class not used?
One file registering one post type does not need one, and prefixed functions are what a plugin of this size looks like in the handbook. Add a class when there is state to hold.
My archive still 404s after activating.
Load any page of the site once: the flush happens on the next request, not during activation. If it persists, check for a page with the same slug, then check that the theme has an archive.php at all.
Can I add custom fields to this?
Yes, and register them properly with 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.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.