Custom Post Types

When a custom post type beats a category, how public and internal types differ, and why new CPT URLs 404.

Syntaxadd_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

Example · php
<?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.

Example · php
/**
 * 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.

Example · php
// 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.

Example · php
// 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

  • Be the first to comment on this lesson.