Custom Post Types
When a custom post type beats a category, how public and internal types differ, and why new CPT URLs 404.
add_action( 'init', fn() => register_post_type( 'acme_review', $args ) );A custom post type is a new value for the post_type column plus a registration describing how WordPress should treat rows carrying it. No new table, no new schema — the same wp_posts row, given its own admin menu, its own URL space and its own capabilities.
When a CPT beats a category
Categorising posts as "Events" gives you one shared editor, one shared archive template and one shared permalink structure. Register event as a post type instead when the content has its own fields, its own editing capabilities, its own archive, or must be excluded from the blog index. If all you need is a way to group otherwise-identical posts, a taxonomy is the lighter answer and stays queryable through tax_query.
Public and internal types
'public' => true is shorthand. It supplies the defaults for four other arguments at once, and its longhand is exactly this:
'public' => true, // which sets, unless you override them: 'publicly_queryable' => true, 'show_ui' => true, 'show_in_nav_menus' => true, 'exclude_from_search' => false,
An internal type leaves public at its default false and turns on only show_ui. Core does this itself: revision, nav_menu_item and wp_block are all post types you never visit by URL.
The rewrite trap
Rewrite rules are generated once and cached in the rewrite_rules option. A newly registered type has no rules there, so every single URL 404s until they are regenerated.
Example
<?php
/**
* Plugin Name: Acme Reviews
*
* A public custom post type, registered correctly, with the rewrite flush
* in the only place it belongs.
*/
function acme_register_review_type() {
register_post_type(
'acme_review', // prefixed, lowercase, max 20 characters
array(
'labels' => array(
'name' => __( 'Reviews', 'acme' ),
'singular_name' => __( 'Review', 'acme' ),
),
'public' => true, // see the longhand in the lesson body
'has_archive' => true, // enables /reviews/
'menu_icon' => 'dashicons-star-filled',
'show_in_rest' => true, // required for the block editor
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
'rewrite' => array( 'slug' => 'reviews' ),
'taxonomies' => array( 'category' ), // reuse an existing taxonomy
)
);
}
add_action( 'init', 'acme_register_review_type' );
/**
* Rewrite rules live in the `rewrite_rules` option. Register the type first,
* so its rules exist, then flush exactly once.
*/
function acme_activate() {
acme_register_review_type();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'acme_activate' );
function acme_deactivate() {
unregister_post_type( 'acme_review' ); // since 4.5.0
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'acme_deactivate' );When to use it
- Modelling products, events or staff profiles as first-class content instead of miscategorised posts
- Storing internal records with an admin UI but no public URLs
- Deciding between a new post type and a new taxonomy for a content section
- Fixing single-item URLs that 404 immediately after a plugin is activated
More examples
An internal post type with no front end
Reach for this shape whenever you need structured records with a free admin UI but no public presence: logs, imported payloads, internal notes. Setting rewrite to false means adding this type can never cause a 404 elsewhere on the site.
/**
* A log of outbound API calls. Editable in the admin by administrators,
* invisible to visitors, with no permalinks and no rewrite rules at all.
*/
add_action( 'init', function () {
register_post_type( 'acme_api_log', array(
'labels' => array( 'name' => __( 'API Log', 'acme' ) ),
'public' => false, // the default; stated for clarity
'show_ui' => true, // but still give it an admin screen
'show_in_menu' => 'tools.php', // nest under Tools instead of top level
'publicly_queryable' => false,
'exclude_from_search' => true,
'has_archive' => false,
'rewrite' => false, // no URL space, nothing to flush
'supports' => array( 'title', 'editor' ),
'capability_type' => 'post',
'map_meta_cap' => true,
) );
} );Choosing a taxonomy over a post type
The decision this lesson turns on. If the only difference between two sets of content is a label, they are one post type with a taxonomy; if they differ in fields, editing permissions or URL structure, they are separate post types.
// WRONG SHAPE: one post type per category of the same content.
// register_post_type( 'news_review' );
// register_post_type( 'news_interview' );
// register_post_type( 'news_feature' );
// Three archives, three templates, three sets of capabilities, and a query
// that has to name all three to list "recent articles".
// RIGHT SHAPE: one type, one taxonomy of formats.
add_action( 'init', function () {
register_post_type( 'acme_article', array(
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail' ),
) );
register_taxonomy( 'acme_format', 'acme_article', array(
'public' => true,
'hierarchical' => false,
'show_in_rest' => true,
'labels' => array( 'name' => __( 'Formats', 'acme' ) ),
) );
} );
// One query covers every format, and tax_query narrows it when needed.
$reviews = new WP_Query( array(
'post_type' => 'acme_article',
'tax_query' => array(
array(
'taxonomy' => 'acme_format',
'field' => 'slug',
'terms' => 'review',
),
),
) );Diagnosing the 404 after registration
The single most reported custom post type problem, and the sequence that finds it. Keep this to hand when a type works in the admin but every front-end URL returns the 404 template.
// Checklist, in the order things usually go wrong.
// 1. Is the type registered at all? Run in a template or via WP-CLI eval.
var_dump( post_type_exists( 'acme_review' ) );
// 2. Was it registered on init? Registering on 'wp' or 'template_redirect'
// is too late for rewrite rules and query vars.
// 3. Are its rules in the cached option? Look for the slug.
$rules = get_option( 'rewrite_rules' );
print_r( preg_grep( '#reviews#', array_keys( (array) $rules ) ) );
// 4. If step 3 is empty, the rules were never regenerated. Flush once:
// - deactivate and reactivate the plugin, or
// - load Settings > Permalinks in the admin, or
// - wp rewrite flush
// 5. Archive missing but single posts fine? has_archive defaults to false.
$type = get_post_type_object( 'acme_review' );
var_dump( $type->has_archive );
var_dump( get_post_type_archive_link( 'acme_review' ) ); // false when has_archive is off
Discussion