We used to attach a PDF to every order confirmation email. The PDF was the invoice, generated server-side from a Blade template via a PDF library, and it worked. It also tanked our email open rates on mobile, where the attachment showed up as a generic clip icon nobody tapped, and it pushed up our spam score because PDF attachments at scale carry weight.
A year ago we replaced the PDF with an inline PNG image embedded in the email body. Same content. No attachment. The image renders the moment the email opens. Customers see the invoice without tapping anything. Open rates went up. The spam score dropped enough to feel it.
This article is about the pattern that replaced the PDF, and how the same plumbing handles receipts, vouchers, and product cards. Working code in Laravel, the bits we got wrong, and when this is the wrong approach.
Why the PDF attachment is not great
A PDF as an order confirmation feels professional. There are three real problems.
PDFs are heavy. A styled invoice from a library like dompdf weighs roughly 150 KB. The equivalent PNG is 40 KB. When you send a million transactional emails a year, the difference adds up. More importantly, your email service provider grades you on average message size, and that grade feeds back into deliverability.
Mobile email clients hide attachments. Gmail on Android shows a generic "1 attachment" pill that needs a tap. iOS Mail shows the attachment as an icon at the bottom of the message. Neither client renders the PDF inline by default. The customer has to want to see the invoice. Most do not bother.
Spam scoring penalises attachments at scale. Marketing emails with PDF attachments are flagged disproportionately, because most legitimate transactional senders moved off PDF attachments years ago. Hitting Inbox or Promotions is partly determined by the absence of attachments. Replacing the PDF with an inline image moves you back into Inbox.
The fix that I tried first: generate the PDF, render its first page to a PNG with imagick, embed the PNG. This works. It also means you're now running both a PDF generator and a rasteriser in your image pipeline, with one feeding the other. The plumbing was twice as much code for half the design control.
The fix that stuck: skip the PDF, render the invoice template directly to a PNG.
The pattern
One Blade template per document type. One renderer service that handles all of them. One cache layer on S3 that serves repeat requests without a re-render.
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class TransactionalImageRenderer
{
private const VIEWPORTS = [
'invoice' => ['w' => 1240, 'h' => 1754, 'view' => 'images.invoice'],
'receipt' => ['w' => 600, 'h' => 900, 'view' => 'images.receipt'],
'voucher' => ['w' => 1200, 'h' => 600, 'view' => 'images.voucher'],
'product-card' => ['w' => 1200, 'h' => 630, 'view' => 'images.product'],
];
public function render(string $type, array $data): string
{
$config = self::VIEWPORTS[$type];
$key = $this->cacheKey($type, $data);
if (Storage::disk('s3')->exists($key)) {
return Storage::disk('s3')->url($key);
}
$html = view($config['view'], $data)->render();
$res = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->post('https://app.html2img.com/api/html', [
'html' => $html,
'width' => $config['w'],
'height' => $config['h'],
'wait_for_selector' => '.ready',
])->throw()->json();
$bytes = Http::get($res['url'])->throw()->body();
Storage::disk('s3')->put($key, $bytes, 'public');
return Storage::disk('s3')->url($key);
}
private function cacheKey(string $type, array $data): string
{
ksort($data);
$hash = md5(json_encode([...$data, '_type' => $type, '_v' => '1']));
return "tx-images/{$type}/{$hash}.png";
}
}
A few things worth highlighting. The cache hits S3 first. If the image exists for this exact payload, it returns the URL immediately. No API call, no credit spent. The vast majority of calls become hits once the system has warmed up, because the same invoice gets re-rendered every time the customer or our support team opens the original email.
The wait_for_selector: '.ready' is a convention I picked up after a few rendered images shipped with the font in the fallback state. Add a <div class="ready"> to the bottom of every template, after the last meaningful element. The screenshot fires once that div is in the DOM, which guarantees everything above it has rendered. Cleaner than picking a content-specific selector per template.
The Mailable side is straightforward:
class OrderInvoiceMail extends Mailable
{
public function __construct(public Order $order) {}
public function build()
{
$invoiceUrl = app(TransactionalImageRenderer::class)->render('invoice', [
'reference' => $this->order->reference,
'issued_on' => $this->order->created_at->format('jS F Y'),
'lines' => $this->order->lines->toArray(),
'total' => $this->order->total,
// ...
]);
return $this->view('emails.order-invoice', ['invoice_url' => $invoiceUrl])
->subject("Your invoice from Coastline Coffee Co");
}
}
In the email view, the invoice is a plain <img> tag pointing at the URL on our CDN. No attachment, no withAttachment, no PDF library.
What changed in the numbers
Mobile open-to-action rate (customers who tapped to view the invoice on mobile) went from 14% to 89%. The latter is essentially everyone who opened the email, because they didn't need to do anything to see the invoice.
Email size dropped from an average 198 KB to 67 KB. Send time per batch dropped proportionally.
Spam score (we use Postmark, which scores 0 to 10) dropped from a stable 1.2 to 0.4. Deliverability into the Inbox tab on Gmail, measured via the seed-list panel, went from 81% to 94%.
These numbers are specific to our setup and our send volume (high transactional, very low marketing). Your mileage will differ. The direction has been consistent across the three companies I have seen do this migration.
The four document types
The same renderer covers four template shapes. Each has a viewport, a Blade view, and a cache key. The data shape is different per type but the plumbing is identical.
Invoice at 1240 by 1754. A4 portrait. Full line items, totals, party details. Heaviest template. If you'd rather not maintain the HTML, there's a pre-built invoice template at the same dimensions.
Receipt at 600 by 900. A compact summary, just enough for an order confirmation. Pre-built version: receipt template.
Voucher at 1200 by 600. The code, expiry, terms, brand colour. Goes into marketing emails. Pre-built: coupon voucher.
Product card at 1200 by 630. Product photo, name, price, optional sale price. Used in abandoned-cart emails. Pre-built: product card.
If you go the pre-built route, the renderer becomes even shorter because you skip the Blade view and the wait selector. You POST JSON to the template endpoint, the response is your PNG URL. The trade-off is the layout is opinionated.
Gotchas
The list of things I wish I'd known.
Mobile email clients clip wide images. Most clients render the image at the email's content width (usually 600px on mobile). A 1200-wide voucher scales down cleanly. A 1240-wide invoice scales down too small to read. Use the preview-plus-link pattern for anything wider than 1000px: a smaller preview image inline, a "view full invoice" link to the full-size image on your CDN.
Tabular numerals. Without font-variant-numeric: tabular-nums on your money columns, numbers shift left and right row to row. Looks wrong in a way you can't quite name until you fix it. One CSS line, immediate improvement.
Currency formatting. number_format($amount, 2) is fine for GBP and USD. If you support more than one currency, use NumberFormatter from the Intl extension. The bug is when you display "£1,234.56" for everyone including the customer paying in EUR.
Compliance. If your invoices need a stable PDF for accounting or audit reasons, the PNG-in-email pattern complements the PDF, it doesn't fully replace it. Generate both: the PNG goes in the email body, the PDF lives in the customer's account or in your finance system. Most tax authorities still expect the PDF; tax authorities are not who reads the email.
The S3 cache is the system of record. The upstream API gives you a URL on its CDN. Tempting to just use that URL in the email. We did, then ran into a 90-minute outage where every old invoice link suddenly 404'd. The fix is to fetch the bytes once at render time and persist to your own storage. Treat the upstream URL as a one-shot handle, not a permanent address.
When this is the wrong approach
Two cases where the PDF attachment is still right.
Markets where customers print every invoice. PDFs at print-quality DPI are a better fit than PNGs, which are fixed-resolution. For B2B sales in regulated industries, the PDF attachment is still the norm and customers expect it.
Strict data residency. If your invoice contains PII that can't leave your infrastructure, an API renderer is a third-party processor. The render request and the resulting bytes flow through a third party. The API doesn't persist your HTML, but if your compliance team has a "no third-party processors for PII" rule, you generate the PDF locally and live with the attachment.
Outside of those two, the pattern has been the lowest-friction win for transactional email work I've made in years. The first version took an afternoon. The maintenance cost is roughly zero, because there is no Chromium and no PDF library to keep happy. Replacing a working PDF pipeline with this is a half-day refactor for the average application.
Worth the half-day.
Top comments (0)