- What "transactional images" cover
- Why PDFs and static images both fall short
- Three approaches
- The shared renderer
- The invoice template
- The receipt, voucher, and product card
- Caching and idempotency
- Gotchas
- Picking an approach
Every e-commerce or SaaS application ends up with the same shopping list of transactional images. An invoice that goes out with the receipt email. A compact receipt for the order confirmation. A voucher graphic for the next promo. A product card for the abandoned-cart reminder. Four different documents, all with the same problem: getting a styled, dynamic, brand-consistent image out of your application without owning a rendering pipeline.
This article walks through how to render all four as PNGs from HTML in Laravel. The same pattern works for any backend. By the end you will have one Mailable, one renderer, and one cache layer that handles every transactional image your application sends.
What "transactional images" cover
Four document types come up over and over.
Invoices. Full A4-shaped documents at 1240 by 1754 (A4 portrait at 150 DPI). Line items, totals, tax, party details. Usually emailed and stored against the order. Historically attached as PDFs.
Receipts. Shorter, narrower documents at around 600 by 900. Just enough to confirm what was ordered. Sent inline in the order confirmation email, sometimes also as a downloadable PDF.
Vouchers and coupons. Promotional graphics at 1200 by 600 or similar. A code, an expiry date, branding, and a clear call to action. Go out in marketing emails. Often double as SMS or push preview images.
Product cards. E-commerce product graphics at 1200 by 630 or 1080 by 1080. The product photo, name, price, optional sale price, brand mark. Used in abandoned-cart emails, retargeting campaigns, social posts.
They look different on the page but they have identical infrastructure needs. Pixel-accurate rendering, brand fonts loaded reliably, dynamic data interpolated from your database, fast enough to ship as part of a transactional email flow.
Why PDFs and static images both fall short
A common starting point is to attach a PDF invoice to the order confirmation email. This works, technically, but has a list of small problems that compound.
PDFs are larger than the equivalent PNG, usually by a factor of three or more. That adds weight to every email you send, which feeds into spam scoring at scale. Mobile email clients show a generic attachment icon rather than the document itself. Web-based clients hide attachments behind a click. By the time the customer has tapped through to see the invoice, they have either lost interest or moved on.
The other instinct is a static image. Designed once, exported from Figma, dropped into the email template. This holds up exactly as long as your branding and your line item structure stay the same, which they do not.
The version that solves both is a styled HTML template, rendered to a PNG, embedded in the email body as a normal image. Renders in every email client immediately. Reads on mobile and desktop. Looks like the invoice you designed, with the current customer's data baked in. Smaller file size than the PDF it replaces.
Three approaches
The same three places to do the rendering as for any HTML to image problem.
Approach 1: Snail-mail manual
A designer makes the document in Figma, exports a template, and a member of the team fills in the customer details before sending. Workable for an accountant issuing 10 invoices a month. Unworkable as a system.
Approach 2: Headless Chromium
A Laravel service that renders a Blade template in Spatie's Browsershot and saves the result. You own the Chromium binary, the memory budget per render (around 250 MB), font pre-warming, and version pinning. The honest tradeoffs of this approach are covered in detail in the dynamic Open Graph images walk-through. Same caveats apply here.
Approach 3: A managed HTML to image API
POST the HTML, receive a PNG URL. For the rest of this article we use the html2img.com HTML endpoint. Any equivalent service works the same way.
The shared renderer
The pattern is one renderer service that handles all four document types. The document type controls the viewport size and the Blade template, everything else is identical.
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', 'selector' => '.totals'],
'receipt' => ['w' => 600, 'h' => 900, 'view' => 'images.receipt', 'selector' => '.total'],
'voucher' => ['w' => 1200, 'h' => 600, 'view' => 'images.voucher', 'selector' => '.code'],
'product-card' => ['w' => 1200, 'h' => 630, 'view' => 'images.product', 'selector' => '.price'],
];
public function render(string $type, array $data): string
{
$config = self::VIEWPORTS[$type]
?? throw new \InvalidArgumentException("Unknown image type: {$type}");
$cacheKey = $this->cacheKey($type, $data);
if (Storage::disk('s3')->exists($cacheKey)) {
return Storage::disk('s3')->url($cacheKey);
}
$html = view($config['view'], $data)->render();
$response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(30)
->post('https://app.html2img.com/api/html', [
'html' => $html,
'width' => $config['w'],
'height' => $config['h'],
'wait_for_selector' => $config['selector'],
])
->throw()
->json();
$bytes = Http::get($response['url'])->throw()->body();
Storage::disk('s3')->put($cacheKey, $bytes, 'public');
return Storage::disk('s3')->url($cacheKey);
}
private function cacheKey(string $type, array $data): string
{
$hash = md5(json_encode([...$data, '_type' => $type, '_v' => '1']));
return "transactional-images/{$type}/{$hash}.png";
}
}
A few things to note. The renderer caches the PNG to S3 (or any equivalent disk), keyed by a hash of the data plus a template version. The first call renders, the next million calls serve from your CDN. The wait_for_selector per document type makes sure the screenshot fires after the meaningful content (the totals row on an invoice, the code on a voucher) is in the DOM, not before.
The four Blade views below all share a common base. Same font stack, same brand colour variable, same date formatting. The differences are layout and density.
The invoice template
A4 portrait, table-heavy, suitable for printing.
{{-- resources/views/images/invoice.blade.php --}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1240px; height: 1754px;
background: #fff; color: #1A1A1A;
font-family: Inter, sans-serif;
font-size: 14px; line-height: 1.5;
padding: 80px;
}
header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 60px; }
.brand { font-size: 28px; font-weight: 800; color: #0B1220; }
.meta { text-align: right; }
.meta h1 { font-size: 40px; font-weight: 800; letter-spacing: -1px; margin-bottom: 8px; }
.meta .ref { font-size: 14px; color: #666; }
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; margin-bottom: 60px; }
.party h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #666; margin-bottom: 8px; }
.party .name { font-weight: 600; font-size: 16px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
thead th {
text-align: left; padding: 12px 0; font-size: 11px;
text-transform: uppercase; letter-spacing: 1.5px; color: #666;
border-bottom: 2px solid #1A1A1A;
}
thead th.num { text-align: right; }
tbody td { padding: 16px 0; border-bottom: 1px solid #E5E5E5; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
.totals { width: 360px; margin-left: auto; margin-top: 24px; }
.totals .row { display: flex; justify-content: space-between; padding: 8px 0; }
.totals .row.grand { border-top: 2px solid #1A1A1A; padding-top: 16px; margin-top: 8px; font-weight: 800; font-size: 18px; }
footer { position: absolute; bottom: 80px; left: 80px; right: 80px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<header>
<div class="brand">{{ $issuer['name'] }}</div>
<div class="meta">
<h1>Invoice</h1>
<div class="ref">#{{ $reference }}</div>
<div class="ref">{{ $issued_on }}</div>
</div>
</header>
<div class="parties">
<div class="party">
<h2>From</h2>
<div class="name">{{ $issuer['name'] }}</div>
<div>{{ $issuer['address'] }}</div>
<div>VAT {{ $issuer['vat'] }}</div>
</div>
<div class="party">
<h2>To</h2>
<div class="name">{{ $recipient['name'] }}</div>
<div>{{ $recipient['address'] }}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="num" style="width:80px;">Qty</th>
<th class="num" style="width:140px;">Unit</th>
<th class="num" style="width:140px;">Total</th>
</tr>
</thead>
<tbody>
@foreach ($lines as $line)
<tr>
<td>{{ $line['description'] }}</td>
<td class="num">{{ $line['qty'] }}</td>
<td class="num">£{{ number_format($line['unit'], 2) }}</td>
<td class="num">£{{ number_format($line['total'], 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="totals">
<div class="row"><span>Subtotal</span><span>£{{ number_format($subtotal, 2) }}</span></div>
<div class="row"><span>VAT (20%)</span><span>£{{ number_format($vat, 2) }}</span></div>
<div class="row grand"><span>Total due</span><span>£{{ number_format($total, 2) }}</span></div>
</div>
<footer>
Payment due within 30 days. Bank transfer details on request.
</footer>
</body>
</html>
The wait selector is .totals because that's the last row that gets filled in, so its presence in the DOM is a reliable signal that the template has finished rendering. The font import is set to display=swap, but the wait selector is still required because Inter occasionally falls back if the screenshot fires inside the swap window.
Triggering it from a Mailable looks like this:
class OrderInvoiceMail extends Mailable
{
public function __construct(public Order $order) {}
public function build()
{
$imageUrl = app(TransactionalImageRenderer::class)->render('invoice', [
'reference' => $this->order->reference,
'issued_on' => $this->order->created_at->format('jS F Y'),
'issuer' => config('billing.issuer'),
'recipient' => $this->order->billing_address(),
'lines' => $this->order->lines->toArray(),
'subtotal' => $this->order->subtotal,
'vat' => $this->order->vat,
'total' => $this->order->total,
]);
return $this->view('emails.order-invoice', ['invoice_url' => $imageUrl])
->subject("Your invoice from " . config('billing.issuer.name'));
}
}
In the email Blade view, the invoice is a plain <img> tag pointing at the URL on your own CDN. No attachments. The customer sees the invoice in the email body the moment the message opens.
The receipt, voucher, and product card
The other three follow the same shape. Each has its own Blade view sized for the right viewport. You can find a pre-built receipt template at html2img if you would rather not build the HTML yourself, with companion templates for vouchers and product cards. All four accept JSON and return PNGs at fixed dimensions, which removes the layout work entirely and is what we recommend if your branding has settled.
For comparison, here is what the voucher template needs as input when you call it directly:
Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->post('https://app.html2img.com/api/template/coupon-voucher', [
'brand_name' => 'Coastline Coffee Co',
'discount' => '20% off',
'code' => 'COAST20',
'expires_on' => '2026-06-30',
'terms' => 'One use per customer. Minimum spend £15.',
'brand_colour' => '#0B6B5C',
])
->throw()
->json();
The pre-built invoice template follows the same shape, sized to A4 portrait at 1240 by 1754. The trade-off is the same as anywhere: the template is opinionated about layout, the HTML endpoint gives you full control.
Caching and idempotency
Two things to handle carefully.
Cache key construction. The key has to capture every field that affects the rendered output. Miss one and you serve a stale image. The safest approach is to hash the full data payload plus a template version field. Bumping the version field is your manual cache bust when the template design changes.
private function cacheKey(string $type, array $data): string
{
ksort($data); // stable key order
$hash = md5(json_encode([...$data, '_type' => $type, '_v' => '1']));
return "transactional-images/{$type}/{$hash}.png";
}
The ksort matters more than it looks. Without stable ordering, the same logical data hashed twice gives two different keys, which doubles your API calls and your storage.
Idempotency at the queue layer. A retried job that re-renders the same image is wasted credits. The S3 existence check inside the renderer handles this automatically. The first call writes, every other call reads. You do not need extra plumbing if your queue worker retries on transient failures.
Gotchas
Things that will surprise you the first time.
Tabular numerals. Without font-variant-numeric: tabular-nums, the numbers in your line items shift horizontally row to row. Looks unprofessional. One CSS line, big difference.
Currency formatting. number_format($amount, 2) is fine for GBP and USD, but if you are issuing invoices in multiple currencies, use the actual Intl extension: (new NumberFormatter('en_GB', NumberFormatter::CURRENCY))->formatCurrency($amount, 'EUR'). Saves a future bug.
Image sizes inside the email. Receipts at 600 by 900 are small enough to embed inline. Invoices at 1240 by 1754 are too large; embed a downscaled preview (around 1000 wide) and link to the full image. The renderer can produce both by calling twice with different viewports, but in practice the inline preview is enough and customers only download the full one when they need to print it.
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 will scale down cleanly. A 1240-wide invoice will scale down too small to read. Hence the preview-plus-link pattern above.
Don't reference fonts inside the HTML that the renderer cannot reach. Google Fonts via @import works. Self-hosted webfonts on your own CDN work. Fonts behind authentication, or on a CDN that blocks bots, will silently fall back. If the rendered image's typography looks wrong, the font is the first thing to check.
Picking an approach
The decision tree for the transactional image stack:
-
Low volume, branding stable, designer on call. Stay manual or attach a PDF generated by a library like
dompdf. Don't introduce a new dependency until you need it. - Daily volume, infrastructure work is fine. Browsershot on a queue. Own the Chromium, accept the operational cost, gain the design freedom.
- Any meaningful volume, transactional emails are a product feature. The HTML endpoint with a shared renderer, or the per-template endpoints if your branding has settled. The shared renderer above scales to four document types with one cache layer and one API integration. The version we recommend is the shared renderer with the HTML endpoint for documents that need bespoke layout (invoices, vouchers with specific brand requirements) and the per-template endpoints for documents that fit a standard mould (receipts, product cards). You get the design freedom where it matters and zero layout maintenance where it does not.
If you have already shipped the OG image pattern from the dynamic Open Graph images in Laravel article, the transactional image stack is roughly the same code with different templates. Same API, same caching shape, same Mailable pattern. The work of getting it into production is measured in afternoons, not weeks.
Interested in proving your knowledge of this topic? Take the PHP Fundamentals certification.
PHP Fundamentals
Covering the required knowledge to create and build web applications in PHP.
$99