Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 05 / 06 35 MIN

Products and pricing

Custom Product Data and the Admin UI

Save a custom product field through the CRUD layer, put it in a product data tab, and make it survive the nonce, the capability check and a variable product.

The first custom product field is a success story in the admin and a bug report the next morning. You add a text input to the General tab, save the product, and the value comes back correctly. Then the shop manager edits the price, saves again, and the field is empty, because the input was only rendered for a product type it no longer matched, so the browser posted nothing and your handler treated the absence as "clear this value".

Adding product data is two problems wearing one hat. The easy half is persistence, and the CRUD layer settles it. The other half is the admin screen, which is made of a tab list, a panel list, a form that submits one $_POST for everything on the page, and a save path that runs after the product has already been written. Getting the second half wrong is what produces fields that appear to save and then disappear.

Product data goes through the object, not through update_post_meta

A product object loads its meta once and keeps it. get_meta() calls get_meta_data(), which calls maybe_read_meta_data(), and that method only touches the data store when its own cache is empty. Call update_post_meta() on a product id while a WC_Product for that id is alive in the request and the object will keep serving the old value. The object and the database are now describing different products.

Worse is the write direction. Every meta key the object knows about is in its meta_data array, and save() writes that array back. A direct update_post_meta() followed by $product->save() in the same request can be undone by the save, so the value does not survive the request that set it. The CRUD surface exists to prevent exactly this:

<?php
$product = wc_get_product( 812 );

// Stage the change on the object. Nothing is written yet.
$product->update_meta_data( '_sifero_redeemable_from', '2026-12-01' );

// Read it back before saving: this returns the staged value.
var_dump( $product->get_meta( '_sifero_redeemable_from' ) );

// Write it. One row in wp_postmeta on a posts-based product store.
$product->save();

The three methods you need are update_meta_data( $key, $value ) to set, get_meta( $key ) to read, and delete_meta_data( $key ) to remove. add_meta_data() and update_meta_data() differ in one respect worth knowing: add_meta_data( $key, $value, true ) deletes existing rows for that key first, and plain add_meta_data() appends a second row for the same key. For a single-value field, use update_meta_data(), which replaces rather than appends.

Keys starting with an underscore are still postmeta

A leading underscore hides a row from the WordPress custom fields box. It does not make the row private, and it does not stop another plugin reading or writing it. Treat it as a naming convention, with the same care you would give any global namespace.

Custom postmeta suits one thing: a small scalar fact about one product

It stops being the right home the moment any of four things becomes true.

If the value is…Postmeta stops working because
queried or sorted across products Postmeta joins are unindexed scans that grow with every plugin's keys. WooCommerce moved the product lookup table and order storage out of postmeta for this reason.
needed in bulk A listing page that reads the value per product pays one meta lookup per row, or a second query if the meta cache was not primed.
really about a variation, a customer or an order One row per product cannot hold a value that varies per variation, and order data belongs on the order.
large or shaped Serialised arrays in meta cannot be indexed or partially updated, so every read deserialises the whole thing.

When a product field crosses that line, the WooCommerce answer is a custom table read and written by your own data store, registered through woocommerce_data_stores. That is more machinery than most extensions need, and the honest position is that a scalar flag per product belongs in postmeta until a measurement says otherwise. What does not work is deciding later: the shape of the data and the places that read it are what you would have to rewrite.

An admin tab is a tab entry and a panel div with matching ids

The product data box builds its tabs from WC_Meta_Box_Product_Data::get_product_data_tabs(), which applies the woocommerce_product_data_tabs filter to an array keyed by tab slug. Each entry carries a label, a target (the id of the panel <div>), a class array, and a priority. The tabs are sorted by priority, so pick a number that puts yours where you want it: 10 is General, 20 Inventory, 70 Advanced.

<?php
add_filter( 'woocommerce_product_data_tabs', function ( $tabs ) {
    $tabs['sifero_gift_card'] = array(
        'label'    => __( 'Gift card', 'sifero-gift-cards' ),
        'target'   => 'sifero_gift_card_data',
        'class'    => array( 'show_if_gift_card' ),
        'priority' => 25,
    );
    return $tabs;
} );

The class array is the only mechanism that controls when your tab is visible, and it works on the product type string. show_if_gift_card reveals the tab when the selector is on gift_card; the core tabs use the same convention with entries like hide_if_grouped and show_if_variable. WooCommerce's own admin script builds its hide and show selectors from the current type, so a class naming a type you never registered never matches, and the tab stays hidden with no error to find.

The panel itself is a <div> whose id equals the tab's target, and it is printed by the woocommerce_product_data_panels action, which fires after all the core panels:

<?php
add_action( 'woocommerce_product_data_panels', function () {
    global $post;

    $product = wc_get_product( $post->ID );
    $value   = $product ? $product->get_meta( '_sifero_redeemable_from' ) : '';
    ?>
    <div id="sifero_gift_card_data" class="panel woocommerce_options_panel">
        <div class="options_group">
            <p class="form-field">
                <label for="_sifero_redeemable_from"><?php esc_html_e( 'Redeemable from', 'sifero-gift-cards' ); ?></label>
                <input type="date" id="_sifero_redeemable_from" name="_sifero_redeemable_from"
                       value="<?php echo esc_attr( $value ); ?>" />
            </p>
        </div>
    </div>
    <?php
} );

Do not print a nonce in the panel. The product data meta box already calls wp_nonce_field( 'woocommerce_save_data', 'woocommerce_meta_nonce' ) once, and a second field with the same name makes the request ambiguous for anything that reads $_POST['woocommerce_meta_nonce'] as a string.

Saving runs after the product is written, which changes what you call

The save path is worth reading in order. WordPress fires save_post; WooCommerce's meta box handler checks the autosave flags, verifies woocommerce_meta_nonce against the woocommerce_save_data action, confirms the submitted post_ID matches the post being saved, requires current_user_can( 'edit_post', $post_id ), and only then fires woocommerce_process_product_meta. Your callback arrives with all of those checks already passed.

What it arrives after is $product->save(). The core handler sets the product's props, fires woocommerce_admin_process_product_object, saves the product, and then fires the action. So a callback on woocommerce_process_product_meta that stages meta on the object must save again:

<?php
add_action( 'woocommerce_process_product_meta', function ( $post_id, $post ) {
    $product = wc_get_product( $post_id );

    if ( ! $product ) {
        return;
    }

    // $post is the second argument the core save passes through.
    // The core handler already verified the nonce and the edit_post capability.
    // Re-checking costs nothing and keeps this handler safe if it is ever called
    // from somewhere else.
    if ( ! isset( $_POST['woocommerce_meta_nonce'] )
        || ! wp_verify_nonce( wp_unslash( $_POST['woocommerce_meta_nonce'] ), 'woocommerce_save_data' ) ) {
        return;
    }

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

    if ( isset( $_POST['_sifero_redeemable_from'] ) ) {
        $value = wc_clean( wp_unslash( $_POST['_sifero_redeemable_from'] ) );
        $product->update_meta_data( '_sifero_redeemable_from', $value );
    }

    $product->save();
}, 10, 2 );

There is a tidier place for this, and it is worth preferring. woocommerce_admin_process_product_object fires before the core save and hands you the product object, so your meta is written by the save that is already happening. The reason to still know the woocommerce_process_product_meta path is that it is the hook most third-party documentation and most existing extension code reaches for, and you will be reading it.

Two details in that callback are load-bearing. wc_clean() is WooCommerce's own sanitiser for scalar input, and it strips tags and normalises whitespace; for a date you may prefer sanitize_text_field(), and for a URL inside a product field, esc_url_raw(). And the isset() guard is the whole reason a field can vanish: if the input is not in $_POST, either the panel was not rendered or the request did not carry it, and an unguarded handler writes an empty string over a good value. Only treat absence as a deletion when the form deliberately posts a marker for it.

A variable product moves the value down to the variation

On a variable product, a value that differs per variation cannot live on the parent. WC_Product_Variation is its own CRUD object with its own postmeta, so the value belongs on the variation and the parent's panel should say so rather than offering a single field that will be wrong for at least one combination.

Rendering per-variation fields uses woocommerce_product_after_variable_attributes, which passes the loop index, the variation data array, and the variation post object. The three arguments are the reason this hook is fiddly the first time: the index is what you use in your input names so the posted arrays line up with the rows, and the third argument is a post, not a WC_Product_Variation.

<?php
add_action( 'woocommerce_product_after_variable_attributes', function ( $loop, $variation_data, $variation ) {
    $value = get_post_meta( $variation->ID, '_sifero_pin_count', true );
    ?>
    <p class="form-row form-row-full">
        <label for="sifero_pin_count_<?php echo esc_attr( $loop ); ?>"><?php esc_html_e( 'PIN count', 'sifero-gift-cards' ); ?></label>
        <input type="number" min="0" step="1"
               id="sifero_pin_count_<?php echo esc_attr( $loop ); ?>"
               name="sifero_pin_count[<?php echo esc_attr( $loop ); ?>]"
               value="<?php echo esc_attr( $value ); ?>" />
    </p>
    <?php
}, 10, 3 );

For the save, use woocommerce_admin_process_variation_object, which fires with the WC_Product_Variation and the loop index just before that variation is saved. Staging meta there means the variation's own save writes it, and you never need a second save call:

<?php
add_action( 'woocommerce_admin_process_variation_object', function ( $variation, $loop ) {
    if ( isset( $_POST['sifero_pin_count'][ $loop ] ) ) {
        $count = absint( wp_unslash( $_POST['sifero_pin_count'][ $loop ] ) );
        $variation->update_meta_data( '_sifero_pin_count', $count );
    }
}, 10, 2 );

Reading it back is then ordinary variation CRUD. Given a cart item, the variation object is $cart_item['data'] when the line is a variation, and its parent is $cart_item['product_id']. When you have only a parent and need a value for a specific combination, $product->get_available_variations() returns arrays with a variation_id you can load with wc_get_product(). The one thing not to do is read the parent's meta and assume it covers the children.

Lab 35 minutes

Take the gift_card type from the previous lesson and give it a _sifero_redeemable_from date field.

  • Put the field in its own product data tab that is visible only for gift_card products.
  • Save it through the CRUD layer, and prove with a query on wp_postmeta that exactly one row exists after saving twice with different values.
  • Make the field work on a variable gift card too, where the date is per variation.
  • Delete the field from a product page by clearing the input, and confirm the row is gone rather than empty.

Then answer: which single change to your save handler would make a mistyped date silently blank the field, and what would you do instead?

Show the solution and the reasoning

The field is one tab entry, one panel, one save callback on the pre-save hook, and a second pair of callbacks for the per-variation copy.

<?php
// 1. The tab.
add_filter( 'woocommerce_product_data_tabs', function ( $tabs ) {
    $tabs['sifero_gift_card'] = array(
        'label'    => __( 'Gift card', 'sifero-gift-cards' ),
        'target'   => 'sifero_gift_card_data',
        'class'    => array( 'show_if_gift_card' ),
        'priority' => 25,
    );
    return $tabs;
} );

// 2. The panel.
add_action( 'woocommerce_product_data_panels', function () {
    global $post;
    $product = wc_get_product( $post->ID );
    $value   = $product ? (string) $product->get_meta( '_sifero_redeemable_from' ) : '';
    ?>
    <div id="sifero_gift_card_data" class="panel woocommerce_options_panel">
        <div class="options_group">
            <p class="form-field">
                <label for="_sifero_redeemable_from"><?php esc_html_e( 'Redeemable from', 'sifero-gift-cards' ); ?></label>
                <input type="date" id="_sifero_redeemable_from" name="_sifero_redeemable_from"
                       value="<?php echo esc_attr( $value ); ?>" />
            </p>
        </div>
    </div>
    <?php
} );

// 3. Save on the parent, before the core save writes the product.
add_action( 'woocommerce_admin_process_product_object', function ( $product ) {
    if ( ! isset( $_POST['_sifero_redeemable_from'] ) ) {
        return; // The panel was not on the page; leave the stored value alone.
    }

    $raw  = wc_clean( wp_unslash( $_POST['_sifero_redeemable_from'] ) );
    $date = '' === $raw ? '' : sanitize_text_field( $raw );

    if ( '' === $date ) {
        $product->delete_meta_data( '_sifero_redeemable_from' );
        return;
    }

    $product->update_meta_data( '_sifero_redeemable_from', $date );
}, 10, 1 );

// 4. Per-variation field and save.
add_action( 'woocommerce_product_after_variable_attributes', function ( $loop, $variation_data, $variation ) {
    $value = get_post_meta( $variation->ID, '_sifero_redeemable_from', true );
    ?>
    <p class="form-row form-row-full">
        <label for="sifero_redeemable_from_<?php echo esc_attr( $loop ); ?>"><?php esc_html_e( 'Redeemable from', 'sifero-gift-cards' ); ?></label>
        <input type="date"
               id="sifero_redeemable_from_<?php echo esc_attr( $loop ); ?>"
               name="sifero_redeemable_from[<?php echo esc_attr( $loop ); ?>]"
               value="<?php echo esc_attr( $value ); ?>" />
    </p>
    <?php
}, 10, 3 );

add_action( 'woocommerce_admin_process_variation_object', function ( $variation, $loop ) {
    if ( ! isset( $_POST['sifero_redeemable_from'][ $loop ] ) ) {
        return;
    }
    $date = wc_clean( wp_unslash( $_POST['sifero_redeemable_from'][ $loop ] ) );
    if ( '' === $date ) {
        $variation->delete_meta_data( '_sifero_redeemable_from' );
    } else {
        $variation->update_meta_data( '_sifero_redeemable_from', $date );
    }
}, 10, 2 );

Why delete_meta_data() and not an empty string. delete_meta_data() sets the staged value to null, and read_meta_data() filters null values out, so the row is removed from storage rather than stored empty. That difference matters the first time you write a query that counts products with the field set: meta_value != '' and "no row" are not the same question, and a table full of empty rows makes the answer wrong.

Why one row survives two saves. update_meta_data() looks for an existing entry with that key and replaces its value. add_meta_data( $key, $value ) without the third argument appends, which is how a product ends up with three rows for one key and why get_meta() returning a single value then looks like it is choosing at random. It is choosing the first, consistently, which is not the same as choosing the right one.

The answer to the last question. The single change that blanks the field is dropping the isset() guard and writing whatever $_POST happens to contain. An unguarded handler turns "this panel was not rendered" and "the browser did not send this input" into "clear the stored value", which is the note in the opening paragraph happening on a real store. The alternative to guarding is a hidden input that always posts the field along with a separate marker for deletion, and that is more moving parts than the guard for a field this simple.

One more decision worth naming. The wrapper choice matters here: the parent's field is saved through wc_get_product() and the object's own save(), and the variation's field is read with get_post_meta() in the render callback. Reading variation meta directly is fine because the variation object does not exist yet in that context, but writing it directly would reintroduce the stale-object problem on the next screen load. Render through whatever is cheapest, write through the object.

Progress

Mark this lesson done and it stays marked in this browser.