Subtle diagonal pinstripe pattern in light grey and cream, giving an impression of printed editorial paper

Build a custom post type with custom fields

Creating a custom post type with custom fields without a plugin gives WordPress developers a native, transparent way to model content that does not fit posts or pages. Registering a post type, adding metadata controls, and saving those values are all supported by core APIs, so a site can avoid a third-party CPT builder while keeping full control over capabilities and output.

The implementation is code rather than a no-code setting, but it can remain small and readable. The examples use a qa_document post type with a reference number, Australian city and review date. They run directly in a child theme; a first-party must-use plugin is only an optional way to keep them active across theme changes.

Register the post type

Start by deciding where the code will live and what the post type represents. A stable, prefixed name such as qa_document prevents collisions with themes or other plugins. The following registration enables public archives, editor support, revisions and the REST API. Custom fields support also lets WordPress recognise metadata in its standard data model.

add_action( 'init', 'qa_register_document_type' );

function qa_register_document_type() {
    register_post_type(
        'qa_document',
        array(
            'labels'          => array(
                'name'          => __( 'Documents', 'qa-theme' ),
                'singular_name' => __( 'Document', 'qa-theme' ),
                'add_new_item'  => __( 'Add New Document', 'qa-theme' ),
                'edit_item'     => __( 'Edit Document', 'qa-theme' ),
                'search_items'  => __( 'Search Documents', 'qa-theme' ),
            ),
            'public'          => true,
            'has_archive'     => true,
            'show_in_rest'    => true,
            'rest_base'       => 'qa-documents',
            'menu_icon'       => 'dashicons-media-document',
            'supports'        => array(
                'title',
                'editor',
                'excerpt',
                'thumbnail',
                'revisions',
                'custom-fields',
            ),
            'rewrite'         => array(
                'slug'       => 'documents',
                'with_front' => false,
            ),
            'capability_type' => 'post',
            'map_meta_cap'    => true,
        )
    );
}

Keep the post type name within WordPress’s 20-character limit. After changing rewrite rules, save Settings > Permalinks once or flush them during a controlled deployment; do not call flush_rewrite_rules() on every request. For broader implementation questions, the WordPress Q&A library provides examples of core WordPress patterns.

Add fields to the editing screen

Metadata should be edited through deliberate controls rather than left to raw key-and-value entry. add_meta_box() creates a familiar panel that works in both the classic editor and the block editor, where it appears in the editor’s meta-box area. The panel below captures three fields and includes a nonce generated by WordPress.

add_action( 'add_meta_boxes', 'qa_add_document_meta_box' );

function qa_add_document_meta_box() {
    add_meta_box(
        'qa-document-details',
        __( 'Document details', 'qa-theme' ),
        'qa_render_document_meta_box',
        'qa_document',
        'normal',
        'high'
    );
}

function qa_render_document_meta_box( $post ) {
    wp_nonce_field( 'qa_save_document_meta', 'qa_document_nonce' );

    $reference = get_post_meta( $post->ID, '_qa_reference', true );
    $location  = get_post_meta( $post->ID, '_qa_location', true );
    $reviewed  = get_post_meta( $post->ID, '_qa_reviewed', true );
    $cities    = array( 'Sydney', 'Melbourne', 'Brisbane', 'Perth' );
    ?>
    <p>
        <label for="qa_reference"><strong>Reference number</strong></label><br>
        <input type="text" class="widefat" id="qa_reference"
            name="_qa_reference" maxlength="100"
            value="<?php echo esc_attr( $reference ); ?>">
    </p>
    <p>
        <label for="qa_location"><strong>Location</strong></label><br>
        <select class="widefat" id="qa_location" name="_qa_location">
            <option value="">Choose a location</option>
            <?php foreach ( $cities as $city ) : ?>
                <option value="<?php echo esc_attr( $city ); ?>"
                    <?php selected( $city, $location ); ?>>
                    <?php echo esc_html( $city ); ?>
                </option>
            <?php endforeach; ?>
        </select>
    </p>
    <p>
        <label for="qa_reviewed"><strong>Review date</strong></label><br>
        <input type="date" id="qa_reviewed" name="_qa_reviewed"
            value="<?php echo esc_attr( $reviewed ); ?>">
    </p>
    <?php
}

The selector includes Sydney, Melbourne, Brisbane and Perth because consistent values are easier to filter. Use Australian English labels and plain database values, then translate them for display where needed. The review date uses Y-m-d internally for validation and can be shown as 31/12/2026. Leading underscores mark the keys as protected metadata, keeping them out of the generic custom-fields panel while the dedicated form and registered API retain access.

Validate and save metadata safely

Security belongs in the save path, not in the assumption that only an administrator will reach the form. The callback below rejects autosaves, verifies the nonce, checks the edit_post capability, unslashes incoming data and sanitises each value before it reaches wp_postmeta. A nonce proves that the request came from the form; the capability check confirms that the user may edit this particular record.

add_action( 'save_post_qa_document', 'qa_save_document_meta' );

function qa_save_document_meta( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    if (
        ! isset( $_POST['qa_document_nonce'] ) ||
        ! is_string( $_POST['qa_document_nonce'] )
    ) {
        return;
    }

    $nonce = sanitize_text_field(
        wp_unslash( $_POST['qa_document_nonce'] )
    );

    if ( ! wp_verify_nonce( $nonce, 'qa_save_document_meta' ) ) {
        return;
    }

    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    $reference = '';
    if ( isset( $_POST['_qa_reference'] ) && is_string( $_POST['_qa_reference'] ) ) {
        $reference = sanitize_text_field( wp_unslash( $_POST['_qa_reference'] ) );
    }

    $location = '';
    if ( isset( $_POST['_qa_location'] ) && is_string( $_POST['_qa_location'] ) ) {
        $location = sanitize_text_field( wp_unslash( $_POST['_qa_location'] ) );
    }

    $reviewed = '';
    if ( isset( $_POST['_qa_reviewed'] ) && is_string( $_POST['_qa_reviewed'] ) ) {
        $reviewed = sanitize_text_field( wp_unslash( $_POST['_qa_reviewed'] ) );
        if ( '' !== $reviewed ) {
            $date = DateTimeImmutable::createFromFormat( 'Y-m-d', $reviewed );
            if ( ! $date || $date->format( 'Y-m-d' ) !== $reviewed ) {
                $reviewed = '';
            }
        }
    }

    update_post_meta( $post_id, '_qa_reference', $reference );
    update_post_meta( $post_id, '_qa_location', $location );
    update_post_meta( $post_id, '_qa_reviewed', $reviewed );
}

Do not use update_post_meta() as a substitute for authorisation. The capability should remain specific to the post being saved, and request data should never be written directly to SQL. The date is checked against Y-m-d rather than accepted as an arbitrary string. For repeatable imports, run the same sanitisation functions in the importer instead of bypassing them.

Make fields available through REST

Metadata stored by a meta box is not automatically part of every REST response. Registering each key with register_post_meta() defines a schema and makes it available in edit-context REST responses. With show_in_rest enabled for qa_document, the registration steps keep the API representation aligned with the editing screen.

add_action( 'init', 'qa_register_document_meta_for_rest', 20 );

function qa_register_document_meta_for_rest() {
    $fields = array(
        '_qa_reference' => 'qa_sanitize_document_text',
        '_qa_location'  => 'qa_sanitize_document_text',
        '_qa_reviewed'  => 'qa_sanitize_review_date',
    );

    foreach ( $fields as $meta_key => $sanitize_callback ) {
        register_post_meta(
            'qa_document',
            $meta_key,
            array(
                'single'            => true,
                'show_in_rest'      => array(
                    'schema' => array(
                        'type'    => 'string',
                        'context' => array( 'edit' ),
                    ),
                ),
                'sanitize_callback' => $sanitize_callback,
                'auth_callback'     => 'qa_authorise_document_meta',
            )
        );
    }
}

function qa_sanitize_document_text( $value ) {
    return sanitize_text_field( $value );
}

function qa_sanitize_review_date( $value ) {
    $value = sanitize_text_field( $value );
    if ( '' === $value ) {
        return '';
    }

    $date = DateTimeImmutable::createFromFormat( 'Y-m-d', $value );
    return $date && $date->format( 'Y-m-d' ) === $value ? $value : '';
}

function qa_authorise_document_meta(
    $allowed,
    $meta_key,
    $post_id,
    $user_id,
    $capability,
    $caps
) {
    return current_user_can( 'edit_post', $post_id );
}

The authorisation callback allows writes only by someone who can edit the relevant document. The schema limits protected keys to the edit context, so a public view response does not automatically disclose them. REST requests use WordPress’s own authentication and do not need the form nonce. Add view to a field’s context only when public REST exposure is intentional. A Gutenberg sidebar can be added with JavaScript, but the classic panel remains a code-only path.

Query and render custom data

Once values are stored, use WordPress queries and escaping rather than custom SQL for normal listings. A meta_query can filter documents by location, and get_post_meta() can supply individual values inside a template. Escape every field at output because stored metadata can contain text imported by another system.

$documents = new WP_Query(
    array(
        'post_type'      => 'qa_document',
        'posts_per_page' => 10,
        'orderby'        => 'date',
        'order'          => 'DESC',
        'meta_query'     => array(
            array(
                'key'   => '_qa_location',
                'value' => 'Sydney',
            ),
        ),
    )
);

if ( $documents->have_posts() ) {
    while ( $documents->have_posts() ) {
        $documents->the_post();

        $reference = get_post_meta( get_the_ID(), '_qa_reference', true );
        $reviewed  = get_post_meta( get_the_ID(), '_qa_reviewed', true );
        $date      = $reviewed
            ? DateTimeImmutable::createFromFormat( 'Y-m-d', $reviewed )
            : false;

        echo '<article>';
        echo '<h2><a href="' . esc_url( get_permalink() ) . '">';
        echo esc_html( get_the_title() );
        echo '</a></h2>';

        if ( $reference ) {
            echo '<p>Reference: ' . esc_html( $reference ) . '</p>';
        }

        if ( $date ) {
            echo '<p>Reviewed: ' . esc_html( $date->format( 'd/m/Y' ) ) . '</p>';
        }

        echo '<p>' . esc_html(
            wp_trim_words( get_the_excerpt(), 30, '...' )
        ) . '</p>';
        echo '</article>';
    }
}

wp_reset_postdata();

Render dates for Australian readers as DD/MM/YYYY. Set the site’s timezone deliberately: Sydney and Melbourne observe AEDT or AEST, Brisbane uses AEST and Perth uses AWST. For timestamps, store UTC and format them with date_i18n() or DateTimeImmutable. Cache query results, but invalidate them after document changes so old reference numbers are not shown.

If publication also triggers a notification, keep that transport concern outside the metadata callback. The linked post message example offers a separate implementation reference for adapting a message flow, while field persistence remains focused on the WordPress record.

Maintain, test, and govern the implementation

Deployment decides whether the implementation is a theme feature or a site capability. A child theme keeps the code with its presentation; a small first-party must-use plugin makes it available whenever a theme changes. Neither requires a third-party CPT plugin. Keep the files version controlled, run PHP linting during deployment and flush rewrite rules only when registration or rewrite settings change.

For an Australian project, decide early whether the content belongs in WooCommerce. Products and orders should remain WooCommerce post types, with prices in AUD and GST handled where applicable; a separate CPT is better for guides, reports or branch information. If forms collect names, email addresses or other personal information, review consent, retention and disclosure against the Privacy Act 1988 and the Spam Act 2003 rather than treating metadata storage as automatically compliant.

The production controls that matter most are:

This approach leaves content in standard WordPress tables, with readable PHP that can be reviewed by a small team. It also makes exports, migrations and audits easier because the post type, field definitions and capabilities are visible in the repository rather than hidden in an opaque builder interface.