DEV Community

Cover image for Custom post type or WooCommerce product for a catalog: fields, taxonomies, queries, and the exit path
AumCreate
AumCreate

Posted on

Custom post type or WooCommerce product for a catalog: fields, taxonomies, queries, and the exit path

A manufacturer sends over a spreadsheet: 1,400 part numbers, six spec columns each, and one line in the email — prices on request only. Two weeks later the same client asks whether the site can take orders directly next year.

That second question decides the architecture. If orders are coming, product is where you want the data to already live. If they are not, WooCommerce charges you in admin weight, template overrides and a plugin surface you did not ask for.

This is a walkthrough of both shapes: how the fields map, how the taxonomies differ, what the query looks like when a filter UI sits on top, and how to move CPT data into products later without re-entering it by hand.

What the product post type actually gives you

WooCommerce registers product as a public post type with product_cat and product_tag taxonomies, a CRUD layer (WC_Product_Simple, WC_Product_Variable), a lookup table wc_product_meta_lookup that keeps price, stock status and SKU sortable without joining wp_postmeta, and a REST namespace at wc/v3.

Concretely, that means SKU uniqueness is enforced for you (wc_get_product_id_by_sku()), layered navigation for attributes works out of the box, and wc_get_products() handles the joins so you do not write them.

The cost side: HPOS order tables, a heavier admin screen, a template layer that takes over your theme's archive, and every third-party plugin that hooks woocommerce_before_shop_loop.

Where each kind of data should live

Data Custom post type WooCommerce
Part number post meta, uniqueness is your problem _sku, uniqueness enforced through the lookup table
Spec values (voltage, tolerance, material) one meta key per spec product attributes, filterable via layered nav
Price leave it out entirely _regular_price, _sale_price
Datasheet PDF meta holding an attachment ID same, or the product Downloads tab
Variants (lengths, colours) child posts, or one post plus a meta table variable product + variations
Category a custom taxonomy product_cat
Stock meta you update by hand _stock_status, _stock

One rule that saves pain either way: keep the spec keys stable and prefixed (catalog_power, not power). Every filter query and every migration reads those keys.

Registering the post type, taxonomy and meta

<?php
/**
 * Plugin Name: Catalog Core
 */

add_action( 'init', function () {
    register_post_type( 'catalog_item', [
        'labels'       => [
            'name'          => 'Catalog',
            'singular_name' => 'Catalog item',
        ],
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => [ 'slug' => 'catalog', 'with_front' => false ],
        'menu_icon'    => 'dashicons-products',
        'supports'     => [ 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ],
        'show_in_rest' => true,
        'rest_base'    => 'catalog-items',
    ] );

    register_taxonomy( 'catalog_family', 'catalog_item', [
        'labels'       => [
            'name'          => 'Families',
            'singular_name' => 'Family',
        ],
        'public'       => true,
        'hierarchical' => true,
        'show_in_rest' => true,
        'rewrite'      => [ 'slug' => 'catalog-family', 'with_front' => false ],
    ] );

    foreach ( [ 'catalog_sku', 'catalog_power', 'catalog_package' ] as $key ) {
        register_post_meta( 'catalog_item', $key, [
            'type'              => 'string',
            'single'            => true,
            'show_in_rest'      => true,
            'sanitize_callback' => 'sanitize_text_field',
            'auth_callback'     => function () {
                return current_user_can( 'edit_posts' );
            },
        ] );
    }
} );
Enter fullscreen mode Exit fullscreen mode

show_in_rest is what lets the block editor read the meta and what makes the fields available at /wp-json/wp/v2/catalog-items if you later build the front end somewhere else.

The edit screen

add_action( 'add_meta_boxes', function () {
    add_meta_box( 'catalog_specs', 'Specs', 'catalog_specs_box', 'catalog_item', 'normal', 'high' );
} );

function catalog_specs_box( WP_Post $post ) {
    wp_nonce_field( 'catalog_specs_save', 'catalog_specs_nonce' );
    $fields = [
        'catalog_sku'     => 'Part number',
        'catalog_power'   => 'Power rating',
        'catalog_package' => 'Package',
    ];
    echo '<table class="form-table">';
    foreach ( $fields as $key => $label ) {
        printf(
            '<tr><th><label for="%1$s">%2$s</label></th><td><input type="text" class="regular-text" id="%1$s" name="%1$s" value="%3$s"></td></tr>',
            esc_attr( $key ),
            esc_html( $label ),
            esc_attr( (string) get_post_meta( $post->ID, $key, true ) )
        );
    }
    echo '</table>';
}

add_action( 'save_post_catalog_item', function ( $post_id ) {
    if ( ! isset( $_POST['catalog_specs_nonce'] ) ) {
        return;
    }
    if ( ! wp_verify_nonce( $_POST['catalog_specs_nonce'], 'catalog_specs_save' ) ) {
        return;
    }
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }
    foreach ( [ 'catalog_sku', 'catalog_power', 'catalog_package' ] as $key ) {
        if ( isset( $_POST[ $key ] ) ) {
            update_post_meta( $post_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) );
        }
    }
} );
Enter fullscreen mode Exit fullscreen mode

The query a filter UI needs

$family = get_query_var( 'catalog_family' );

$args = [
    'post_type'      => 'catalog_item',
    'post_status'    => 'publish',
    'posts_per_page' => 24,
    'paged'          => max( 1, get_query_var( 'paged' ) ),
    'meta_query'     => [
        'relation' => 'AND',
        'power'    => [
            'key'     => 'catalog_power',
            'value'   => '24',
            'compare' => '=',
        ],
    ],
    'orderby'        => [ 'power' => 'ASC', 'title' => 'ASC' ],
];

if ( $family ) {
    $args['tax_query'] = [ [
        'taxonomy' => 'catalog_family',
        'field'    => 'slug',
        'terms'    => $family,
    ] ];
}

$q = new WP_Query( $args );
Enter fullscreen mode Exit fullscreen mode

Named clauses are required if you want to sort by a meta value — orderby with meta_value_num alone only works for a single unnamed clause.

Each meta_query clause is another join on wp_postmeta. At 1,400 rows this is fine and stays fine. At a few hundred thousand rows with three or four active filters, it will not, and no amount of posts_per_page tuning fixes a join on an unindexed value column.

When meta stops scaling

Move the filterable specs into a single index table, one row per item per spec key:

register_activation_hook( __FILE__, function () {
    global $wpdb;
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    $table   = $wpdb->prefix . 'catalog_index';
    $charset = $wpdb->get_charset_collate();
    dbDelta( "CREATE TABLE {$table} (
        item_id BIGINT UNSIGNED NOT NULL,
        spec_key VARCHAR(64) NOT NULL,
        spec_value VARCHAR(191) NOT NULL,
        PRIMARY KEY (item_id, spec_key),
        KEY spec_lookup (spec_key, spec_value)
    ) {$charset};" );
} );
Enter fullscreen mode Exit fullscreen mode

Write it on save_post_catalog_item, delete rows before re-inserting, and query it directly:

$ids = $wpdb->get_col( $wpdb->prepare(
    "SELECT i.item_id FROM {$wpdb->prefix}catalog_index i
     WHERE i.spec_key = %s AND i.spec_value = %s LIMIT 500",
    'catalog_power',
    '24'
) );
Enter fullscreen mode Exit fullscreen mode

Then hand post__in to WP_Query, or skip WP_Query entirely and hydrate with get_posts( [ 'post__in' => $ids, 'orderby' => 'post__in' ] ).

The exit path into WooCommerce

Write this before launch day, not after. A WP-CLI command that copies catalog items into products, idempotent by SKU:

if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'catalog', 'Catalog_Migrate_Command' );
}

class Catalog_Migrate_Command {
    /**
     * Copy catalog items into WooCommerce products.
     *
     * ## OPTIONS
     *
     * [--dry-run]
     */
    public function to_woo( $args, $assoc_args ) {
        if ( ! class_exists( 'WC_Product_Simple' ) ) {
            WP_CLI::error( 'WooCommerce is not active.' );
        }

        $dry = isset( $assoc_args['dry-run'] );

        $ids = get_posts( [
            'post_type'      => 'catalog_item',
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'fields'         => 'ids',
            'no_found_rows'  => true,
        ] );

        foreach ( $ids as $id ) {
            $sku = get_post_meta( $id, 'catalog_sku', true );

            if ( ! $sku ) {
                WP_CLI::warning( "skip {$id}: no part number" );
                continue;
            }

            if ( wc_get_product_id_by_sku( $sku ) ) {
                WP_CLI::warning( "skip {$id}: SKU {$sku} already exists" );
                continue;
            }

            if ( $dry ) {
                WP_CLI::log( "would import {$id} ({$sku})" );
                continue;
            }

            $product = new WC_Product_Simple();
            $product->set_name( get_the_title( $id ) );
            $product->set_status( 'publish' );
            $product->set_catalog_visibility( 'visible' );
            $product->set_sku( $sku );
            $product->set_description( get_post_field( 'post_content', $id ) );
            $product->set_short_description( get_post_field( 'post_excerpt', $id ) );
            $product->update_meta_data( '_quote_only', 'yes' );
            $product->update_meta_data( 'catalog_power', get_post_meta( $id, 'catalog_power', true ) );

            $new_id = $product->save();

            $terms = wp_get_object_terms( $id, 'catalog_family', [ 'fields' => 'names' ] );
            if ( $terms && ! is_wp_error( $terms ) ) {
                wp_set_object_terms( $new_id, $terms, 'product_cat' );
            }

            WP_CLI::log( "imported {$id} -> {$new_id}" );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run wp catalog to_woo --dry-run first, then for real. The _quote_only flag is what keeps prices off a catalog that is not selling yet:

add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {
    if ( 'yes' === $product->get_meta( '_quote_only' ) ) {
        return '';
    }
    return $html;
}, 10, 2 );
Enter fullscreen mode Exit fullscreen mode

Choosing

Take WooCommerce when the client says the words cart, stock, tax or coupon even once, or when variations are real (sizes, lengths, finishes) rather than a spec table. Take the custom post type when the catalog is a reference document with a quote form attached, when the spec set is deep and the filter UI is bespoke, or when the front end is going to be rendered somewhere other than a Woo template.

The middle case — a catalog that might sell later — is why the migration command matters more than the initial choice. If the spec keys are stable and the SKU is the join key, moving later costs an afternoon. If they were stored as free-form post content, it costs a rebuild.

If the catalog also has to be legible to search engines and to AI crawlers, the structured data on those archive and single templates is a separate job from the data model; AumViso handles schema output on either post type without changing what you built above.

Top comments (0)