How to Attach a PDF to Magento 2 Order Emails (Manuals, Warranty Cards)
Magento 2 order emails can't carry attachments. Here is the custom TransportBuilder code, what it leaves out, and a per-SKU module that sends PDFs.

Magento's order, invoice, and shipment emails cannot carry file attachments. There is no admin setting for it, and the framework's TransportBuilder has no addAttachment() method. If you want a drill's manual or a warranty card to land in the buyer's inbox as a PDF, you either write code that extends the mail layer or you install a module that already does.
We build and sell one of those modules, the OCM Labs Product Messaging module for Magento 2, so weigh what follows with that in mind. We will still show the custom code first, because for a store with one PDF and a developer on hand, it may be all you need.
Can you add an attachment to a core Magento order email?
Not without code. Core sales emails are rendered from templates you edit under Marketing > Communications > Email Templates, and templates only control the HTML body. The message itself is built by Magento\Framework\Mail\Template\TransportBuilder, which creates a single body part and nothing else.
There is a second wrinkle. The order, invoice, and shipment emails do not call TransportBuilder from your code. They go through their own sender classes (OrderSender, InvoiceSender, ShipmentSender), which hand off to Magento\Sales\Model\Order\Email\SenderBuilder. To attach a file to those specific emails, you have to replace the TransportBuilder that SenderBuilder receives and then work out, from inside the mail layer, which order is being sent and which PDF belongs to it.
That is why most custom builds skip the core email entirely and send a second email of their own. It is also what our module does, as covered below.
What does the custom code look like?
Two pieces: a TransportBuilder subclass that accepts attachments, and something that decides when to send. This sample targets Magento 2.4.8 and 2.4.9, where the mail layer is built on Symfony Mime. Magento 2.4.7 still used laminas-mail, so the same idea needs different code there.
The subclass lets the parent build the message exactly as core does, then rewraps the body in a multipart/mixed part with one DataPart per PDF:
<?php
declare(strict_types=1);
namespace Acme\OrderPdf\Model\Mail;
use Magento\Framework\Mail\EmailMessage;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\MixedPart;
class TransportBuilder extends \Magento\Framework\Mail\Template\TransportBuilder
{
/** @var array<int, array{0: string, 1: string}> */
private array $attachments = [];
public function addAttachment(string $content, string $filename): self
{
$this->attachments[] = [$content, $filename];
return $this;
}
protected function prepareMessage()
{
parent::prepareMessage();
if ($this->attachments === [] || !$this->message instanceof EmailMessage) {
return $this;
}
$symfonyMessage = $this->message->getSymfonyMessage();
$parts = [$symfonyMessage->getBody()];
foreach ($this->attachments as [$content, $filename]) {
$parts[] = new DataPart($content, $filename, 'application/pdf');
}
$symfonyMessage->setBody(new MixedPart(...$parts));
return $this;
}
protected function reset()
{
parent::reset();
$this->attachments = [];
return $this;
}
}Why not pass several parts to Magento's own MimeMessageInterfaceFactory? In 2.4.8 and 2.4.9, Magento\Framework\Mail\MimeMessage only keeps the first part you hand it. We found that the hard way while building our module. The attachments have to be merged on the underlying Symfony message, which EmailMessage::getSymfonyMessage() exposes. That method is on the concrete class, not the interface, hence the instanceof check.
Overriding reset() matters too. getTransport() calls it after every build, and if your buffer survives, the next email from the same builder instance goes out with the previous customer's PDF.
Then an observer sends the email when a shipment is created:
<?php
declare(strict_types=1);
namespace Acme\OrderPdf\Observer;
use Acme\OrderPdf\Model\Mail\TransportBuilder;
use Magento\Framework\App\Area;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Filesystem\Driver\File;
use Magento\Sales\Model\Order\Shipment;
class SendManualOnShipment implements ObserverInterface
{
private const MANUAL = BP . '/var/manuals/drill-2000x.pdf';
public function __construct(
private readonly TransportBuilder $transportBuilder,
private readonly File $file
) {
}
public function execute(Observer $observer): void
{
/** @var Shipment $shipment */
$shipment = $observer->getEvent()->getShipment();
if ($shipment->getOrigData('entity_id') !== null) {
return; // not a new shipment
}
$order = $shipment->getOrder();
$this->transportBuilder
->setTemplateIdentifier('acme_orderpdf_manual')
->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $order->getStoreId()])
->setTemplateVars(['order' => $order])
->setFromByScope('sales', $order->getStoreId())
->addTo($order->getCustomerEmail(), $order->getCustomerName())
->addAttachment($this->file->fileGetContents(self::MANUAL), 'drill-2000x-manual.pdf')
->getTransport()
->sendMessage();
}
}Register it on sales_order_shipment_save_after in etc/events.xml and declare the acme_orderpdf_manual template in etc/email_templates.xml. That works for one product and one PDF.
What this sample does not handle
Which PDF for which order. The sample sends one manual to every shipment. A real store needs a SKU-to-file map, including what happens when a configurable product's child SKU is what actually shipped.
Split shipments. If an order ships in two boxes, this fires twice. The second box might not contain the drill at all, so you need to check the shipment's items, not the order's.
Duplicates. Saving an existing shipment again, or a module that re-saves it, must not resend. The getOrigData() check covers the simple case only.
Failures and retries. The send runs inside the shipment save. If your SMTP relay is slow, the admin waits. If it throws, the shipment save can fail with it, and nothing retries the email later. Production code wraps this, queues it, and sends from cron.
File handling. Someone uploads a renamed .docx as the manual, or deletes the file from disk. You want to reject the first at upload and still send the email in the second case.
A record. When a customer says "I never got the manual," you need a log showing what went out, when, and why it failed.
None of this is hard on its own. Together it is a small module, and it becomes code someone has to own through every Magento upgrade, including mail-layer changes like the 2.4.8 move to Symfony.
When is custom code enough?
When you have one or two PDFs, they belong to products that never ship partially, and a developer will maintain the code. A single observer like the one above, with a try/catch and a hardcoded SKU check, is a reasonable afternoon of work.
It stops being enough when merchandising wants to add a new manual without a deploy, or when you sell enough SKUs that the mapping lives in a spreadsheet.
How does the module handle PDF attachments?
You create a rule under Sales > Product Messaging > Rules, list the SKUs it applies to, pick an email template, and upload the PDFs. Every email that rule sends carries those files.
To be clear about one thing up front: the module sends its own separate email for each matching rule. It does not modify Magento's order confirmation, invoice, or shipment email. Your customer gets the normal order confirmation from Magento, plus, say, a "Your DRILL-2000X manual" email with the PDF attached.
The pieces that map to the gaps listed above:
- SKU patterns. Exact SKUs or
*wildcards, soDRILL-*covers the whole line. A Match Children option also matches the child SKUs of configurable and bundle items. A Browse Products grid appends SKUs to the list if you don't want to type them. - Three triggers. Order Placed, Invoice Created, or Shipment Created, with an optional send delay in minutes.
4320sends three days after the trigger. - Shipment-aware. A Shipment Created rule fires only when a matched SKU is in that shipment, so the manual goes out with the box that contains the product.
- One email per rule per order. A unique constraint on rule and order means double saves, re-invoices, and partial shipments do not produce a second send.
- Checked uploads. PDF only, verified by extension and by the file's actual content, so a renamed non-PDF is rejected. A rule's attachments share a combined cap, 10 MB by default and configurable.
- Missing files don't block the email. If a PDF has gone missing from disk by send time, the email goes out without it and a warning is logged.
- Queued, not inline. The observer writes a queue row and returns. Cron sends the email, and failed sends retry with exponential backoff up to a configurable number of attempts. The trigger details are in how the three triggers and send delays work.
- Email Log. Every queued email shows as Pending, Sent, Failed, or Cancelled, with attempts and the error message, and you can resend from the grid. See the Email Log and Resend.
- Send Test. Sends the rule's current form state, unsaved changes included, to any address you enter, using the most recent order that matches. It skips the queue and the log.
Templates are ordinary Magento email templates with the usual directives, plus variables like order, customer_name, and matched_items. Two samples ship with the module, a warranty email and an instruction-manual email, so the first rule can be live in a few minutes.
What are the limits?
Matching is by SKU list only. There are no category or attribute conditions, so "every product in the Power Tools category" means listing SKUs or using a wildcard that fits your SKU scheme.
There is no unsubscribe link or suppression list. These emails are meant to be transactional: the manual for the thing someone bought. If you turn a rule into a promotion, the marketing-email obligations are yours.
Cron has to run. A pile of old Pending rows in the Email Log almost always means cron is stopped.
There is no open or click tracking and no REST or GraphQL API for rules.
The module is $250 for Magento Open Source and $500 for Adobe Commerce, installed with composer require ocmlabs/module-product-messaging (Adobe Commerce stores use ocmlabs/module-product-messaging-commerce). It runs on Luma and Hyva without a separate Hyva package. Our other modules are on the Magento extensions page.
✦ FAQ
Does Magento 2 support email attachments out of the box?+–
Can I attach a PDF to the order confirmation email itself?+–
Can different products get different PDFs?+–
What happens if an order ships in two parts?+–
Is there a file size limit?+–
Can I edit transactional email templates for these emails?+–

Shane Blandford
Founder of Orange Collar Media, a Denver ecommerce agency behind 800+ Magento and Shopify builds. In Magento since version 1.x - building, rescuing, and supporting revenue-critical stores since 2008.
✦ KEEP READING
Related from the Journal.
Magento 2 Rich Snippets: How to Set Up Product Schema That Validates
Magento 2 rich snippets, done by hand: Google's required Product properties, a JSON-LD block mapped to Magento data, and fixes for duplicate microdata.

Magento SEO Checklist for 2.4.x: Severity, Admin Path and a Verify Step for Every Item
A Magento SEO checklist for 2.4.x stores: 50 items, each with a severity, the admin path or bin/magento command, and a way to verify it passes.

What a Magento 2 SEO Extension Should Do, and How to Test One
What a Magento SEO extension should do as you edit: a live 0-100 score, an 18-check list with fix hints, a Google preview, and a buyer's test for demos.
