WordPressJuly 8, 20269 min read

WordPress Custom Post Types Without a Plugin: Full Developer Guide

How to register CPTs, custom taxonomies, and meta boxes in pure PHP with no bloat, no plugin dependency, and full control over the data your theme reads.

JA

Jacob Almogela

Full Stack Developer

WordPress Custom Post Types Without a Plugin: Full Developer Guide

Most WordPress tutorials send you straight to a plugin like CPT UI or Pods. Plugins are a fine starting point, but they introduce a dependency you do not need and one that can break during updates. Registering Custom Post Types in PHP takes about 20 lines of code per post type, gives you complete control, and ships zero additional overhead. Here is how to do it properly.

What Is a Custom Post Type?

WordPress stores every piece of content as a 'post' in the wp_posts table, differentiated by post_type. Pages, posts, attachments, nav menus, all of them. A Custom Post Type is just a new value for that column, with its own admin menu, labels, and capabilities. Use CPTs whenever your content is structurally different from a standard blog post: portfolio projects, team members, testimonials, events, products, properties.

Registering a CPT with register_post_type()

All registration code belongs in functions.php or a dedicated include file, hooked to init. Never call register_post_type() outside a hook. The taxonomy and rewrite systems are not ready yet and you will get inconsistent behavior.

php
function register_portfolio_cpt() {
    $labels = [
        'name'               => 'Portfolio',
        'singular_name'      => 'Project',
        'menu_name'          => 'Portfolio',
        'add_new_item'       => 'Add New Project',
        'edit_item'          => 'Edit Project',
        'view_item'          => 'View Project',
        'not_found'          => 'No projects found.',
        'not_found_in_trash' => 'No projects found in trash.',
    ];

    $args = [
        'labels'        => $labels,
        'public'        => true,
        'has_archive'   => true,          // /portfolio/ archive page
        'rewrite'       => ['slug' => 'portfolio'],
        'supports'      => ['title', 'editor', 'thumbnail', 'excerpt'],
        'menu_icon'     => 'dashicons-portfolio',
        'show_in_rest'  => true,          // enables Gutenberg + REST API
    ];

    register_post_type('portfolio', $args);
}
add_action('init', 'register_portfolio_cpt');
TIP

Always set show_in_rest to true. Without it, the Block Editor falls back to the Classic Editor and the REST API cannot query this post type, which blocks headless setups and mobile apps.

Adding a Custom Taxonomy

Custom taxonomies work exactly like categories and tags but for your CPT. A portfolio might have a Service Type taxonomy so clients can filter by Web Design, SEO, or Automation. Register it with register_taxonomy() also on the init hook, and link it to your CPT via the object_type parameter.

php
function register_service_taxonomy() {
    $labels = [
        'name'              => 'Service Types',
        'singular_name'     => 'Service Type',
        'search_items'      => 'Search Service Types',
        'all_items'         => 'All Service Types',
        'edit_item'         => 'Edit Service Type',
        'add_new_item'      => 'Add New Service Type',
    ];

    $args = [
        'labels'       => $labels,
        'hierarchical' => true,   // true = categories, false = tags
        'public'       => true,
        'rewrite'      => ['slug' => 'service'],
        'show_in_rest' => true,
    ];

    // Second argument links the taxonomy to the CPT
    register_taxonomy('service_type', 'portfolio', $args);
}
add_action('init', 'register_service_taxonomy');

Storing Extra Fields with Custom Meta Boxes

For structured data that does not fit the content editor, like a project URL, a client name, or a completion date, use post meta. The WordPress way is to register a meta box that renders an input in the Edit screen, then save the value using update_post_meta() on the save_post hook.

php
// 1. Register the meta box
function portfolio_add_meta_box() {
    add_meta_box(
        'portfolio_details',       // ID
        'Project Details',         // Title shown in the editor
        'portfolio_meta_box_html', // Callback to render the HTML
        'portfolio',               // Post type
        'normal',                  // Context: normal, side, advanced
        'high'                     // Priority
    );
}
add_action('add_meta_boxes', 'portfolio_add_meta_box');

// 2. Render the input fields
function portfolio_meta_box_html($post) {
    wp_nonce_field('portfolio_save_meta', 'portfolio_nonce');
    $url = get_post_meta($post->ID, '_project_url', true);
    ?>
    <label for="project_url">Live Project URL</label>
    <input type="url" id="project_url" name="project_url"
           value="<?php echo esc_attr($url); ?>" style="width:100%">
    <?php
}

// 3. Save when the post is saved
function portfolio_save_meta($post_id) {
    // Verify nonce and permissions
    if (
        ! isset($_POST['portfolio_nonce']) ||
        ! wp_verify_nonce($_POST['portfolio_nonce'], 'portfolio_save_meta') ||
        ! current_user_can('edit_post', $post_id)
    ) return;

    if (isset($_POST['project_url'])) {
        update_post_meta(
            $post_id,
            '_project_url',
            esc_url_raw($_POST['project_url'])
        );
    }
}
add_action('save_post_portfolio', 'portfolio_save_meta');
TIP

Prefix all meta keys with an underscore (e.g. _project_url). WordPress treats underscored keys as 'private' and hides them from the Custom Fields panel in the editor, keeping the UI clean.

Querying Your CPT in Templates

WP_Query understands CPTs via the post_type argument. You can filter by taxonomy terms, order by meta values, and paginate exactly like standard posts. The template hierarchy also applies. WordPress automatically looks for archive-portfolio.php and single-portfolio.php, so you can give CPTs a completely custom layout without touching the functions file.

php
// Query the latest 6 portfolio projects tagged 'shopify'
$projects = new WP_Query([
    'post_type'      => 'portfolio',
    'posts_per_page' => 6,
    'tax_query'      => [[
        'taxonomy' => 'service_type',
        'field'    => 'slug',
        'terms'    => 'shopify',
    ]],
    'meta_key'       => '_project_url',
    'orderby'        => 'date',
    'order'          => 'DESC',
]);

while ($projects->have_posts()) {
    $projects->the_post();
    $url = get_post_meta(get_the_ID(), '_project_url', true);
    // Render each project card here
}
wp_reset_postdata(); // Always reset after a custom query

Flushing Rewrite Rules

After registering a CPT with has_archive set to true, the /portfolio/ URL returns a 404 until WordPress regenerates its rewrite rules. Go to Settings, then Permalinks and click Save Changes. You do not need to change anything, just saving triggers the flush. Alternatively, call flush_rewrite_rules() once on plugin or theme activation, but never on every page load since it is an expensive operation.

Why Skip the Plugin?

  • Zero plugin dependency — CPT UI and Pods export PHP anyway, just with extra steps
  • Faster page loads — no plugin bootstrap overhead on every request
  • Full control over labels, capabilities, rewrite slugs, and REST API exposure
  • Works in version control — your CPT definition ships with the theme, not stored in the database
  • No risk of plugin abandonment breaking your content structure in a future WordPress update

Custom Post Types are one of WordPress's most powerful features and one of the most underused by developers who reach for a plugin first. The code is short, readable, and easy to version control. Once you register a few CPTs by hand, you will find yourself reaching for the plugin less and less. Your themes will be lighter for it too.

#WordPress#PHP#Custom Post Types#Theme Development
© 2026 Jacob AlmogelaBuilt with Next.js + Framer Motion