Custom post types are how you tell WordPress that your data isn’t a blog post. Products, events, team members, properties, courses — any content with its own structure and its own admin screen needs a custom post type. The same goes for taxonomies: if you need to categorise that content in a way that doesn’t fit default categories and tags, you register a custom taxonomy.
Both get registered on init with a single function call. The WordPress docs list every possible argument, which makes both functions look intimidating — most of those arguments have sensible defaults and you’ll rarely touch them. This guide focuses on the ones you’ll actually configure.
Here’s a real-world example — a plugin that manages events:
add_action( 'init', 'myplugin_register_event_post_type' );
function myplugin_register_event_post_type(): void {
$labels = [
'name' => 'Events',
'singular_name' => 'Event',
'add_new' => 'Add New',
'add_new_item' => 'Add New Event',
'edit_item' => 'Edit Event',
'new_item' => 'New Event',
'view_item' => 'View Event',
'search_items' => 'Search Events',
'not_found' => 'No events found',
'not_found_in_trash' => 'No events found in trash',
'menu_name' => 'Events',
];
register_post_type( 'event', [
'labels' => $labels,
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'events' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
'menu_icon' => 'dashicons-calendar-alt',
'menu_position'=> 5,
'show_in_rest' => true, // required for Gutenberg support
] );
}
PHPThe post type key — 'event' — must be 20 characters or fewer, lowercase, no spaces. It’s stored in the database, so changing it later means migrating data. Pick it carefully the first time.
| Argument | What it does | Default |
|---|---|---|
public | Makes the CPT visible in the admin and on the front end | false |
has_archive | Enables an archive page at /events/ | false |
rewrite | Controls the URL slug for single posts | post type key |
supports | Which editor fields to show (title, editor, thumbnail, etc.) | ['title','editor'] |
show_in_rest | Exposes the CPT via REST API and enables Gutenberg | false |
menu_icon | Dashicon slug or SVG data URI for the admin menu | generic post icon |
capability_type | Base for permission checks ('post' reuses post caps) | 'post' |
hierarchical | Set true for page-like parent/child structure | false |
One thing people miss: show_in_rest => true isn’t optional anymore. Without it, the Gutenberg editor won’t load for your post type — you’ll get the classic editor instead. If you’re building anything in 2026, always set this to true.
By default, capability_type => 'post' means WordPress reuses the standard post permissions — anyone who can edit posts can edit your CPT. That’s fine for most cases. But if you need separate permissions — so editors can manage events but not delete them, for example — use a custom capability type:
register_post_type( 'event', [
// ...
'capability_type' => 'event',
'map_meta_cap' => true, // let WordPress map meta caps for you
] );
// Then grant the capabilities to roles on activation
register_activation_hook( __FILE__, function() {
$admin = get_role( 'administrator' );
$admin->add_cap( 'edit_events' );
$admin->add_cap( 'publish_events' );
$admin->add_cap( 'delete_events' );
// ... etc
} );
PHPAlways set map_meta_cap => true when using a custom capability_type. Without it, WordPress can’t resolve capabilities like edit_post for your CPT and will silently deny access in unexpected places.
Taxonomies work the same way — one function on init. The third argument links the taxonomy to one or more post types:
add_action( 'init', 'myplugin_register_event_type_taxonomy' );
function myplugin_register_event_type_taxonomy(): void {
$labels = [
'name' => 'Event Types',
'singular_name' => 'Event Type',
'search_items' => 'Search Event Types',
'all_items' => 'All Event Types',
'edit_item' => 'Edit Event Type',
'update_item' => 'Update Event Type',
'add_new_item' => 'Add New Event Type',
'new_item_name' => 'New Event Type Name',
'menu_name' => 'Event Types',
];
register_taxonomy( 'event_type', 'event', [
'labels' => $labels,
'hierarchical' => true, // true = category-like, false = tag-like
'public' => true,
'rewrite' => [ 'slug' => 'event-type' ],
'show_in_rest' => true, // required for Gutenberg support
'show_admin_column' => true, // shows taxonomy column in post list table
] );
}
PHPYou can attach a taxonomy to multiple post types by passing an array as the second argument: [ 'event', 'post' ]. This is useful when you want to share a taxonomy across content types — a “location” taxonomy that applies to both events and venues, for instance.
hierarchical => true gives you a checkbox UI in the editor, parent/child relationships, and behaviour like categories. hierarchical => false gives you a text input with autocomplete, no parent/child, and behaviour like tags. Pick based on how your users will actually add terms — if they’ll be choosing from a fixed list, hierarchical is usually cleaner. If they’ll be typing freeform labels, non-hierarchical is better.
This trips up almost everyone the first time. After registering a custom post type, the archive and single URLs return 404 until you flush the rewrite rules. In development you can do this manually: Settings → Permalinks → Save Changes (without changing anything). In a plugin, flush on activation:
register_activation_hook( __FILE__, function() {
// Register the post type first
myplugin_register_event_post_type();
// Then flush — expensive operation, only do this on activation/deactivation
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, function() {
unregister_post_type( 'event' );
flush_rewrite_rules();
} );
PHPNever call flush_rewrite_rules() on every request or inside init. It’s a database write that rebuilds the entire rewrite rule table — doing it repeatedly will noticeably slow down the site. Activation and deactivation are the only appropriate places.
Once registered, you can query your CPT with WP_Query exactly like built-in post types. For the full argument reference, the WP_Query complete guide covers every available parameter including taxonomy queries:
$events = new WP_Query( [
'post_type' => 'event',
'posts_per_page' => 10,
'post_status' => 'publish',
'tax_query' => [
[
'taxonomy' => 'event_type',
'field' => 'slug',
'terms' => 'conference',
],
],
'meta_key' => 'event_start_date',
'orderby' => 'meta_value',
'order' => 'ASC',
'no_found_rows' => true,
] );
PHPTo include your CPT in the main query on archive pages, use pre_get_posts rather than a secondary query. The pre_get_posts guide covers this pattern — it’s more efficient than creating a secondary WP_Query on the template.
A clean structure for a plugin that registers a CPT and taxonomy together — keeping registration, activation, and deactivation in one place:
php
/**
* Plugin Name: Event Manager
* Description: Registers the Event custom post type and Event Type taxonomy.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) exit;
function myplugin_register_types(): void {
// Post type
register_post_type( 'event', [
'labels' =--> [
'name' => 'Events',
'singular_name' => 'Event',
'add_new_item' => 'Add New Event',
'edit_item' => 'Edit Event',
'not_found' => 'No events found',
],
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'events' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'menu_icon' => 'dashicons-calendar-alt',
'show_in_rest' => true,
] );
// Taxonomy
register_taxonomy( 'event_type', 'event', [
'labels' => [
'name' => 'Event Types',
'singular_name' => 'Event Type',
'add_new_item' => 'Add New Event Type',
],
'hierarchical' => true,
'public' => true,
'rewrite' => [ 'slug' => 'event-type' ],
'show_in_rest' => true,
'show_admin_column' => true,
] );
}
add_action( 'init', 'myplugin_register_types' );
register_activation_hook( __FILE__, function() {
myplugin_register_types();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, function() {
unregister_post_type( 'event' );
unregister_taxonomy( 'event_type' );
flush_rewrite_rules();
} );
PHPFor the WordPress plugin folder structure question — registration code like this belongs in a dedicated includes/class-post-types.php file rather than the main plugin file. The main file should only bootstrap; the actual registration logic lives in a class that gets instantiated on plugins_loaded.
Forgetting show_in_rest => true and wondering why Gutenberg won’t load. Using a post type key longer than 20 characters (WordPress silently truncates it, which causes hard-to-debug queries). Calling flush_rewrite_rules() on every init. Using a post type key that conflicts with a built-in — post, page, attachment, revision, nav_menu_item, custom_css, customize_changeset, oembed_cache, user_request, wp_block, wp_template, and wp_template_part are all reserved.
And the one that costs the most debugging time: registering the taxonomy after the post type but then trying to query it before it’s associated. Always register both inside the same init callback, or at least make sure your taxonomy registration runs before any queries against it.
A custom post type is a new content type — events, products, team members. A custom taxonomy is a classification system for that content — event types, product categories, departments. Post types store the actual content; taxonomies organise it. They work together: you register a taxonomy and attach it to one or more post types.
No. CPT UI is useful for quickly prototyping or for clients who need to manage their own post types, but for anything you’re shipping in a plugin or theme, register via code. UI-registered post types are stored in the database and disappear if the plugin is deactivated. Code-registered ones are always there when your plugin is active.
Rewrite rules haven’t been flushed. Go to Settings → Permalinks and click Save Changes. If you’re doing this programmatically, call flush_rewrite_rules() inside your activation hook after registering the post type.
Yes, with pre_get_posts. Add the CPT to the post_type argument on the main query for the home page or archive. Don’t set 'exclude_from_search' => false and expect it to appear automatically — that only controls search, not the main blog index.
Set 'show_admin_column' => true when registering the taxonomy — this adds a column to the post list table. For the dropdown filter above the list, hook into restrict_manage_posts and render a with your taxonomy terms, then use parse_query to apply the filter. WordPress doesn’t do this automatically for custom taxonomies.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More