Attaching Files to WooCommerce Order Emails Programmatically (Conditional, Per-Product, HPOS-Ready)
Table of Contents
Attaching a file to a WooCommerce order email takes one filter hook. Getting it right – attaching different files per product, per category, or only on specific email types – takes a bit more thought.
The core hook is woocommerce_email_attachments. It receives three arguments: the current array of file paths, the email ID string (e.g. customer_completed_order), and the WC_Order object. Return a modified array and WooCommerce’s PHPMailer integration picks it up automatically. No output buffering, no custom mailer setup needed.
The Base Hook Structure
Before adding any conditional logic, the raw implementation looks like this:
add_filter( 'woocommerce_email_attachments', 'wrtech_attach_order_files', 10, 3 );
function wrtech_attach_order_files( array $attachments, string $email_id, $order ): array {
if ( ! $order instanceof WC_Order ) {
return $attachments;
}
$file = WP_CONTENT_DIR . '/uploads/docs/terms.pdf';
if ( file_exists( $file ) ) {
$attachments[] = $file;
}
return $attachments;
}
The file_exists() check matters – passing a non-existent path does not throw an error in most setups, but it does cause sporadic PHPMailer warnings that land in your error log at scale.
Always use absolute server paths (WP_CONTENT_DIR, ABSPATH, wp_upload_dir()['basedir']), never URLs. PHPMailer reads the file from disk, not over HTTP.
Targeting Specific Email Types
The $email_id parameter is the most overlooked part of this hook. Without checking it, your attachment ends up on every email WooCommerce sends – new order admin notifications, failed order alerts, refund confirmations. That is rarely the intent.
$target_emails = [ 'customer_completed_order', 'customer_invoice' ];
if ( ! in_array( $email_id, $target_emails, true ) ) {
return $attachments;
}
Common email IDs to know: customer_processing_order, customer_completed_order, customer_invoice, customer_refunded_order, new_order (admin), cancelled_order (admin). Custom email classes registered via woocommerce_email_classes use whatever ID their $id property is set to – check with error_log( $email_id ) if unsure.
If you are building a WooCommerce email marketing workflow that dispatches follow-up documents, locking attachment logic to the right email ID prevents duplicate sends across the transactional sequence.
Attaching Files Per Product
The most common real-world scenario: a store sells digital products alongside physical ones, and each digital product has its own license PDF or usage guide. You cannot use a single static path for that.
add_filter( 'woocommerce_email_attachments', 'wrtech_per_product_attachments', 10, 3 );
function wrtech_per_product_attachments( array $attachments, string $email_id, $order ): array {
if ( ! $order instanceof WC_Order ) {
return $attachments;
}
if ( 'customer_completed_order' !== $email_id ) {
return $attachments;
}
$upload_dir = wp_upload_dir();
foreach ( $order->get_items() as $item ) {
$product_id = $item->get_product_id();
// Store the file path in product meta: _attachment_file
$file_path = get_post_meta( $product_id, '_attachment_file', true );
if ( $file_path && file_exists( $file_path ) ) {
$attachments[] = $file_path;
}
}
return $attachments;
}
The _attachment_file meta key is arbitrary – name it whatever makes sense for your setup. You can expose it as a custom field in the product edit screen using woocommerce_product_options_general_product_data and woocommerce_process_product_meta. Checking which products exist in a WooCommerce order uses the same get_items() loop pattern.
One edge case: variable products. $item->get_product_id() returns the parent ID. If your attachments are variation-specific, use $item->get_variation_id() and fall back to the parent if variation meta is empty.
Category-Based PDF Attachments
For stores with larger catalogs, maintaining per-product meta for attachments becomes unmanageable. Attaching files based on product category is cleaner – one PDF per category covers an entire range of products.
function wrtech_category_attachments( array $attachments, string $email_id, $order ): array {
if ( ! $order instanceof WC_Order || 'customer_completed_order' !== $email_id ) {
return $attachments;
}
// Map category slugs to absolute file paths
$category_files = [
'electronics' => WP_CONTENT_DIR . '/uploads/manuals/electronics-guide.pdf',
'software' => WP_CONTENT_DIR . '/uploads/manuals/software-license.pdf',
'supplements' => WP_CONTENT_DIR . '/uploads/manuals/supplement-info.pdf',
];
$attached = [];
foreach ( $order->get_items() as $item ) {
$product_id = $item->get_product_id();
$terms = wp_get_post_terms( $product_id, 'product_cat', [ 'fields' => 'slugs' ] );
foreach ( $terms as $slug ) {
if ( isset( $category_files[ $slug ] ) && ! in_array( $category_files[ $slug ], $attached, true ) ) {
$path = $category_files[ $slug ];
if ( file_exists( $path ) ) {
$attachments[] = $path;
$attached[] = $path;
}
}
}
}
return $attachments;
}
add_filter( 'woocommerce_email_attachments', 'wrtech_category_attachments', 10, 3 );
The $attached deduplication array prevents the same PDF appearing twice when an order contains multiple products from the same category.
wp_get_post_terms() fires a get_terms database query per product. On orders with 15+ line items, that is 15+ extra queries per email send. For high-volume stores, cache the result per product ID using a static variable inside the loop, or move the category-to-file mapping into an option that you look up once and match against term IDs stored in a transient. The query overhead compounds when email sending happens synchronously on order completion.
HPOS Compatibility
If the store has migrated to High-Performance Order Storage, $order passed to the filter is a WC_Order object backed by the custom tables rather than a post. The woocommerce_email_attachments filter itself is not affected – it still receives a proper WC_Order instance regardless. What breaks is code that bypasses the object and calls get_post_meta( $order->get_id(), ... ) to read order meta directly.
Always read order meta through the object API:
// HPOS-safe
$custom_meta = $order->get_meta( '_my_custom_field', true );
// Breaks with HPOS enabled
$custom_meta = get_post_meta( $order->get_id(), '_my_custom_field', true );
This applies to any custom logic that determines which file to attach based on order-level meta. HPOS query patterns differ significantly from the legacy post meta approach, and the email hook is an easy place to introduce a regression during migration if you are reading order data the old way.
Generating Attachments Dynamically
Static files cover most use cases, but some stores need per-order PDFs – invoices with order numbers, purchase summaries with customer data baked in. The pattern: generate the file on demand in the hook, attach it, then schedule cleanup.
function wrtech_generate_invoice_attachment( array $attachments, string $email_id, $order ): array {
if ( ! $order instanceof WC_Order || 'customer_completed_order' !== $email_id ) {
return $attachments;
}
$upload_dir = wp_upload_dir();
$filename = 'invoice-' . $order->get_order_number() . '.txt';
$file_path = $upload_dir['basedir'] . '/invoices/' . $filename;
if ( ! file_exists( $file_path ) ) {
$content = 'Order: ' . $order->get_order_number() . "n";
$content .= 'Total: ' . $order->get_formatted_order_total() . "n";
file_put_contents( $file_path, $content );
}
if ( file_exists( $file_path ) ) {
$attachments[] = $file_path;
}
return $attachments;
}
For proper PDF generation you would swap the file_put_contents call with a library like TCPDF or mPDF. Wrapping the generation logic in a try-catch keeps email delivery intact if the PDF library throws – PHP 8 fatal error handling in plugins is especially relevant here since PDF libraries can throw on malformed order data.
Attaching Files Only on Manual Resend
The admin email resend flow (WooCommerce > Orders > Resend email) passes the same email IDs through the same filters. If you want attachments only on the first automated send and not on manual resends, detect the admin context:
if ( is_admin() && doing_action( 'woocommerce_before_resend_order_emails' ) ) {
return $attachments;
}
This is niche but relevant when storing sensitive documents – you may want them sent once automatically and removed from resend flows to avoid accidental re-delivery to a different email address after the customer updates their account. Controlling WordPress email triggers at the hook level follows the same principle of checking execution context before acting.
File Size and SMTP Considerations
Most shared hosting SMTP relays cap attachment size at 10-25 MB per message. Attaching a 15 MB product manual to every completed order email will hit those limits and cause silent delivery failures – the order still completes, the email silently drops.
Keep attachments under 2 MB where possible. For larger documents, generate a signed temporary URL and include it in the email body via woocommerce_email_order_details instead of attaching the file directly. Alternatively, a custom order endpoint can serve protected documents post-purchase without putting file delivery weight on transactional email.
If the store uses an external SMTP provider (SendGrid, Mailgun, Postmark), check whether attachments count toward your monthly data quota. A store doing 500 orders per day with a 1 MB PDF per email adds 15 GB of attachment data per month to the email delivery bill.
Putting It All Together
Organizing attachment logic into a single class rather than multiple loose add_filter calls makes the conditional branching easier to maintain and test:
class WRTech_Email_Attachments {
public function __construct() {
add_filter( 'woocommerce_email_attachments', [ $this, 'attach' ], 10, 3 );
}
public function attach( array $attachments, string $email_id, $order ): array {
if ( ! $order instanceof WC_Order ) {
return $attachments;
}
if ( ! in_array( $email_id, $this->get_target_emails(), true ) ) {
return $attachments;
}
return $this->resolve_attachments( $attachments, $order );
}
private function get_target_emails(): array {
return apply_filters( 'wrtech_attachment_email_ids', [ 'customer_completed_order' ] );
}
private function resolve_attachments( array $attachments, WC_Order $order ): array {
foreach ( $order->get_items() as $item ) {
$path = get_post_meta( $item->get_product_id(), '_attachment_file', true );
if ( $path && file_exists( $path ) ) {
$attachments[] = $path;
}
}
return array_unique( $attachments );
}
}
new WRTech_Email_Attachments();
The apply_filters call on get_target_emails() lets child plugins or functions.php extend the email ID list without touching the core class. array_unique() on the return value is a cheap final deduplication pass that costs nothing on small arrays but prevents duplicate file sends on larger orders with repeated products.
Често задавани въпроси
-
What hook do you use to add attachments to WooCommerce emails?
Use the woocommerce_email_attachments filter. It receives three parameters: an array of existing file paths, the email ID string, and the WC_Order object. Add your absolute server file paths to the array and return it.
-
How do I attach a different file depending on which product was ordered?
Loop through $order->get_items() inside the filter callback, read a custom meta field like _attachment_file from each product using get_post_meta(), and append the path to the $attachments array if file_exists() returns true.
-
Does woocommerce_email_attachments work with HPOS enabled?
Yes, the filter receives a WC_Order object regardless of storage backend. Just make sure any order meta reads use $order->get_meta() rather than get_post_meta() directly, which breaks under HPOS.
-
How do I attach a file only to the completed order email and not other WooCommerce emails?
Check the $email_id parameter at the top of your callback. Return the original $attachments array unchanged if $email_id does not match ‘customer_completed_order’ or whatever target IDs you need.
-
Can I generate a per-order PDF and attach it dynamically?
Yes. Generate the file to wp_upload_dir()[‘basedir’] inside the filter callback, then add the resulting path to $attachments. Wrap the generation logic in a try-catch block so a PDF library failure does not break email delivery entirely.
Related Articles
If you enjoyed reading this, then please explore our other articles below:
More Articles
If you enjoyed reading this, then please explore our other articles below:




2019-2026 ©