Skip to content

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 admin
  • show_in_rest: true -- required for the block editor and REST API
  • supports -- 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

  1. Run docker compose up -d and verify the new CPT appears in WP Admin
  2. Create a test post and confirm ACF fields render in the editor
  3. Verify the REST API endpoint returns data: curl http://localhost:8080/wp-json/tinnitus/v1/guides
  4. Run quality checks:

    composer lint && composer lint:phpcs && composer analyse
    
  5. Run tests:

    composer test:unit && composer test