The project looked simple on paper: a local photography studio needed a portfolio section on their existing WordPress site. Each entry required a custom set of fields — shoot date, camera gear used, location tags, and a project type category. The studio manager had installed a portfolio plugin two years earlier. It worked fine until a WordPress update quietly broke the shortcode renderer, and the plugin author had moved on. Three hundred portfolio items, inaccessible. That afternoon spent rebuilding the section is why this tutorial exists.
Registering a custom post type (CPT) directly in your theme takes roughly 25 lines of PHP. You own the code entirely. There is no third-party dependency to track, no plugin conflict waiting to emerge on the next WordPress release, and no upsell wall when you need a feature the free version doesn't cover. Here is exactly how to do it.
What Are Custom Post Types?
WordPress ships with several built-in content types: posts, pages, attachments, revisions, and navigation menus. Under the hood, all of these live in the same database table — wp_posts — distinguished by a post_type column. A custom post type follows the exact same pattern. It is a new value for that column, registered via PHP so WordPress knows how to route, display, query, and administer it.
Once registered, your CPT gets its own entry in the admin sidebar, its own archive URL, and its own position in the WordPress template hierarchy. You can attach custom taxonomies (think: categories and tags specific to that content type) and store custom fields via post meta to build exactly the data structure the project requires.
Common use cases include portfolios, testimonials, team members, events, product catalogs (before WooCommerce became a dedicated solution), real estate listings, recipes, and documentation pages. The rule of thumb: if your content has fields or URL patterns that do not fit the default post or page model, a CPT is the right architectural choice.
Prerequisites
Before writing any code, confirm you have the following in place:
- A working WordPress installation running version 5.0 or later (6.x recommended)
- Access to your theme's
functions.php— or preferably, a child theme so your changes survive parent theme updates - Basic familiarity with PHP syntax: functions, arrays, and associative arrays
- FTP/SFTP access or access to the WordPress file editor at Appearance → Theme File Editor
- A staging or development environment to test changes before pushing to production
Safety first: A PHP syntax error in functions.php will white-screen your entire site. Always edit theme files in a staging environment before touching production. Keep a recent backup, or at minimum note what you're changing so you can revert via FTP if something breaks.
Step 1: Register the Custom Post Type
Open your child theme's functions.php and add the following block. This example registers a "Portfolio" post type with the internal slug portfolio:
function kt_register_portfolio_cpt() {
$labels = array(
'name' => 'Portfolio',
'singular_name' => 'Portfolio Item',
'add_new' => 'Add New Item',
'add_new_item' => 'Add New Portfolio Item',
'edit_item' => 'Edit Portfolio Item',
'new_item' => 'New Portfolio Item',
'view_item' => 'View Portfolio Item',
'search_items' => 'Search Portfolio',
'not_found' => 'No portfolio items found',
'not_found_in_trash' => 'No items found in trash',
'menu_name' => 'Portfolio',
);
$args = array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'portfolio' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ),
'menu_icon' => 'dashicons-format-gallery',
'show_in_rest' => true,
);
register_post_type( 'portfolio', $args );
}
add_action( 'init', 'kt_register_portfolio_cpt' );
Several of these arguments are worth understanding before you copy and customise them:
has_archive: Set totrueto create an archive page atyoursite.com/portfolio/. Without this, visitors can only reach individual items directly; there is no listing URL.show_in_rest: Required if you want the block editor (Gutenberg) rather than the classic editor for this post type. The block editor communicates with WordPress entirely through the REST API.supports: Controls which meta boxes appear in the editor. Add'page-attributes'if you need ordering (menu order) or parent page selection for hierarchical CPTs.rewrite: Sets the URL slug. Keep it short and meaningful — this becomes the permalink base for every single item and for the archive.
The complete list of available arguments, including capability settings and REST API namespace configuration, is documented in the WordPress developer reference for register_post_type(). It is updated with each WordPress release and is the authoritative source.
Step 2: Register a Custom Taxonomy
A taxonomy lets you group CPT items into categories. For a photography portfolio, "Project Type" — covering portraits, landscapes, and commercial shoots — is an obvious starting point. Add this function beneath your CPT registration in functions.php:
function kt_register_portfolio_taxonomy() {
$labels = array(
'name' => 'Project Types',
'singular_name' => 'Project Type',
'search_items' => 'Search Project Types',
'all_items' => 'All Project Types',
'edit_item' => 'Edit Project Type',
'update_item' => 'Update Project Type',
'add_new_item' => 'Add New Project Type',
'new_item_name' => 'New Project Type Name',
'menu_name' => 'Project Types',
);
register_taxonomy( 'project_type', 'portfolio', array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'project-type' ),
) );
}
add_action( 'init', 'kt_register_portfolio_taxonomy' );
Setting hierarchical to true gives you category-style checkboxes with parent-child nesting in the editor UI. Set it to false for tag-style free-text input. Both use the same underlying taxonomic data structure — the difference is purely in how editors add and manage terms.
Step 3: Create Template Files
WordPress uses a template hierarchy to decide which PHP file renders each URL. For a CPT named portfolio, the lookup order works like this:
- Single item (
yoursite.com/portfolio/project-name/): WordPress checks forsingle-portfolio.php, thensingular.php, thensingle.php, thenindex.php - Archive (
yoursite.com/portfolio/): WordPress checks forarchive-portfolio.php, thenarchive.php, thenindex.php
Create single-portfolio.php in your child theme folder. A minimal working version that outputs the title, featured image, content, and taxonomy terms:
<?php get_header(); ?>
<main class="site-main">
<?php while ( have_posts() ) : the_post(); ?>
<article id="portfolio-<?php the_ID(); ?>" class="portfolio-item">
<h1><?php the_title(); ?></h1>
<?php if ( has_post_thumbnail() ) : ?>
<div class="portfolio-thumbnail">
<?php the_post_thumbnail( 'large' ); ?>
</div>
<?php endif; ?>
<div class="portfolio-content">
<?php the_content(); ?>
</div>
<p class="portfolio-type">
Project Type:
<?php echo get_the_term_list( get_the_ID(), 'project_type', '', ', ' ); ?>
</p>
</article>
<?php endwhile; ?>
</main>
<?php get_footer(); ?>
For the archive, create archive-portfolio.php. Pull items using the standard Loop — WordPress automatically paginates based on the value set in Settings → Reading → Blog pages show at most. Style the output with a CSS grid to create the gallery-style layout most portfolios require.
Step 4: Query Portfolio Items Anywhere on the Site
The built-in archive handles the /portfolio/ URL, but you will often need to display CPT items in other locations: a homepage featured section, a sidebar widget, or a custom shortcode. Use WP_Query for this:
$portfolio_query = new WP_Query( array(
'post_type' => 'portfolio',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
'tax_query' => array(
array(
'taxonomy' => 'project_type',
'field' => 'slug',
'terms' => 'commercial',
),
),
) );
if ( $portfolio_query->have_posts() ) {
while ( $portfolio_query->have_posts() ) {
$portfolio_query->the_post();
the_title( '<h3>', '</h3>' );
the_excerpt();
}
wp_reset_postdata();
}
The wp_reset_postdata() call at the end is non-negotiable. Without it, WordPress loses track of which post is "current" and subsequent template tags in the same page load — the_title(), get_the_ID(), the_permalink() — may silently return incorrect values. This is the single most common WP_Query bug, and the resulting display errors are notoriously hard to trace back to the missing reset call. The WP_Query class reference covers every available query argument if you need to filter by date range, post status, meta value, or ordering priority.
Step 5: Flush Permalink Rules
After saving your CPT registration code, WordPress must regenerate its rewrite rules before the new URLs resolve. Go to Settings → Permalinks and click Save Changes — no need to change any setting, just save. This flushes the internal rewrite cache.
If the archive URL still returns a 404 after flushing, the most likely causes are: has_archive is set to false or missing entirely, the CPT slug in the rewrite array differs from what you expect, or the CPT registration hook is firing too early or too late in the WordPress load sequence.
Common Mistakes to Avoid
Registering on the Wrong Hook
Always register CPTs on the init action, as shown above. Attempting to register on after_setup_theme causes partial functionality: the CPT may appear in the admin but URL rewriting breaks. Using admin_init means the CPT is invisible on the front end entirely. The init hook fires consistently on every request, both admin and front end, at the right point in the WordPress load sequence.
Uppercase or Spaces in the Internal Slug
The first argument to register_post_type() is the internal identifier that WordPress stores in the database. Keep it lowercase, use underscores as separators if needed, and stay under 20 characters. The display name goes in $labels['name']. Changing the internal slug after content has been published requires a database migration to update existing rows in wp_posts — it is not a change to make lightly.
Missing show_in_rest for Block Editor
If your CPT opens in the classic editor when you expected Gutenberg, show_in_rest => true is almost certainly absent. The block editor requires REST API access to load, save, and autosave content. Without this flag, WordPress falls back silently to the classic editor with no error message.
Editing the Parent Theme Directly
Adding registration code directly to a parent theme's functions.php means your CPT disappears the next time the theme auto-updates. Always use a child theme for code that needs to persist. For CPTs that should survive even a full theme change — portfolios and testimonials often outlast three or four theme redesigns — consider placing the registration in a small custom plugin file stored in wp-content/plugins/. Our WordPress theme customization guide covers when each approach makes sense, and our tutorial on custom page templates demonstrates the same child-theme-first workflow applied to page structure.
When to Use a Plugin Instead
Writing registration code directly is the right call for custom-built projects with a developer maintaining the codebase long term. There are legitimate cases where a plugin is the better choice:
- The client or a non-technical team member needs to add or modify CPTs without touching code — reach for Custom Post Type UI
- The project requires an extensive set of custom fields with a visual interface — Advanced Custom Fields or Meta Box handle this better than raw post meta
- The CPT is functionally complex enough to be a mini-application (event ticketing, e-commerce, course management)
- Multiple developers need a shared, visible reference for what CPTs exist and how they're configured
The code approach eliminates overhead and gives you total control. The plugin approach trades some control for a UI and faster iteration. Match the tool to the team's technical capacity and the expected maintenance lifespan of the project.
Need a Clean Foundation?
Our WordPress themes are structured so CPT registration and template overrides slot in without fighting the existing architecture.
Browse Themes