Add a Content Type¶
This guide covers how to register a new custom post type (CPT), attach taxonomies and ACF fields, and expose it via the custom REST API.
Architecture overview¶
Content types are defined across three mu-plugins that work together:
| Mu-plugin | Responsibility |
|---|---|
content-types.php |
Registers CPTs and taxonomies |
acf-field-groups.php |
Registers ACF field groups attached to CPTs |
tinnitus-rest-api/ |
Exposes CPTs via /tinnitus/v1/ REST endpoints |
All three are committed to git and always active. The existing content types -- article, research, and landing -- follow the patterns documented below.
Step 1: Register the post type¶
Edit web/app/mu-plugins/content-types.php and add a new register_post_type() call inside tinnitus_register_post_types(). Use the existing $shared_args array to inherit common settings:
function tinnitus_register_post_types(): void
{
$shared_args = [
'public' => true,
'show_in_rest' => true,
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'rewrite' => ['with_front' => false],
];
// ...existing CPTs...
register_post_type('guide', array_merge($shared_args, [
'labels' => array_merge(
tinnitus_cpt_labels('Guide', 'Guides'),
[
'name' => __('Guides', 'tinnitus'),
'singular_name' => __('Guide', 'tinnitus'),
],
),
'menu_icon' => 'dashicons-book-alt',
'has_archive' => true,
]));
}
The $shared_args array provides sensible defaults:
public: true-- visible on the frontend and in adminshow_in_rest: true-- required for the block editor and REST APIsupports-- title, editor, thumbnail, excerpt, and custom-fields (ACF)rewrite-- clean URLs without the/blog/front prefix
The helper tinnitus_cpt_labels() generates standard admin labels (Add New, Edit, Search, etc.) from the singular and plural names.
Translatable labels
Wrap the name and singular_name values in __('...', 'tinnitus') so they are translatable. The helper labels are English-only by design (admin UI), but the primary labels should go through the i18n system.
Step 2: Register a taxonomy (if needed)¶
If the new CPT needs its own taxonomy, add it to tinnitus_register_taxonomies() in the same file. Use the $shared_tax_args array:
function tinnitus_register_taxonomies(): void
{
$shared_tax_args = [
'hierarchical' => true,
'show_in_rest' => true,
'rewrite' => ['with_front' => false],
];
// ...existing taxonomies...
register_taxonomy('guide_topic', ['guide'], array_merge($shared_tax_args, [
'label' => 'Guide Topics',
]));
}
Use underscores in taxonomy slugs
Taxonomy slugs should use underscores, not hyphens. PHP converts hyphens in $_GET keys to underscores, which silently breaks REST API taxonomy filtering. The existing taxonomies (tinnitus_type, tinnitus_stage, treatment_modality) all follow this pattern.
To attach an existing taxonomy to the new CPT, add the CPT slug to the taxonomy's object type array. For example, to share the audience taxonomy:
register_taxonomy('audience', ['article', 'research', 'guide'], array_merge($shared_tax_args, [
'label' => 'Audiences',
]));
Step 3: Add ACF field groups¶
Edit web/app/mu-plugins/acf-field-groups.php to register fields for the new CPT. Call your registration function from tinnitus_register_acf_field_groups():
function tinnitus_register_acf_field_groups(): void
{
tinnitus_register_shared_fields();
tinnitus_register_article_fields();
// ...existing registrations...
tinnitus_register_guide_fields(); // Add here
}
function tinnitus_register_guide_fields(): void
{
acf_add_local_field_group([
'key' => 'group_tinnitus_guide',
'title' => 'Guide Settings',
'fields' => [
[
'key' => 'field_tinnitus_guide_difficulty',
'label' => 'Difficulty Level',
'name' => 'difficulty',
'type' => 'select',
'choices' => [
'beginner' => 'Beginner',
'intermediate' => 'Intermediate',
'advanced' => 'Advanced',
],
'default_value' => 'beginner',
'show_in_rest' => 1,
],
],
'location' => [
[
[
'param' => 'post_type',
'operator' => '==',
'value' => 'guide',
],
],
],
'position' => 'side',
'style' => 'default',
'show_in_rest' => 1,
]);
}
The shared Content Metadata field group (summary, reading time, app_featured, last_reviewed) is attached to both article and research. To include your new CPT, add another location rule to tinnitus_register_shared_fields():
'location' => [
[['param' => 'post_type', 'operator' => '==', 'value' => 'article']],
[['param' => 'post_type', 'operator' => '==', 'value' => 'research']],
[['param' => 'post_type', 'operator' => '==', 'value' => 'guide']], // Add
],
Step 4: Expose via REST API¶
The custom REST API uses an abstract base controller pattern. Creating a new endpoint requires two files.
Create the controller class¶
Add a new file at web/app/mu-plugins/tinnitus-rest-api/src/class-tinnitus-rest-guides-controller.php:
<?php
declare(strict_types=1);
defined('ABSPATH') || exit;
/**
* REST API controller for guides.
*/
class Tinnitus_REST_Guides_Controller extends Tinnitus_REST_Content_Controller
{
protected function get_post_type(): string
{
return 'guide';
}
protected function get_route_base(): string
{
return 'guides';
}
}
The base class Tinnitus_REST_Content_Controller provides list and single endpoints, pagination, taxonomy filtering, language filtering, and ACF field serialization. You only need to implement get_post_type() and get_route_base().
If your CPT needs custom response fields beyond what the base controller provides, override prepare_item_for_response():
public function prepare_item_for_response($post, $request): WP_REST_Response
{
$response = parent::prepare_item_for_response($post, $request);
$data = $response->get_data();
$data['difficulty'] = get_field('difficulty', $post->ID) ?: null;
return new WP_REST_Response($data, 200);
}
Register the routes¶
Edit web/app/mu-plugins/tinnitus-rest-api/tinnitus-rest-api.php to require the new class file and instantiate the controller:
require_once __DIR__ . '/src/class-tinnitus-rest-content-controller.php';
require_once __DIR__ . '/src/class-tinnitus-rest-articles-controller.php';
require_once __DIR__ . '/src/class-tinnitus-rest-research-controller.php';
require_once __DIR__ . '/src/class-tinnitus-rest-guides-controller.php'; // Add
function tinnitus_register_rest_routes(): void
{
(new Tinnitus_REST_Articles_Controller())->register_routes();
(new Tinnitus_REST_Research_Controller())->register_routes();
(new Tinnitus_REST_Guides_Controller())->register_routes(); // Add
}
This registers GET /wp-json/tinnitus/v1/guides (list) and GET /wp-json/tinnitus/v1/guides/{id} (single).
Step 5: Update Polylang sync¶
If the new CPT should be multilingual, add it to the Polylang options in web/app/mu-plugins/polylang-customizations.php:
$desired = [
// ...existing options...
'post_types' => ['article', 'research', 'landing', 'guide'], // Add
];
And if you created a new taxonomy, add it to the taxonomies array in the same function:
'taxonomies' => ['cornerstone', 'tinnitus_type', 'tinnitus_stage', 'treatment_modality', 'audience', 'guide_topic'],
Step 6: Update test bootstrap¶
Add the new CPT to the integration test bootstrap (tests/bootstrap.php) so it is available during tests. Since content types register on init, your CPT is already covered by the existing require_once $mu_dir . '/content-types.php' line -- no changes needed unless you added a new mu-plugin file.
If you added a new REST controller, it is covered by the existing require_once $mu_dir . '/tinnitus-rest-api/tinnitus-rest-api.php' line.
Verification checklist¶
- Run
docker compose up -dand verify the new CPT appears in WP Admin - Create a test post and confirm ACF fields render in the editor
- Verify the REST API endpoint returns data:
curl http://localhost:8080/wp-json/tinnitus/v1/guides -
Run quality checks:
-
Run tests: