Snippets / WooCommerce

WooCommerce Custom Checkout Field — Add and Validate with PHP

Add a custom field to the WooCommerce checkout form with server-side validation, order meta storage, and admin display — using WordPress filter hooks, not template overrides.

PHP utility Production-tested ~10 min setup Checkout Intermediate WP 6.0+ · WC 7.0+ Updated May 15, 2026
TL;DR

This WooCommerce custom checkout field PHP snippet adds a checkout field with server-side validation, order meta storage, and admin display using hooks.

Use this when WooCommerce's default checkout fields don't capture everything you need — a delivery gate code, a gift message, a VAT number, or a "how did you hear about us?" dropdown. No template hacking, no plugin dependency, just five focused hooks.

01 — Building it step by step

WooCommerce custom checkout field — The Code

This WooCommerce custom checkout field snippet registers, displays, validates, and saves a custom field at checkout in one place.

We’ll add a “Delivery Instructions” textarea to checkout in 5 practical steps. Each step adds exactly one new concept — field registration, validation, saving, display — and every step is a working piece you can stop at and test. No prior WooCommerce hook experience assumed.

Step 1: Add the field. The woocommerce_checkout_fields filter gives you the full checkout fields array — billing, shipping, and order sections. You insert your field into any section by adding a key to the $fields array with an associative config: type, label, required, class for layout width, and priority for ordering. At this point the field renders on the checkout form but the submitted value is discarded — WooCommerce has no idea it exists beyond the HTML.

Step 1 — Add the field.php
php
// WooCommerce custom checkout field — production-tested PHP snippet by Mosharaf Hossain
<?php
/**
 * Step 1 — Add the field. That's it. No validation, no saving.
 */
add_filter( 'woocommerce_checkout_fields', function( $fields ) {
    $fields['billing']['delivery_instructions'] = [
        'type'        => 'textarea',
        'label'       => 'Delivery Instructions',
        'placeholder' => 'Gate code, floor number, parking spot...',
        'required'    => false,
        'class'       => [ 'form-row-wide' ],
        'priority'    => 30,
    ];
    return $fields;
} );

Step 2: Validate. The woocommerce_checkout_process action fires when the customer clicks “Place Order” but before the order is created. If you call wc_add_notice( $message, 'error' ) inside this hook, WooCommerce stops the checkout, reloads the form, and shows your error. This is server-side validation — no JavaScript required. We check that if a value was provided, it meets a minimum length. Empty is fine because the field isn’t required; too-short is blocked.

Step 2 — Validate.php
php
// WooCommerce custom checkout field
<?php
/**
 * Step 2 — Validate. Block checkout if the field fails your rules.
 *
 * New: woocommerce_checkout_process fires before order creation.
 * wc_add_notice( ..., 'error' ) stops the checkout and shows a message.
 */
add_action( 'woocommerce_checkout_process', function() {
    $val = $_POST['delivery_instructions'] ?? '';
    if ( ! empty( $val ) && strlen( trim( $val ) ) < 10 ) {
        wc_add_notice(
            'Delivery Instructions must be at least 10 characters.',
            'error'
        );
    }
} );

Step 3: Save to order meta. By the time woocommerce_checkout_update_order_meta fires, the order post exists in the database with a known $order_id. Your field’s value is in $_POST. The pattern is simple: check $_POST for a non-empty value, run it through a sanitizer (sanitize_textarea_field preserves line breaks while stripping dangerous input), and call update_post_meta(). The underscore prefix on the meta key (_delivery_instructions) keeps it hidden from the Custom Fields metabox — standard WooCommerce convention.

Step 3 — Save to order meta.php
php
// WooCommerce custom checkout field
<?php
/**
 * Step 3 — Save the validated value to order meta.
 *
 * New: woocommerce_checkout_update_order_meta fires after payment,
 * when the order exists in the database.
 */
add_action( 'woocommerce_checkout_update_order_meta', function( $order_id ) {
    if ( ! empty( $_POST['delivery_instructions'] ) ) {
        update_post_meta(
            $order_id,
            '_delivery_instructions',
            sanitize_textarea_field( $_POST['delivery_instructions'] )
        );
    }
} );

Step 4: Display the saved value. Two hooks cover every place the value needs to appear. The admin hook woocommerce_admin_order_data_after_billing_address inserts output right after the billing address box on the Edit Order screen — the store manager sees delivery instructions at a glance. The email hook woocommerce_email_order_meta injects the value into every transactional email below the order table — the customer and the shop owner both receive it. Both hooks pass the WC_Order object, so you read meta with $order->get_id() and get_post_meta().

Step 4 — Display in admin and emails.php
php
// WooCommerce custom checkout field
<?php
/**
 * Step 4 — Display the saved value in the admin and customer emails.
 *
 * New: Two hooks render the field where it matters most:
 * admin view → woocommerce_admin_order_data_after_billing_address
 * emails    → woocommerce_email_order_meta
 */
add_action( 'woocommerce_admin_order_data_after_billing_address',
function( $order ) {
    $val = get_post_meta( $order->get_id(), '_delivery_instructions', true );
    if ( $val ) {
        echo '<p><strong>Delivery Instructions:</strong><br>'
            . esc_html( $val ) . '</p>';
    }
} );

add_action( 'woocommerce_email_order_meta', function( $order ) {
    $val = get_post_meta( $order->get_id(), '_delivery_instructions', true );
    if ( $val ) {
        echo '<p><strong>Delivery Instructions:</strong> '
            . esc_html( $val ) . '</p>';
    }
} );

Step 5: The full production version. All five hooks combined into one file with clear execution-order comments. Notice the small production refinements: wc_clean() in validation (WooCommerce’s own sanitizer, stricter than sanitize_text_field), a maxlength attribute on the field to prevent accidental novels, and a styled <h4> heading in the admin display. Every part follows the same principle: filter to add, action to process, action to save, action to show.

inc/woo-checkout-fields.php
php
// WooCommerce custom checkout field
<?php
/**
 * WooCommerce Custom Checkout Fields — all hooks in one file.
 *
 * Save as inc/woo-checkout-fields.php and require from functions.php.
 *
 * Hook reference (execution order):
 *   1. woocommerce_checkout_fields           — add field to form
 *   2. woocommerce_checkout_process          — validate before order
 *   3. woocommerce_checkout_update_order_meta — save after payment
 *   4. woocommerce_admin_order_data_after_billing_address — admin view
 *   5. woocommerce_email_order_meta          — customer emails
 */

add_filter( 'woocommerce_checkout_fields', function( $fields ) {
    $fields['billing']['delivery_instructions'] = [
        'type'        => 'textarea',
        'label'       => 'Delivery Instructions',
        'placeholder' => 'Gate code, floor number, parking spot...',
        'required'    => false,
        'class'       => [ 'form-row-wide' ],
        'priority'    => 30,
        'maxlength'   => 250,
    ];
    return $fields;
} );

add_action( 'woocommerce_checkout_process', function() {
    $val = wc_clean( $_POST['delivery_instructions'] ?? '' );
    if ( $val && strlen( $val ) < 10 ) {
        wc_add_notice(
            'Delivery Instructions must be at least 10 characters.',
            'error'
        );
    }
} );

add_action( 'woocommerce_checkout_update_order_meta', function( $order_id ) {
    if ( ! empty( $_POST['delivery_instructions'] ) ) {
        update_post_meta(
            $order_id,
            '_delivery_instructions',
            sanitize_textarea_field( $_POST['delivery_instructions'] )
        );
    }
} );

add_action(
    'woocommerce_admin_order_data_after_billing_address',
    function( $order ) {
        $val = get_post_meta( $order->get_id(), '_delivery_instructions', true );
        if ( $val ) {
            echo '<h4>Delivery Instructions</h4>';
            echo '<p>' . esc_html( $val ) . '</p>';
        }
    }
);

add_action( 'woocommerce_email_order_meta', function( $order ) {
    $val = get_post_meta( $order->get_id(), '_delivery_instructions', true );
    if ( $val ) {
        echo '<p><strong>Delivery Instructions:</strong> '
            . esc_html( $val ) . '</p>';
    }
} );

Where to put the code

Save Step 5 in inc/woo-checkout-fields.php and require it from functions.php:

require_once get_template_directory() . '/inc/woo-checkout-fields.php';

There is zero reason to put this in a plugin. These five hooks are theme-level configuration — they describe this site’s checkout form, not a reusable feature. If you ever switch themes, you want these hooks to stop firing.

02 — How it works

How WooCommerce custom checkout field Works — Why Each Layer Exists

Here is how this WooCommerce custom checkout field works step by step: WooCommerce checkout is a pipeline. Every function in this snippet hooks into one stage — form assembly, validation, persistence, and output.

This WooCommerce custom checkout field implementation works as follows.

WooCommerce checkout is a pipeline. Every function in this snippet hooks into one stage of that pipeline — form assembly, submission processing, data persistence, and output rendering. Here’s why each hook was chosen and what problem it solves.

1

Filter: woocommerce_checkout_fields — add the field, no template hacking

The woocommerce_checkout_fields filter is WooCommerce’s single entry point for modifying checkout fields. Before this hook, the only way to add a field was to override the checkout/form-billing.php template — which means copying the entire template into your theme and manually maintaining it through every WooCommerce update. This filter gives you the same $fields array that WooCommerce passes to its own template, so you append a new entry without touching any template file. The array structure — type, label, required, class, priority — mirrors WooCommerce’s own field definitions, meaning your custom field behaves identically to native fields: same CSS classes, same responsive layout, same validation markup.

2

Action: woocommerce_checkout_process — validate before the order exists

woocommerce_checkout_process is the validation gate. It fires after the form is posted but before the order post is created, which is exactly when you want to reject invalid input — no order means no cleanup, no orphaned meta, no payment attempt. wc_add_notice( ..., 'error' ) is WooCommerce’s unified notice system; an error-level notice halts checkout and re-renders the form with the notice displayed above the fields. The user’s existing form values are preserved because WooCommerce re-renders the form from $_POST. Compare this to template-override validation in checkout/form-checkout.php — that approach requires manual re-rendering of every field.

3

Action: woocommerce_checkout_update_order_meta — save after the order is created

woocommerce_checkout_update_order_meta fires immediately after the order object is fully saved. By this point, $order_id is a valid post ID, payment has been processed (or failed), and the order has a status. Because this hook receives only the order ID — not the full WC_Order — you read the submitted value from $_POST directly. This is deliberate: the WC_Order object doesn’t know about your custom field because it’s not a native WooCommerce property. The underscore prefix convention (_delivery_instructions) marks this as “hidden” meta — it won’t appear in the Custom Fields metabox but is still queryable via get_post_meta() and exportable by tools like WooCommerce Customer/Order Export.

4

Actions: admin + email hooks — show the data where it matters

Two display hooks cover the two audiences for order data. woocommerce_admin_order_data_after_billing_address targets the store manager — it inserts content into the Edit Order screen right after the billing address box, which is where most site owners look for customer instructions. woocommerce_email_order_meta targets the customer and any CC recipients — it fires at the bottom of every transactional email (order confirmation, invoice, shipping notice) after the order meta table. Both hooks receive the WC_Order object, so you can read any registered post meta, not just the ones you added. This means you could display the field conditionally — only for shipping orders, only for selected payment gateways — by checking $order->get_shipping_method() or $order->get_payment_method().

5

Production refinements — wc_clean(), maxlength, admin styling

The production version in Step 5 combines all four concepts into one file with execution-order documentation. Three new production details are worth noting: 1) wc_clean() replaces the raw $_POST access from earlier steps — it’s WooCommerce’s own sanitizer that handles multi-dimensional arrays and slashed data, which matters if your server runs with magic_quotes. 2) The maxlength attribute is set on the textarea — not a security control (users can bypass it), but a UX guard that prevents a customer from accident ally pasting a 5,000-character message. 3) The admin display uses a styled <h4> heading rather than inline bold text — it sits alongside WooCommerce’s native “Billing address” heading and looks intentional, not hacked in.

03 — Setup & integration

WooCommerce custom checkout field — Setup & Integration

To integrate this WooCommerce custom checkout field into your project: One file, one require, and your checkout form has a new field. Here's exactly where everything lives and how to verify it.

This WooCommerce custom checkout field implementation works as follows.

One file, one require, and your checkout form has a new field. Here’s exactly where everything lives and how to wire it up.

wp-content/themes/your-theme/
│   ├── inc/
│   │   └── woo-checkout-fields.php
    └── functions.php
1

Create the file and require it

Create the file. Copy the Step 5 code into inc/woo-checkout-fields.php. In functions.php, add this at the bottom (after your theme setup function):

require_once get_template_directory() . '/inc/woo-checkout-fields.php';

No conditionals like is_plugin_active( 'woocommerce' ) are needed — if WooCommerce isn’t active, these hooks simply never fire. WordPress silently ignores hooks whose tagged callbacks don’t have a matching do_action() call.

2

Verify the field appears on checkout

Verify the field appears. Go to your checkout page, add an item to the cart, and proceed to checkout. You should see a “Delivery Instructions” textarea below the billing fields. If you don’t, check three things: a) Is the file being loaded? Add error_log( 'woo-checkout-fields loaded' ) at the top of the file and check your debug log. b) Is WooCommerce active? The hooks won’t error without WooCommerce, they just won’t fire. Check that WC() exists before requiring: if ( function_exists( 'WC' ) ) { require_once ... }. c) Is your theme’s checkout template overridden? If themes/your-theme/woocommerce/checkout/form-billing.php exists and was customized, it might not call woocommerce_form_field() for the full $checkout->checkout_fields array — it might hardcode specific fields.

3

Test the validation logic

Test the validation. Enter fewer than 10 characters in the Delivery Instructions field and click Place Order. The checkout should reload with a red error notice: “Delivery Instructions must be at least 10 characters.” Enter 10 or more characters (or leave it blank) and the order should complete normally. Critical: test with both filled and empty values. A field that blocks checkouts when blank when it’s supposed to be optional is the most common bug.

4

Verify data persistence and display

Verify data persistence and display. After a successful test order, go to WooCommerce → Orders, find your order, and click to edit. Below the billing address, you should see “Delivery Instructions” followed by your test message. Open the order confirmation email that was sent to the customer — the same message should appear below the order table. If it doesn’t appear in both places, check the hook names for typos (they’re long and easy to get wrong). Tip: use echo '<!-- hook fired -->'; as a temporary debugging line inside each callback to verify the hooks are firing — the comment will be visible in the page source and raw email HTML.

04 — Making it your own

Making This WooCommerce custom checkout field Your Own

Customise this WooCommerce custom checkout field to match your specific needs: The five-hook pattern scales to any number of fields, any field type, and conditional logic. Here's how to adapt it.

1

Add multiple fields in one filter callback

Add multiple fields at once. The checkout fields array is associative — add as many keys as you need in the same filter callback:

add_filter( 'woocommerce_checkout_fields', function( $fields ) {
    $fields['billing']['gift_message'] = [
        'type'  => 'textarea',
        'label' => 'Gift Message',
        'class' => [ 'form-row-wide' ],
    ];
    $fields['billing']['hear_about_us'] = [
        'type'    => 'select',
        'label'   => 'How did you hear about us?',
        'options' => [ '' => 'Select...', 'google' => 'Google', 'friend' => 'A friend' ],
        'class'   => [ 'form-row-wide' ],
    ];
    return $fields;
} );

Then validate and save each field in their respective hooks with the same pattern — wc_add_notice() per failing field, update_post_meta() per saved field.

2

Use different field types (select, checkbox, date)

Use different field types. The type key accepts any WooCommerce-compatible field type: text, textarea, select, checkbox, radio, password, tel, email, number, date. For selects and radios, provide an options array. For checkboxes, note that unchecked boxes send no $_POST value — use $_POST['field_name'] ?? '' to handle the missing key. For a file upload, use type => 'file' but be aware the upload handling is significantly more complex — WooCommerce needs additional hooks for file validation (woocommerce_checkout_process will see $_FILES, not $_POST).

3

Show fields conditionally based on cart contents

Conditional visibility — show the field only for certain orders. The first hook (woocommerce_checkout_fields) has full access to the checkout session. You can check cart contents and hide the field when irrelevant:

add_filter( 'woocommerce_checkout_fields', function( $fields ) {
    $has_shipping = false;
    foreach ( WC()->cart->get_cart() as $item ) {
        if ( $item['data']->needs_shipping() ) { $has_shipping = true; break; }
    }
    if ( ! $has_shipping ) {
        unset( $fields['billing']['delivery_instructions'] );
    }
    return $fields;
} );

This is safer than adding a <style> block to hide it — removing the field from the array means the HTML never reaches the browser, and no attacker can submit a hidden field value via DevTools.

3 things that will silently break:

1. Template overrides blocking hooks. If your theme has woocommerce/checkout/form-billing.php, check that it calls woocommerce_form_field() inside a loop over $checkout->checkout_fields['billing']. If it hardcodes specific field names instead, your custom field key will never be reached. Fix: either update the template override to loop over the array, or add your field’s HTML manually in the override.

2. Underscore prefix inconsistency. All four display hooks read from get_post_meta( $order_id, '_delivery_instructions', true ). If you save with delivery_instructions (no underscore) and read with _delivery_instructions (with underscore), the display hooks will silently show nothing. Pick one convention and stick to it — underscore prefix is WooCommerce’s standard for hidden meta.

3. Skipping the wc_clean() sanitizer in production. In Steps 1–3 we used raw $_POST for clarity. But servers with magic_quotes enabled will prepend backslashes to single quotes, and sanitize_textarea_field() doesn’t strip them. wc_clean() calls wp_unslash() first, then a recursive sanitizer — always prefer it for WooCommerce POST data.

Performance notes:

  • All five hooks are WordPress actions and filters — zero database queries until Step 3. The first two hooks only run when the checkout page is loaded (filter) or submitted (action). The third hook runs once per order (one update_post_meta() call). The display hooks only run when an admin views the Edit Order screen or an email is generated. This code adds no measurable load to product pages, the shop archive, or the cart.
  • No additional HTTP requests. Unlike a plugin that enqueues its own CSS and JS, these five hooks operate entirely within the server-side request. The field uses WooCommerce’s existing form styling — the form-row-wide class hooks into the same CSS WooCommerce ships by default.
  • get_post_meta() is WordPress’s fastest meta access. After the first call for a given post ID, the result is cached in WP_Object_Cache for the remainder of the request. Displaying the field in admin and email contexts costs two memory reads, not two database queries. If you add 10 custom fields, all 10 come from the cache after the first update_post_meta() call populates it.
0 comments

No comments yet. Be the first.

Leave a Reply

Table of Contents

WooCommerce custom checkout field — Code Snippet

WooCommerce custom checkout field — code snippet by Mosharaf Hossain

This WooCommerce custom checkout field is a production-tested PHP snippet for WordPress developers. See the WooCommerce checkout fields documentation for related official documentation.

The WooCommerce custom checkout field snippet adds a new input field to the WooCommerce checkout form, validates it server-side before the order is placed, and saves the validated value to order post meta — all without a plugin. It uses the four-hook pattern that WooCommerce designed specifically for this use case: add the field, validate the field, save the field value, and display it in the order confirmation email and admin panel.

The problem with custom checkout field plugins is that they add a layer of configuration UI between you and the output HTML. For a single custom field such as a gift message, a delivery note, or a company VAT number, you get a plugin with 30 settings, a database table, and an ongoing subscription. The WooCommerce hook system handles the same requirement in under 60 lines of PHP that you own completely and can modify without waiting for a plugin update.

The field renders inside woocommerce_after_order_notes, which places it below the Order notes field at the bottom of the checkout form. WooCommerce renders standard billing and shipping fields using woocommerce_form_field — the snippet uses the same function so the new field inherits your theme’s checkout field CSS automatically. Supported field types include text, textarea, select, checkbox, and radio.

Validation runs on woocommerce_checkout_process, which fires after the customer clicks Place Order but before the order is created. If the validation fails, wc_add_notice adds an error message and WooCommerce aborts the order without charging the customer. The snippet demonstrates a required text field check. Extend the validation condition for format checking such as email, phone, or VAT number regex by replacing the empty check with a regex test against the submitted value.

The validated value saves to order post meta via woocommerce_checkout_update_order_meta. It renders in the admin order view via woocommerce_admin_order_data_after_billing_address, making it visible to the team processing the order without a custom admin page.

Tested on WooCommerce 7.0+ and WordPress 6.0+ with PHP 8.0+. Browse all WooCommerce snippets at Code Snippets or see the companion WooCommerce Checkout Notice snippet. This field pattern is production-tested on Vitamines.

Related: Vitamines WooCommerce project — and browse the full code snippet library.

Chat on WhatsApp