snowray

How to Add a File Upload Field to WooCommerce Without a Plugin

#woocommerce #development #tutorial #file-upload

You can add a customer file upload field to WooCommerce with nothing but code in your theme. No plugin, no subscription — just WooCommerce hooks and WordPress's own upload handling.

This tutorial builds the complete feature: an upload field on the product page, server-side validation, the file carried through cart and checkout, a link in the order admin, and the security work that most snippets on Stack Overflow skip.

The short version: print the field with woocommerce_before_add_to_cart_button, validate in woocommerce_add_to_cart_validation, move the file with wp_handle_upload() inside woocommerce_add_cart_item_data, and copy it to the order with woocommerce_checkout_create_order_line_item. Full code for each step is below.

Fair warning up front: this is a real feature, not a five-line snippet. Read the limitations section before you commit — for many stores, a dedicated upload plugin is the cheaper path once you price in maintenance.

Table of contents

What we're building

A customer opens a product page, picks a JPG, PNG, or PDF, and adds the product to their cart. The file uploads to your server, travels with the cart item through checkout, and lands in the order as a clickable link you can open from WooCommerce → Orders.

All code goes in your child theme's functions.php or, better, a small custom plugin file so it survives theme changes.

[Screenshot placeholder — ALT: "Product page showing a custom file upload field above the Add to Cart button"]

Step 1: Add the upload field to the product page

WooCommerce fires woocommerce_before_add_to_cart_button inside the add-to-cart form — exactly where we want the field.

add_action( 'woocommerce_before_add_to_cart_button', 'sr_upload_field' );

function sr_upload_field() {
    // Only show on products that need it.
    if ( ! has_term( 'personalized', 'product_cat' ) ) {
        return;
    }
    ?>
    <div class="sr-upload-field" style="margin-bottom:1em;">
        <label for="sr_customer_file">
            <?php esc_html_e( 'Upload your design (JPG, PNG or PDF, max 10 MB)', 'your-textdomain' ); ?>
        </label>
        <input type="file" id="sr_customer_file" name="sr_customer_file"
               accept=".jpg,.jpeg,.png,.pdf" />
    </div>
    <?php
}

The has_term() check limits the field to a personalized product category. Adjust to taste — a product meta flag works too.

Note that accept is a convenience for the file picker, not security. A user can bypass it trivially, which is why every check repeats on the server.

Step 2: Fix the form encoding

Here's the gotcha that breaks most first attempts: the add-to-cart form doesn't support file uploads by default. Its <form> tag lacks enctype="multipart/form-data", so the browser submits your field's filename but silently drops the file itself. $_FILES arrives empty and nothing errors.

WooCommerce has no filter for the form tag, so patch it with a small script:

add_action( 'wp_footer', 'sr_fix_cart_form_enctype' );

function sr_fix_cart_form_enctype() {
    if ( ! is_product() ) {
        return;
    }
    ?>
    <script>
    document.querySelectorAll( 'form.cart' ).forEach( function ( form ) {
        form.setAttribute( 'enctype', 'multipart/form-data' );
    } );
    </script>
    <?php
}

If you ever wonder why your upload "doesn't work" with no error anywhere, check this first. (More silent-failure causes in our troubleshooting guide.)

Step 3: Validate the file

woocommerce_add_to_cart_validation runs before the item enters the cart — reject bad files here and the customer gets a normal WooCommerce error notice.

add_filter( 'woocommerce_add_to_cart_validation', 'sr_validate_upload', 10, 3 );

function sr_validate_upload( $passed, $product_id, $quantity ) {
    // Field not present on this product — nothing to validate.
    if ( ! isset( $_FILES['sr_customer_file'] ) ) {
        return $passed;
    }

    $file = $_FILES['sr_customer_file'];

    // Require the file for this category.
    if ( empty( $file['name'] ) ) {
        wc_add_notice( __( 'Please upload your design file before adding to cart.', 'your-textdomain' ), 'error' );
        return false;
    }

    if ( UPLOAD_ERR_OK !== $file['error'] ) {
        wc_add_notice( __( 'Your file failed to upload. Please try again.', 'your-textdomain' ), 'error' );
        return false;
    }

    // Size cap: 10 MB.
    if ( $file['size'] > 10 * 1024 * 1024 ) {
        wc_add_notice( __( 'File is too large. Maximum size is 10 MB.', 'your-textdomain' ), 'error' );
        return false;
    }

    // Real type check — inspects file contents, not just the extension.
    $check = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
    $allowed = array( 'jpg', 'jpeg', 'png', 'pdf' );

    if ( empty( $check['ext'] ) || ! in_array( $check['ext'], $allowed, true ) ) {
        wc_add_notice( __( 'Only JPG, PNG and PDF files are accepted.', 'your-textdomain' ), 'error' );
        return false;
    }

    return $passed;
}

The important line is wp_check_filetype_and_ext(). It verifies the file's actual content signature, not just its name — a PHP script renamed to photo.jpg fails this check.

Step 4: Handle the upload and attach it to the cart item

Once validation passes, move the file into the uploads directory and store its URL on the cart item:

add_filter( 'woocommerce_add_cart_item_data', 'sr_handle_upload', 10, 2 );

function sr_handle_upload( $cart_item_data, $product_id ) {
    if ( empty( $_FILES['sr_customer_file']['name'] ) ) {
        return $cart_item_data;
    }

    require_once ABSPATH . 'wp-admin/includes/file.php';

    $upload = wp_handle_upload(
        $_FILES['sr_customer_file'],
        array(
            'test_form' => false, // Not a normal WP form submission.
            'mimes'     => array(
                'jpg|jpeg' => 'image/jpeg',
                'png'      => 'image/png',
                'pdf'      => 'application/pdf',
            ),
        )
    );

    if ( isset( $upload['error'] ) ) {
        wc_add_notice( esc_html( $upload['error'] ), 'error' );
        return $cart_item_data;
    }

    $cart_item_data['sr_customer_file'] = array(
        'url'  => $upload['url'],
        'file' => $upload['file'],
        'name' => sanitize_file_name( $_FILES['sr_customer_file']['name'] ),
    );

    // Make identical products with different files separate cart lines.
    $cart_item_data['unique_key'] = md5( $upload['url'] . microtime() );

    return $cart_item_data;
}

wp_handle_upload() does the heavy lifting: it sanitizes the filename, prevents overwrites, respects the mimes whitelist, and files everything under /wp-content/uploads/YYYY/MM/.

The unique_key line matters more than it looks. Without it, two "photo mug" orders with different photos merge into one cart line with one file — and one very unhappy customer.

Step 5: Show the file in the cart and checkout

Give the customer confirmation that their file made it:

add_filter( 'woocommerce_get_item_data', 'sr_show_file_in_cart', 10, 2 );

function sr_show_file_in_cart( $item_data, $cart_item ) {
    if ( ! empty( $cart_item['sr_customer_file']['name'] ) ) {
        $item_data[] = array(
            'key'   => __( 'Your file', 'your-textdomain' ),
            'value' => esc_html( $cart_item['sr_customer_file']['name'] ),
        );
    }
    return $item_data;
}

[Screenshot placeholder — ALT: "Cart page showing the uploaded file name listed under the product"]

Step 6: Save the file to the order

Copy the file reference onto the order line item at checkout:

add_action( 'woocommerce_checkout_create_order_line_item', 'sr_save_file_to_order', 10, 4 );

function sr_save_file_to_order( $item, $cart_item_key, $values, $order ) {
    if ( ! empty( $values['sr_customer_file']['url'] ) ) {
        $item->add_meta_data(
            __( 'Customer file', 'your-textdomain' ),
            esc_url( $values['sr_customer_file']['url'] )
        );
    }
}

Because it's order item meta, the file URL shows up automatically in the order admin, on the customer's order confirmation, and in the admin new-order email. Open WooCommerce → Orders, click your test order, and the link is right there on the line item.

[Screenshot placeholder — ALT: "Order edit screen with the customer file URL shown as line item meta"]

That's the whole feature — roughly 120 lines. Now the part the snippets never mention.

Security hardening

Accepting files from strangers onto your own server is one of the classic web attack surfaces. The code above already includes the essentials, but understand why they're there:

Whitelist, never blacklist. We allow three known-safe types. Blocking "dangerous" extensions always misses one (.phtml, .php5, .shtml...).

Check content, not names. wp_check_filetype_and_ext() plus the mimes whitelist in wp_handle_upload() means a renamed script fails twice.

Keep execution off the uploads folder. Belt-and-suspenders for Apache — drop this into /wp-content/uploads/.htaccess:

<FilesMatch "\.(php|php5|phtml|pht|shtml|cgi|pl|py)$">
  Require all denied
</FilesMatch>

On nginx, add a location rule that denies PHP execution under /wp-content/uploads/.

Mind the URL privacy. Files in the uploads folder are publicly readable by anyone who has (or guesses) the URL. Customer designs are usually low-risk; passports or legal documents are not. For sensitive files, you'd need protected storage and authenticated downloads — a substantially bigger build.

The OWASP file upload cheat sheet is the canonical reference if you want the complete threat model.

Common mistakes

Forgetting the enctype fix (Step 2). The number one cause of "my code doesn't work" — no error, just an empty $_FILES.

Validating only in JavaScript. The accept attribute and any client-side checks are UX sugar. Every rule must repeat server-side.

Skipping the unique_key. Identical products with different files silently merge in the cart.

Trusting PHP defaults for size limits. Your 10 MB check is meaningless if upload_max_filesize is 2 MB — PHP rejects the file before your code runs, and the customer sees a generic failure. Check php.ini values first (here's how).

Never cleaning up. Files accumulate forever in /uploads/, bloating backups and disk. Schedule a cleanup routine for files attached to completed orders past your retention window.

What this approach can't do

Being honest about the ceiling, since we build upload tools for a living:

If you hit those limits, that's the point where a dedicated tool earns its keep — File Uploader for WooCommerce handles 14 upload sources including HEIC, runs on dedicated upload infrastructure, and keeps files off your server entirely on the Pro plan. The free tier is a reasonable way to compare against your own build.

Frequently asked questions

Why is $_FILES empty when my form submits?

Almost always the missing enctype="multipart/form-data" on the add-to-cart form — see Step 2. If the enctype is set and $_FILES is still empty, check that the file exceeds no PHP limit (upload_max_filesize, post_max_size).

Where does wp_handle_upload put the files?

In your standard uploads directory, organized by date: /wp-content/uploads/2026/07/filename.jpg. The function returns both the filesystem path and the public URL.

Can I make the upload required only for some products?

Yes. Gate the field output (Step 1) and the "file is required" branch of validation (Step 3) on the same condition — a product category, tag, or custom field.

How do I let customers upload multiple files?

Add multiple to the input and name it sr_customer_file[], then loop over the normalized $_FILES array in validation and upload handling. Budget real time: the $_FILES structure for multi-uploads is awkward, and every step above needs the loop treatment.

Does this work with the WooCommerce block-based product templates?

The hooks above target the classic product template, which most themes still render. Block themes using the Product Collection / single product blocks may not fire woocommerce_before_add_to_cart_button where you expect — test on a staging copy first.

Wrapping up

A hand-rolled WooCommerce upload field is a fair weekend project for a developer: six hooks, one encoding gotcha, and a security checklist. You now have every piece, working and explained.

Just price the whole thing honestly — validation edge cases, mobile users, HEIC photos, cleanup, and the maintenance tail. If the build stops being fun, the free version of our uploader takes about ten minutes to set up, and you can keep your code on a branch for the day you need something truly custom.