Custom Post Types (CPT) Development in WordPress
Imagine a client asks for a "Portfolio" section with a filter by year, but standard posts don't provide a custom URL or custom fields. We face this regularly. WordPress out of the box works with two content types — Posts and Pages. When a site needs Projects, Vacancies, Real Estate, Testimonials, or Products, a custom post type (CPT) is created for each. CPT provides a separate section in /wp-admin, its own URLs, archive pages, and full control over data structure. Registering a CPT with basic settings takes a few hours; full configuration with pretty URLs, custom columns, and search capabilities takes 1–2 days. Our experience: over 50 configured CPTs for various projects, from portfolios to online stores.
How to register a CPT with correct settings?
Registration starts with the init hook. Inside, call register_post_type with required parameters. Consider an example for the "Projects" type:
add_action('init', function () {
register_post_type('project', [
'labels' => [
'name' => 'Projects',
'singular_name' => 'Project',
'add_new' => 'Add Project',
'add_new_item' => 'New Project',
'edit_item' => 'Edit Project',
'new_item' => 'New Project',
'view_item' => 'View Project',
'search_items' => 'Search Projects',
'not_found' => 'No projects found',
'not_found_in_trash' => 'Trash is empty',
'menu_name' => 'Projects',
],
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'query_var' => true,
'rewrite' => ['slug' => 'projects', 'with_front' => false],
'capability_type' => 'post',
'has_archive' => 'projects',
'hierarchical' => false,
'menu_position' => 5,
'menu_icon' => 'dashicons-portfolio',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'show_in_rest' => true,
'rest_base' => 'projects',
]);
});
has_archive => 'projects' creates an archive page at /projects/ — all posts of this type go there. show_in_rest => true makes the type accessible via REST API and in the Gutenberg editor. After registration, be sure to update permalink rules: in the admin, go to "Settings → Permalinks" and click "Save" or execute flush_rewrite_rules() upon activation.
Custom columns in the post list and sorting
By default, the projects list shows only the title and date. Let's add columns "Client", "Year", and "Featured" — all sortable. Here's a compact code block including column registration, data output, and sorting:
// Register columns and output data
add_filter('manage_project_posts_columns', function (array $columns): array {
$new = [];
foreach ($columns as $key => $title) {
$new[$key] = $title;
if ($key === 'title') {
$new['client'] = 'Client';
$new['year'] = 'Year';
$new['featured'] = 'Featured';
}
}
unset($new['comments']);
return $new;
});
add_action('manage_project_posts_custom_column', function (string $column, int $post_id): void {
switch ($column) {
case 'client':
echo esc_html(get_post_meta($post_id, '_project_client', true) ?: '—');
break;
case 'year':
echo esc_html(get_post_meta($post_id, '_project_year', true) ?: '—');
break;
case 'featured':
$is_featured = get_post_meta($post_id, '_project_featured', true);
echo $is_featured ? '⭐' : '—';
break;
}
}, 10, 2);
add_filter('manage_edit-project_sortable_columns', function (array $columns): array {
$columns['year'] = 'year';
return $columns;
});
add_action('pre_get_posts', function (WP_Query $query): void {
if (!is_admin() || !$query->is_main_query()) return;
if ($query->get('orderby') === 'year') {
$query->set('meta_key', '_project_year');
$query->set('orderby', 'meta_value_num');
}
});
Including CPT in WordPress search and filtering by taxonomy
By default, standard search does not include custom CPTs. Fix this via pre_get_posts:
add_action('pre_get_posts', function (WP_Query $query): void {
if ($query->is_search() && !is_admin() && $query->is_main_query()) {
$post_types = $query->get('post_type') ?: ['post'];
if (!is_array($post_types)) {
$post_types = [$post_types];
}
$query->set('post_type', array_merge($post_types, ['project', 'vacancy']));
}
});
And for filtering the list by taxonomy (e.g., project category) add a dropdown:
add_action('restrict_manage_posts', function (string $post_type): void {
if ($post_type !== 'project') return;
$taxonomy = get_taxonomy('project_category');
wp_dropdown_categories([
'taxonomy' => 'project_category',
'name' => 'project_category',
'show_option_all' => 'All categories',
'selected' => $_GET['project_category'] ?? 0,
'value_field' => 'slug',
'hierarchical' => true,
]);
});
Comparison: CPT vs taxonomy vs custom field
A common mistake is using a CPT where a taxonomy or field would suffice. The table below helps choose the right tool:
| Entity | When to use | Example |
|---|---|---|
| Custom Post Type | Need a separate page, archive, content editor | Project, testimonial, job vacancy |
| Taxonomy | Need grouping or filtering of posts | Project category, service tag |
| Custom field | Store an attribute of an existing post | Product price, project year, office address |
CPT is better than taxonomy for content-rich entities — it provides a separate URL, editor, and full control. Using a taxonomy instead of CPT means losing SEO potential and limiting content.
Which templates are needed for correct CPT display?
WordPress automatically looks for templates in a specific order. For the project type, the priority is:
| Priority | Template | Purpose |
|---|---|---|
| 1 | single-project.php |
Single post |
| 2 | single.php |
Fallback for posts |
| 3 | archive-project.php |
Archive (list) |
| 4 | archive.php |
Fallback for archives |
If the required template is missing, WordPress falls back to index.php. Creating single-project.php and archive-project.php is a baseline practice for any CPT.
Related posts between different CPTs
The standard way to relate two CPTs is to store IDs of related posts in post meta. For example, link cases to a project:
// Save relations
update_post_meta($project_id, '_related_cases', array_map('absint', $case_ids));
// Get related cases
$case_ids = get_post_meta($project_id, '_related_cases', true);
if (!empty($case_ids)) {
$cases = get_posts([
'post_type' => 'case',
'post__in' => $case_ids,
'orderby' => 'post__in',
'posts_per_page' => -1,
]);
}
For bidirectional relationships (many-to-many), use the Posts 2 Posts plugin or create an intermediate table.
What's included in turnkey CPT setup
Our experience: over 5 years of WordPress development, over 50 configured CPTs for portfolios, online stores, and catalogs. The service includes:
- CPT registration with full parameter set (pretty URLs, archive, icon, columns);
- Custom columns with sorting and filtering;
- Integration with search and REST API;
- Creation of single-{cpt}.php and archive-{cpt}.php templates;
- Documentation and access transfer;
- Training for content managers.
We guarantee all CPTs will work correctly with Gutenberg and W3C validator. Contact us to discuss your project — we'll estimate the task within one business day. Get a consultation: we'll help choose the optimal architecture and implement your CPT faster than you can write a spec.
Why trust CPT setup to professionals?
Incorrect CPT registration leads to 404 errors, broken search, and performance drops. We use proven patterns: registration_hook for rule flushing, custom columns without extra queries, proper capability_type. Your site won't lose speed — we guarantee Core Web Vitals in the green zone.
For a deep understanding of the registration mechanism, refer to the official documentation: register_post_type. Our specialists are always ready to consult and find the right solution for your project.







