Add Custom WooCommerce Product Fields with Validation and CRUD Operations

Adding custom fields to WooCommerce products requires hooks at four stages of the product admin lifecycle: rendering the field in the product edit screen, saving the value on post save, reading the value back for display, and exposing it via the WooCommerce REST API. WooCommerce provides panel tab hooks such as woocommerce_product_options_general_product_data for general tab fields and woocommerce_product_options_shipping for shipping tab fields — using a dedicated tab via woocommerce_product_data_tabs is appropriate when adding more than three fields. Field rendering uses WooCommerce’s own helper functions — woocommerce_wp_text_input(), woocommerce_wp_textarea_input(), woocommerce_wp_select(), woocommerce_wp_checkbox() — which output correctly structured HTML including the <label>, the input, and a <span class="description"> tooltip, styled consistently with the rest of the WooCommerce admin UI. Saving hooks on to woocommerce_process_product_meta, which receives the post ID — retrieve the value from $_POST with proper sanitization (sanitize_text_field, absint, or wc_clean), validate against business rules, and persist with update_post_meta() (or $product->update_meta_data() + $product->save() for HPOS compatibility). Displaying the field on the frontend reads from get_post_meta() or $product->get_meta() inside a product loop or single product template. Exposing the field via the WooCommerce REST API is done by registering metadata with register_meta() and setting show_in_rest to true, or by hooking into woocommerce_rest_prepare_product_object to inject the value into the response and woocommerce_rest_save_product_object to read and save it from PUT/PATCH requests. The HPOS migration post explains why $product->update_meta_data() is preferred over update_post_meta() for WooCommerce product meta when the store uses HPOS — products still use post storage in WooCommerce 8.x, but using the CRUD API is future-proof.

Problem: WooCommerce products need a custom text field (e.g., a “lead time” in days) that appears in the product editor, is validated as a positive integer before saving, is shown on the single product page, and is readable via the WooCommerce REST API.

Solution: Render the field with woocommerce_wp_text_input() on the General tab, validate and save with woocommerce_process_product_meta using absint sanitization, display on the frontend with a woocommerce_single_product_summary hook, and expose via REST by injecting into the product response object.

// 1. Render the field on the General tab
add_action('woocommerce_product_options_general_product_data', function() {
    woocommerce_wp_text_input([
        'id'          => '_lead_time_days',
        'label'       => __('Lead Time (days)', 'myplugin'),
        'description' => __('Number of days before the item ships.', 'myplugin'),
        'desc_tip'    => true,
        'type'        => 'number',
        'custom_attributes' => ['min' => '0', 'step' => '1'],
    ]);
});

// 2. Validate and save
add_action('woocommerce_process_product_meta', function(int $post_id) {
    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WC verifies nonce before this hook
    $raw = isset($_POST['_lead_time_days']) ? absint($_POST['_lead_time_days']) : 0;

    // Business rule: lead time must be 0–365
    if ($raw > 365) {
        $raw = 365;
    }

    update_post_meta($post_id, '_lead_time_days', $raw);
});

// 3. Display on the single product page
add_action('woocommerce_single_product_summary', function() {
    global $product;
    $days = absint($product->get_meta('_lead_time_days'));
    if ($days > 0) {
        printf(
            '<p class="lead-time">%s</p>',
            /* translators: %d = number of days */
            esc_html(sprintf(_n('Ships in %d day.', 'Ships in %d days.', $days, 'myplugin'), $days))
        );
    }
}, 25); // after price (priority 10), after excerpt (priority 20)

// 4. Expose via WooCommerce REST API

// Inject into GET /wp-json/wc/v3/products/{id} response
add_filter('woocommerce_rest_prepare_product_object',
    function(WP_REST_Response $response, WC_Product $product): WP_REST_Response {
        $response->data['lead_time_days'] = absint($product->get_meta('_lead_time_days'));
        return $response;
    }, 10, 2
);

// Save from PUT/PATCH /wp-json/wc/v3/products/{id} request
add_action('woocommerce_rest_save_product_object',
    function(WC_Product $product, WP_REST_Request $request): void {
        if ($request->has_param('lead_time_days')) {
            $days = absint($request->get_param('lead_time_days'));
            $days = min($days, 365);
            $product->update_meta_data('_lead_time_days', $days);
            $product->save();
        }
    }, 10, 2
);

NOTE: Never access $_POST directly in woocommerce_process_product_meta without sanitization — WooCommerce verifies the nonce before firing this hook, but sanitization and validation of the value itself is your responsibility. A missing absint() on a numeric field allows arbitrary strings to be stored in wp_postmeta, which can break queries that cast the value to an integer.