Custom Post Types in Code, Not in a Plugin UI

Why a custom post type vanishes on a theme switch, which register_post_type arguments actually decide behavior, and the rewrite flush that has to happen once.

WordPress custom post type registration in plugin code, showing a PHP file on the init hook connected to archive, REST, taxonomy, and content flow, with a plugin UI panel in the background

There is a good reason developers reach for a UI to create a custom post type: register_post_type takes an argument array with dozens of keys, most of the examples online paste all of them, and it is not obvious which ones matter.

The trade is worth understanding, because it is not really about convenience.

A post type registered through a UI lives in that tool’s database rows. It is not in version control, it does not travel with a deployment, it cannot be diffed, and if the tool is removed the registration goes with it. A post type registered in code is a file. It deploys, it reviews, it reverts.

Whichever you choose, one thing is not negotiable: the registration has to run on every request, or the post type does not exist on that request.

The vanishing post type

This is the question that brings most people here. The content is in the database. The admin menu is gone. Every URL 404s.

Almost always, the registration lived in the theme’s functions.php and the theme changed. Nothing deleted the posts. The wp_posts rows are exactly where they were, with their post_type column intact. There is simply nothing running register_post_type any more, so WordPress does not know that type exists, so there is no admin screen for it and no query that will return it.

Put the registration back and the content reappears immediately. That is the recovery, and it is also the argument: registration belongs in a plugin, not in a theme, because a post type is not presentation and should outlive a redesign.

The second cause is registration on the wrong hook. init is the hook. Registering on after_setup_theme is too early for some of what the call does, and registering inside an activation hook does not work at all, because activation runs once and the type must be declared on every request. The stage-by-stage picture behind that is in the WordPress execution order.

The registration, with the arguments that decide things

add_action( 'init', 'acme_register_guide' );

function acme_register_guide() {

    register_post_type( 'acme_guide', array(
        'labels' => array(
            'name'          => __( 'Guides', 'acme' ),
            'singular_name' => __( 'Guide', 'acme' ),
            'add_new_item'  => __( 'Add New Guide', 'acme' ),
            'edit_item'     => __( 'Edit Guide', 'acme' ),
        ),
        'public'        => true,
        'show_in_rest'  => true,
        'has_archive'   => true,
        'rewrite'       => array( 'slug' => 'guides', 'with_front' => false ),
        'menu_icon'     => 'dashicons-book',
        'menu_position' => 20,
        'supports'      => array( 'title', 'editor', 'thumbnail',
                                  'excerpt', 'revisions', 'custom-fields' ),
        'taxonomies'    => array( 'category' ),
    ) );
}

Five of those keys do most of the work.

public is a compound switch. true sets sensible defaults for querying, the admin UI, and front-end visibility all at once. Set it and then override individual pieces (publicly_queryable, show_ui, exclude_from_search) only when you actually need a non-standard combination. An internal type nobody browses is 'public' => false.

show_in_rest decides whether the block editor is available. Not the REST API in the abstract: the editor itself is a REST client, so a type without this key gets the classic editor and no block support. It is the single most frequently missed argument.

has_archive creates /guides/ as a listing page. Without it, individual items resolve and the archive 404s.

supports is an allowlist, and it is easy to under-specify. If thumbnail is missing there is no featured image box. If revisions is missing there is no history. If custom-fields is missing, meta boxes that rely on it do not appear. Omitting the key entirely gives you title and editor only.

rewrite controls the URL. with_front => false stops the type inheriting whatever prefix the site’s permalink structure has, which is usually what you want for a top-level content type.

The slug rules that bite later

Keep the post type key at 20 characters or fewer, lowercase, no spaces. The column is a fixed width and longer keys fail in ways that are not obvious.

Prefix it. acme_guide, not guide. The key shares a namespace with core types, every plugin’s types, and every taxonomy. A collision means one registration silently loses.

The key and the URL slug are separate. The key is acme_guide, the URL is guides via rewrite. Users never see the key, so make it unambiguous rather than pretty.

Renaming the key later orphans your content. The post_type column stores the key. Change it and every existing row points at a type that no longer exists. It is fixable with a database update and it is not something you want to do on a live site. Choose carefully once.

Flushing rewrite rules, exactly once

New rewrite rules are not live until they are flushed. Skip this and every single-item URL 404s while the admin works perfectly, which is a confusing pair of symptoms.

The wrong fix is calling flush_rewrite_rules() on init. It rebuilds the rule set on every request and it is expensive.

The right fix is to flush on activation, after registering:

register_activation_hook( __FILE__, function () {
    acme_register_guide();     // register first
    flush_rewrite_rules();     // then flush
} );

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

Order matters: flushing before the type is registered rebuilds rules that do not include it.

If you are not in a plugin, or you changed the slug after activation, visiting the Permalinks screen in the admin flushes as a side effect. That is a fine one-off, and a bad thing to depend on.

Taxonomies, and the pairing

A custom taxonomy is registered the same way, on the same hook, and must be attached to a type that already exists:

add_action( 'init', function () {
    register_taxonomy( 'acme_topic', 'acme_guide', array(
        'hierarchical' => true,
        'public'       => true,
        'show_in_rest' => true,
        'rewrite'      => array( 'slug' => 'topics' ),
    ) );
}, 11 );

Note the priority 11. Both calls are on init, and the taxonomy needs the post type to be registered first. Same hook, later priority.

Reusing a core taxonomy instead is often better: adding 'taxonomies' => array( 'category' ) to the post type args shares the existing category tree, which is usually what an editor expects and one less thing to maintain.

When it does not work

The three symptoms and their usual causes:

  • No admin menu at all. The registration did not run. Wrong hook, or the file is not being loaded.
  • Admin works, front end 404s. Rewrite rules were never flushed.
  • Editor is the classic one, blocks missing. show_in_rest is not set.

None of the three produce an error message, which is the recurring theme of registration bugs: WordPress accepts the call and the consequence appears somewhere else entirely. When the symptom and the cause are in different files, resist fixing the file the symptom appeared in, for the reasons set out in why the error line is often not where the bug is.

The short version

  • Register on init, in a plugin, on every request.
  • Prefixed key, 20 characters or fewer. Separate URL slug via rewrite.
  • show_in_rest for the block editor. has_archive for a listing page.
  • supports is an allowlist. Name everything you need.
  • Flush rewrite rules on activation, after registering, and not on init.
Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *