DEV Community

Cover image for Add Custom Fields to WooCommerce Checkout
Md. Zubaer Ahammed
Md. Zubaer Ahammed

Posted on Originally published at wptutshub.com

Add Custom Fields to WooCommerce Checkout

To add a custom field to a WooCommerce checkout you need three hooks for the data path and two for display: woocommerce_after_checkout_billing_form prints the input, woocommerce_after_checkout_validation rejects bad values, woocommerce_checkout_update_order_meta saves it, and then woocommerce_admin_order_data_after_billing_address and woocommerce_email_order_meta_fields put it on the order screen and in the emails. The popular version of that recipe does all five and still loses the value on a shop running HPOS, because it writes to post meta and trusts the request.

Below is the snippet as it usually appears, then a refactor in four steps with the reason for each change. You need WooCommerce active and somewhere to put PHP. A small plugin or an mu-plugin beats functions.php here, because the field is order data and should outlive a theme switch.

The snippet most people start with, and what it actually stores

This is close to the first thing you find for the search, and it looks fine on a test order placed by the person who wrote it.

add_action( 'woocommerce_after_checkout_billing_form', function () {
    echo '<p class="form-row"><label for="delivery_note">Delivery note</label>';
    echo '<input type="text" id="delivery_note" name="delivery_note" />';
    echo '</p>';
} );

add_action( 'woocommerce_checkout_update_order_meta', function ( $order_id ) {
    update_post_meta( $order_id, 'delivery_note', $_POST['delivery_note'] );
} );

add_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {
    echo '<p><strong>Delivery note:</strong> ' . get_post_meta( $order->get_id(), 'delivery_note', true ) . '</p>';
} );
Enter fullscreen mode Exit fullscreen mode

Four things are wrong with it, and none of them show up on the first checkout.

update_post_meta() writes to wp_postmeta. Orders lived there for years, so this worked. With HPOS an order is a row in wc_orders and its meta is in wc_orders_meta, and $order->get_meta() never looks at post meta. On an HPOS store WooCommerce keeps a placeholder row in wp_posts with the same ID, so update_post_meta() and get_post_meta() still find each other and the order screen shows the note. Everything that reads the order the supported way does not: $order->get_meta(), the REST API, exports and other plugins all see nothing. The HPOS custom order meta migration post goes further into what to do with orders created before the switch.

The second problem is $_POST['delivery_note'] read without checking it exists. On the block checkout, or when another plugin removes the field, PHP raises "Undefined array key" on every order. With WP_DEBUG_DISPLAY on, that warning is printed into the checkout AJAX response and the spinner never resolves.

Third, nothing is sanitised. A customer can type <script> into a text input, it goes into the database as typed, and it comes back out as typed on the order screen. That is stored XSS aimed at whoever opens the order, usually a shop manager with more capabilities than the customer. Emails are less exposed because WooCommerce runs the value through wp_kses_post(); the admin screen is not.

The snippet above on an HPOS store, after a customer typed a note with a script tag in it (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2):

Order #13, HPOS on: yes
get_post_meta( id, 'delivery_note' ):    'Leave by the door <script>alert(document.cookie)</script>'
$order->get_meta( 'delivery_note' ):     ''
$order->get_meta( '_wpth_delivery_note' ): ''
Order screen prints: <p><strong>Delivery note:</strong> Leave by the door <script>alert(document.cookie)</script></p>
Enter fullscreen mode Exit fullscreen mode

The order screen line is exactly what a shop manager's browser receives: the script tag runs as soon as they open the order.

Fourth, there is no length limit and no validation, so a 4,000 character paste ends up on a picking slip. The hand-written markup also misses the label association and the classes the rest of the form uses, which is why the field looks pasted in.

woocommerce_form_field gives you the wrapper, the label and the styling

add_action( 'woocommerce_after_checkout_billing_form', 'wpth_render_delivery_note_field' );

function wpth_render_delivery_note_field( $checkout ) {
    woocommerce_form_field(
        'wpth_delivery_note',
        array(
            'type'              => 'text',
            'label'             => __( 'Delivery note', 'wpth' ),
            'placeholder'       => __( 'Gate code, safe place, neighbour', 'wpth' ),
            'required'          => false,
            'class'             => array( 'form-row-wide' ),
            'custom_attributes' => array(
                'maxlength'    => '200',
                'autocomplete' => 'off',
            ),
        ),
        $checkout->get_value( 'wpth_delivery_note' )
    );
}
Enter fullscreen mode Exit fullscreen mode

woocommerce_form_field() is the helper WooCommerce uses for its own billing inputs. You pass a key, an argument array and a value, and it builds the form-row wrapper, the label with a matching for, the input-text class and the placeholder. The field then lines up with the rest of the form and inherits whatever styling the theme applies to checkout inputs.

The third argument is the current value. Passing $checkout->get_value( 'wpth_delivery_note' ) lets WooCommerce repopulate the input from the posted data when the checkout is re-rendered after a validation error, so the customer does not retype the gate code.

The classic checkout with the delivery note field at the end of the billing details (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2)

maxlength goes in custom_attributes because that array is rendered straight onto the input element. Treat it as a hint for the browser, not a rule: anyone can remove it in devtools, which is why the next step repeats the limit on the server.

Where to validate: woocommerce_after_checkout_validation, not the render callback

add_action( 'woocommerce_after_checkout_validation', 'wpth_validate_delivery_note', 10, 2 );

function wpth_validate_delivery_note( $data, $errors ) {
    if ( ! isset( $_POST['wpth_delivery_note'] ) ) {
        return;
    }

    $raw  = wp_unslash( $_POST['wpth_delivery_note'] );
    $note = is_string( $raw ) ? sanitize_text_field( $raw ) : '';

    if ( is_string( $raw ) && '' !== trim( $raw ) && '' === $note ) {
        $errors->add(
            'wpth_delivery_note_unreadable',
            __( 'That delivery note could not be read. Please type it again.', 'wpth' )
        );
        return;
    }

    if ( mb_strlen( $note ) > 200 ) {
        $errors->add(
            'wpth_delivery_note_length',
            __( 'Please keep the delivery note under 200 characters.', 'wpth' )
        );
    }

    if ( preg_match( '#https?://#i', $note ) ) {
        $errors->add(
            'wpth_delivery_note_url',
            __( 'Please do not put links in the delivery note.', 'wpth' )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

woocommerce_checkout_process is the hook most snippets use, and it works: add a notice with type error and checkout stops. woocommerce_after_checkout_validation is the better place because it hands you a WP_Error object and runs after WooCommerce has checked its own fields, so you add one message instead of racing the core checks.

The $data argument is not the whole request. WC_Checkout::get_posted_data() builds it from the fields registered through woocommerce_checkout_fields, so a field added with woocommerce_after_checkout_billing_form is not in there. Read $_POST and treat it as hostile.

wp_unslash() comes first because WordPress slashes every superglobal on load; sanitising comes second. The is_string() guard makes the intent explicit. A request with wpth_delivery_note[]=x posts an array; sanitize_text_field() turns an array into an empty string rather than failing, so without the guard an array would quietly become an empty note. With it, you decide.

woocommerce_after_checkout_validation run against four inputs (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2):

a 250-character note   -> Please keep the delivery note under 200 characters.
a note with a link     -> Please do not put links in the delivery note.
an array (note[]=x)    -> accepted
a normal note          -> accepted

sanitize_text_field( array( 'x' ) ) returns: ''
Enter fullscreen mode Exit fullscreen mode

$errors->add() takes a code and a message. The code is what you grep for later, so make it unique to the field rather than reusing invalid_postcode. To see the error path without a real payment, submit the checkout with a 250 character note: the notice appears above the payment box and no order is created.

Saving through the order object is what keeps the value under HPOS

add_action( 'woocommerce_checkout_update_order_meta', 'wpth_save_delivery_note', 10, 2 );

function wpth_save_delivery_note( $order_id, $data ) {
    if ( ! isset( $_POST['wpth_delivery_note'] ) ) {
        return;
    }

    $raw  = wp_unslash( $_POST['wpth_delivery_note'] );
    $note = is_string( $raw ) ? sanitize_text_field( $raw ) : '';

    $order = wc_get_order( $order_id );

    if ( ! $order ) {
        return;
    }

    if ( '' === $note ) {
        $order->delete_meta_data( '_wpth_delivery_note' );
    } else {
        $order->update_meta_data( '_wpth_delivery_note', $note );
    }

    $order->save();
}
Enter fullscreen mode Exit fullscreen mode

wc_get_order() returns the order whichever storage is active, and update_meta_data() followed by save() writes to the right table. That is the whole HPOS fix: no update_post_meta(), no get_post_meta(), no branching on which storage the shop uses.

The _wpth_ prefix avoids collisions with other plugins, and the leading underscore hides the key from the legacy custom fields box, which is what you want for a value the customer should not edit from the order screen.

Deleting on empty matters more than it looks. Without it, a customer who clears the field on a second attempt leaves the old note attached, and the warehouse reads a gate code that no longer applies.

The same script-tag note saved through the order object, and read back (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2):

Order #13, HPOS on: yes
get_post_meta( id, 'delivery_note' ):    ''
$order->get_meta( 'delivery_note' ):     ''
$order->get_meta( '_wpth_delivery_note' ): 'Leave by the door'
Order screen prints: <p class="wpth-delivery-note"><strong>Delivery note</strong><br />Leave by the door</p>
Enter fullscreen mode Exit fullscreen mode

sanitize_text_field() removed the tag on the way in, and the order screen line is plain text.

woocommerce_checkout_create_order is the other place to save. It passes the order object before it is written, so you call $order->update_meta_data() and let WooCommerce do the save(). Either hook works; woocommerce_checkout_update_order_meta passes the ID, which is why older snippets use it.

The order screen and the emails need two separate display hooks

add_action( 'woocommerce_admin_order_data_after_billing_address', 'wpth_show_delivery_note_in_admin' );

function wpth_show_delivery_note_in_admin( $order ) {
    $note = $order->get_meta( '_wpth_delivery_note' );

    if ( '' === $note ) {
        return;
    }

    printf(
        '<p class="wpth-delivery-note"><strong>%s</strong><br />%s</p>',
        esc_html__( 'Delivery note', 'wpth' ),
        esc_html( $note )
    );
}

add_filter( 'woocommerce_email_order_meta_fields', 'wpth_add_delivery_note_to_emails', 10, 3 );

function wpth_add_delivery_note_to_emails( $fields, $sent_to_admin, $order ) {
    $note = $order->get_meta( '_wpth_delivery_note' );

    if ( '' === $note ) {
        return $fields;
    }

    $fields['wpth_delivery_note'] = array(
        'label' => __( 'Delivery note', 'wpth' ),
        'value' => $note,
    );

    return $fields;
}
Enter fullscreen mode Exit fullscreen mode

woocommerce_admin_order_data_after_billing_address fires inside the billing box on both the legacy order screen and the HPOS one, so the note sits where the person packing the parcel is already looking. esc_html() on the way out is the second half of the XSS fix: sanitising on save protects new orders, escaping on output protects the ones already in the database with a script tag in them.

woocommerce_email_order_meta_fields adds a row to the order details table in every WooCommerce email, customer and admin. The $sent_to_admin argument is there if you want the note only in the admin copy. The value is printed through wp_kses_post(), so a stray bracket will not break the email layout, but that is not a substitute for sanitising at save time.

For the customer-facing side, woocommerce_order_details_after_order_table prints the same value on the thank-you page and under My Account. Same get_meta() call, same escaping.

The email field the filter adds (same test site):

Email fields: {"wpth_delivery_note":{"label":"Delivery note","value":"Leave by the door"}}
Enter fullscreen mode Exit fullscreen mode

The HPOS order edit screen with the delivery note under the billing address (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2)

The block checkout never runs woocommerce_after_checkout_billing_form

If the Checkout page contains the Checkout block instead of the [woocommerce_checkout] shortcode, none of the rendering above happens. The block is rendered by React from the Store API, and the classic form hooks do not exist in that tree. New WooCommerce installs create block checkout pages, so this is the default on a fresh shop rather than an edge case.

The supported route is the Additional Checkout Fields API, available since WooCommerce 8.9.

add_action( 'woocommerce_init', 'wpth_register_delivery_note_block_field' );

function wpth_register_delivery_note_block_field() {
    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
        return;
    }

    woocommerce_register_additional_checkout_field(
        array(
            'id'       => 'wpth/delivery-note',
            'label'    => __( 'Delivery note', 'wpth' ),
            'location' => 'order',
            'type'     => 'text',
            'required' => false,
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

The id has to be namespaced, location decides where the field appears (contact, address or order), and type is text, select or checkbox. WooCommerce renders it, validates it and saves it. The value lands in order meta under _wc_other/ followed by the field id, so $order->get_meta( '_wc_other/wpth/delivery-note' ) reads it back and the admin and email callbacks above work once you point them at that key. If the key looks different on your install, dump $order->get_meta_data() once and read it off.

Whether these fields also appear in the shortcode checkout depends on your WooCommerce version, so test both before deleting the classic hooks. Registering the field while the classic callbacks are still active is safe, because the block checkout ignores the classic ones.

The block checkout with the delivery note registered through the Additional Checkout Fields API (test site: WordPress 7.1.3, WooCommerce 11.2.2 with HPOS on, PHP 8.2)

The meta WooCommerce writes when the block checkout saves the field (same test site):

meta key _billing_address_index =           
meta key _shipping_address_index =          
meta key _wc_other/wpth/delivery-note = Leave at the back door
Enter fullscreen mode Exit fullscreen mode

Checking the saved value from the command line

When the field saves but does not display, or displays but does not save, the question is which half is broken. Read the meta straight off the order:

wp eval '
$order = wc_get_order( 123 );
echo $order->get_meta( "_wpth_delivery_note" ), PHP_EOL;
'
Enter fullscreen mode Exit fullscreen mode

An empty result means the save hook did not run, so check which checkout form is actually rendering. A value here with nothing on the order screen means the display callback is attached to something that is not being rendered, usually a legacy meta box on a store that has moved to HPOS.

If customers cannot get past the shipping step at all, that is a different problem: no shipping options available in WooCommerce walks through it.

One follow-up worth the time: put the note in the orders list so nobody opens each order to read it. On HPOS the column filter is woocommerce_shop_order_list_table_columns and the render hook is woocommerce_shop_order_list_table_custom_column; on the legacy list it is manage_edit-shop_order_columns with manage_shop_order_posts_custom_column. Two callbacks, one get_meta() call, and the packing bench stops clicking.


Originally published on WP Tuts Hub.

Top comments (0)