how-to · · 9 min read
how to add a custom input field to a woocommerce product page
Add a text field to a WooCommerce product page (engraving, a gift message) that is checked, shown in the cart and saved on the order. Five hooks, no plugin.
By Big Bad Agency
Engraving text. A name on a jersey. A gift message. A date for a cake. Sooner or later a store needs the customer to type something on the product page and needs that text to land on the order where the person packing it will actually see it.
Putting an input box on the page is the easy part. Most tutorials stop there, and that’s the problem: the text then has to survive four more steps before anyone can act on it. It has to be checked, kept with the item in the cart, shown back to the customer, and saved onto the order. Miss one step and you get a box that looks like it works and quietly throws the customer’s text away.
Here’s the whole chain, five hooks, in code you can paste into a small plugin or your child theme’s functions.php.
first, is a field the right tool?
A free-text field is for things only the customer knows. Before you build one, check you’re not using it for something WooCommerce already handles better:
- A fixed set of choices (size, colour, finish) should be variations. They get their own price, stock and SKU, and a typo can’t create a size that doesn’t exist.
- Facts about the product (brand, material, model) should be attributes. They’re filterable, consistent across the catalogue, and nobody types them at checkout. If those attribute values are themselves products in your store, like a brand you also sell as a product, our free Product-Linked Attributes Mapper turns them into links to those product pages.
- Anything the customer types (a name, a message, a measurement) is a field. That’s what this post builds.
the five steps
We’ll build an engraving field, up to 20 characters, shown only on products tagged engravable. Swap the names for your own.
1. show the field
woocommerce_before_add_to_cart_button fires inside the add-to-cart form, which matters: anything outside the form never gets submitted. It fires for simple products and inside the variation form on variable products.
add_action( 'woocommerce_before_add_to_cart_button', function () {
global $product;
if ( ! $product || ! has_term( 'engravable', 'product_tag', $product->get_id() ) ) {
return;
}
?>
<p class="form-row engraving-field">
<label for="engraving_text"><?php esc_html_e( 'Engraving (up to 20 characters)', 'your-textdomain' ); ?></label>
<input type="text" id="engraving_text" name="engraving_text" maxlength="20" />
</p>
<?php
} );
The maxlength is a courtesy to the customer. It’s not a rule anyone has to obey, because a browser can be told to ignore it. The real check is the next step.
2. check it before it goes in the cart
add_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) {
if ( isset( $_POST['engraving_text'] ) ) {
$text = sanitize_text_field( wp_unslash( $_POST['engraving_text'] ) );
if ( mb_strlen( $text ) > 20 ) {
wc_add_notice( __( 'Engraving can be up to 20 characters.', 'your-textdomain' ), 'error' );
return false;
}
}
return $passed;
}, 10, 3 );
Returning false stops the add-to-cart, and the notice tells the customer why. Use mb_strlen, not strlen. An accented letter or an emoji is more than one byte, and strlen would refuse a name that fits.
3. keep it on the cart line
add_filter( 'woocommerce_add_cart_item_data', function ( $cart_item_data, $product_id, $variation_id ) {
if ( ! empty( $_POST['engraving_text'] ) ) {
$cart_item_data['engraving_text'] = sanitize_text_field( wp_unslash( $_POST['engraving_text'] ) );
}
return $cart_item_data;
}, 10, 3 );
This is the step most snippets get half right. Whatever you add here becomes part of the cart line’s identity. WooCommerce builds the line’s ID from the product, the variation and this data, so two of the same mug with different names become two separate lines, not one line with quantity two. That’s what you want. Each one has to be engraved differently.
4. show it back in the cart and at checkout
add_filter( 'woocommerce_get_item_data', function ( $item_data, $cart_item ) {
if ( ! empty( $cart_item['engraving_text'] ) ) {
$item_data[] = array(
'key' => __( 'Engraving', 'your-textdomain' ),
'value' => $cart_item['engraving_text'],
);
}
return $item_data;
}, 10, 2 );
Customers check this. A misspelt name caught in the cart is a two-second fix. Caught after engraving, it’s a remake and an apology.
5. save it on the order
add_action( 'woocommerce_checkout_create_order_line_item', function ( $item, $cart_item_key, $values, $order ) {
if ( ! empty( $values['engraving_text'] ) ) {
$item->add_meta_data( __( 'Engraving', 'your-textdomain' ), $values['engraving_text'], true );
}
}, 10, 4 );
Cart data is temporary. Without this step the text disappears the moment the order is placed. With it, the engraving is stored as meta on that line item. It shows under the product in the admin order screen and in the order emails, which is where the person doing the work will look for it.
In current WooCommerce the block-based Checkout builds its order lines through the same function as the classic checkout, so this hook fires for both. Still, place a test order through whichever checkout your store actually uses before you trust it.
If the person doing the work lives in a spreadsheet, Slack or a job board rather than the WooCommerce admin, the engraving can travel there too. Saved as item meta, it rides along when you send paid orders to Zapier or any webhook.
making it required
If an engravable product without engraving makes no sense, replace step 2 with a version that refuses an empty field:
add_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) {
if ( ! has_term( 'engravable', 'product_tag', $product_id ) ) {
return $passed;
}
$text = isset( $_POST['engraving_text'] ) ? sanitize_text_field( wp_unslash( $_POST['engraving_text'] ) ) : '';
if ( '' === $text ) {
wc_add_notice( __( 'Please enter the engraving text.', 'your-textdomain' ), 'error' );
return false;
}
if ( mb_strlen( $text ) > 20 ) {
wc_add_notice( __( 'Engraving can be up to 20 characters.', 'your-textdomain' ), 'error' );
return false;
}
return $passed;
}, 10, 3 );
Know the side effect. The “Add to cart” buttons on shop and category pages don’t show your field, so for a simple product they submit without it, and this check refuses them. On a classic theme WooCommerce responds by sending the shopper to the product page, where the field is. That’s arguably what you want, but it should be a decision, not a surprise. Check what your theme’s shop page does.
charging extra for it
If engraving costs $5, adjust the price on lines that carry engraving text:
add_action( 'woocommerce_before_calculate_totals', function ( $cart ) {
foreach ( $cart->get_cart() as $cart_item ) {
if ( empty( $cart_item['engraving_text'] ) ) {
continue;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
$cart_item['data']->set_price( (float) $fresh->get_price( 'edit' ) + 5 );
}
} );
Two things in there are deliberate:
- It starts from a freshly loaded price every time. Totals get calculated more than once per page load, and adding $5 to the price the cart is already holding would add it again on every pass.
- The surcharge is in the line price. That’s simple, and the customer sees it in the line total. If you’d rather show “Engraving: $5.00” as its own line, use a cart fee (
woocommerce_cart_calculate_fees) instead. Don’t do both.
Also tell the customer about the charge next to the field. A price that changes in the cart without warning is the kind of surprise that ends a checkout.
letting them pick a font
Engraving fields rarely stay one box for long. The next request is usually a font choice. Use a dropdown here, not more free text, and check the submitted value against your own list. A dropdown can be edited in the browser like any other input, and whatever comes back goes straight to the person doing the engraving.
Keep the choices in one place so every step reads the same list:
function my_engraving_fonts() {
return array(
'script' => __( 'Script', 'your-textdomain' ),
'block' => __( 'Block capitals', 'your-textdomain' ),
'serif' => __( 'Classic serif', 'your-textdomain' ),
);
}
Print the dropdown inside the step 1 callback, just before the closing </p>:
<label for="engraving_font"><?php esc_html_e( 'Font', 'your-textdomain' ); ?></label>
<select id="engraving_font" name="engraving_font">
<?php foreach ( my_engraving_fonts() as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></option>
<?php endforeach; ?>
</select>
Then add one check to step 2, before its final return $passed;, and carry the key through steps 3 to 5 the same way as the text:
// Step 2: refuse anything that is not on the list.
$font = isset( $_POST['engraving_font'] ) ? sanitize_key( wp_unslash( $_POST['engraving_font'] ) ) : '';
if ( '' !== $font && ! array_key_exists( $font, my_engraving_fonts() ) ) {
wc_add_notice( __( 'Please choose one of the listed fonts.', 'your-textdomain' ), 'error' );
return false;
}
// Step 3: inside the add_cart_item_data callback.
if ( ! empty( $_POST['engraving_font'] ) ) {
$cart_item_data['engraving_font'] = sanitize_key( wp_unslash( $_POST['engraving_font'] ) );
}
// Step 4: inside the get_item_data callback.
$fonts = my_engraving_fonts();
if ( ! empty( $cart_item['engraving_font'] ) && isset( $fonts[ $cart_item['engraving_font'] ] ) ) {
$item_data[] = array(
'key' => __( 'Font', 'your-textdomain' ),
'value' => $fonts[ $cart_item['engraving_font'] ],
);
}
// Step 5: inside the create_order_line_item callback. Save the label, not the key.
$fonts = my_engraving_fonts();
if ( ! empty( $values['engraving_font'] ) && isset( $fonts[ $values['engraving_font'] ] ) ) {
$item->add_meta_data( __( 'Font', 'your-textdomain' ), $fonts[ $values['engraving_font'] ], true );
}
Three details matter here:
- The order gets the label, not the key. The person engraving needs to read “Block capitals”, not
block. If you rename a label later, orders that were already placed keep the wording the customer chose. - The font is part of the cart line’s identity, just like the text. The same name in two different fonts makes two separate lines, and that is correct.
- Only offer fonts you can actually engrave. The list is a promise to the customer. Keep it in sync with the fonts the machine or the engraver has, not with what a font picker happens to offer.
If the field is the price, with the customer typing what they want to pay, the same chain of hooks applies, plus a minimum and a tax label. That’s a name your price field in WooCommerce.
before it goes live
- Test it on staging first: simple product, variable product, two lines with different text, a required-field refusal, and a full order through to the confirmation email.
- Look at the order in the admin, not just the thank-you page. The admin order screen is what your team will work from.
- Escape on the way out.
sanitize_text_fieldcleans the input. WooCommerce escapes item meta when it displays it, but if you print the value anywhere yourself (a packing slip, a custom email), escape it there withesc_html. - Mind your text domain. Replace
your-textdomainwith your plugin’s or theme’s, or the labels can’t be translated.
when to reach for a plugin instead
Five hooks is fine for one or two fields. If you need a dozen field types, per-product field builders, conditional logic, file uploads or price rules managed by non-developers, a product add-ons plugin is the better tool. That’s a whole product, and maintaining a homegrown version of one isn’t a good use of anyone’s time.
But if what you need is “let the customer type one thing and make sure it reaches the order”, the code above is the whole job. It’s code you own, with nothing to renew.