If your PHP app already builds a receipt in HTML, you can turn that same template into an image. There's no need to redraw it with GD and position every line of text by hand.
PHP builds the HTML, a browser renders it, and you save the pixels as a PNG. That only works if your server can actually run a browser, though, and in PHP that's often the deciding question. So we'll answer it first.
The example is a pickup receipt for a bakery, generated from an order array. We'll capture it with Chrome PHP, show the shorter Browsershot version, and then send the same HTML to HTML/CSS to Image for servers that can't run Chrome at all.
Can Your Server Run Chrome?
Plenty of PHP apps run on shared hosting or a managed platform where you can't install system packages, let alone a full browser. Answer this before you pick a library:
- You control the server (a VPS, a container or a dedicated worker): use Chrome PHP. It controls Chrome directly from PHP, so Chrome is the only extra dependency.
- You control the server and already run Node.js: Spatie Browsershot is also a good choice, especially if you already use other Spatie packages. It wraps Puppeteer, so it needs Node.js and Puppeteer installed too. Check its requirements before you commit to it.
- You can't install anything: skip to step 5 and use the HCTI client. It only needs PHP and Composer.
Whichever you choose, the template in step 2 stays the same. You'll need PHP 8.2 or newer either way.
Note
Older PHP answers often point at wkhtmltoimage or PhantomJS. The wkhtmltopdf/wkhtmltoimage repository is archived, and PhantomJS development is suspended. Neither is a good place to start a new project.
1. Install Chrome PHP
composer require chrome-php/chrome
Install Chrome or Chromium on the machine that runs the script. Chrome PHP checks CHROME_PATH first and then looks in the usual places. If it can't find the browser, set CHROME_PATH or pass the path in: new BrowserFactory('/path/to/chrome').
2. Build a Receipt from PHP Data
Save this as order-template.php. Instead of echoing the receipt, it returns the HTML as a string, which is what both renderers need.
<?php
declare(strict_types=1);
$order = [
'number' => 'PK-1048',
'customer' => 'Alex & Morgan',
'pickup' => 'Today, 4:30–5:00 PM',
'items' => [
['name' => 'Sourdough loaf', 'quantity' => 2, 'unit_cents' => 850],
['name' => 'Olive & rosemary focaccia', 'quantity' => 1, 'unit_cents' => 1200],
],
];
$escape = static fn (string $value): string =>
htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$money = static fn (int $cents): string => '$' . number_format($cents / 100, 2);
$totalCents = array_sum(array_map(
static fn (array $item): int => $item['quantity'] * $item['unit_cents'],
$order['items'],
));
ob_start();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pickup receipt <?= $escape($order['number']) ?></title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, sans-serif; color: #233b34; background: #fff; }
.receipt-frame { width: 520px; padding: 24px; background: #edf3ed; }
.receipt { padding: 32px; background: #fffefa; border: 1px solid #dce6dc; border-radius: 14px; }
.eyebrow { margin: 0 0 10px; font-size: 12px; font-weight: 700; letter-spacing: 2px; }
h1 { margin: 0 0 24px; font-size: 30px; }
.pickup { padding: 18px; background: #e9f3e7; border-radius: 8px; }
.pickup p { margin: 0; font-size: 14px; line-height: 1.6; }
.pickup strong { display: block; font-size: 18px; }
table { width: 100%; margin: 24px 0 14px; border-collapse: collapse; font-size: 14px; }
th { padding-bottom: 12px; color: #65746d; font-size: 11px; text-transform: uppercase; }
td { padding: 12px 0; border-top: 1px solid #e7ece5; line-height: 1.5; }
th:first-child, td:first-child { text-align: left; }
th:last-child, td:last-child { text-align: right; white-space: nowrap; }
.quantity { color: #65746d; }
.total { display: flex; justify-content: space-between; padding-top: 18px; border-top: 2px solid #233b34; font-size: 20px; font-weight: 700; }
.footer { margin: 24px 0 0; color: #65746d; font-size: 12px; line-height: 1.6; }
</style>
</head>
<body>
<main class="receipt-frame">
<article class="receipt">
<p class="eyebrow">WILLOW BAKERY · <?= $escape($order['number']) ?></p>
<h1>Your order is ready</h1>
<div class="pickup">
<p>Pickup for <?= $escape($order['customer']) ?></p>
<strong><?= $escape($order['pickup']) ?></strong>
</div>
<table>
<thead><tr><th>Item</th><th>Amount</th></tr></thead>
<tbody>
<?php foreach ($order['items'] as $item): ?>
<tr>
<td><span class="quantity"><?= $item['quantity'] ?> ×</span> <?= $escape($item['name']) ?></td>
<td><?= $money($item['quantity'] * $item['unit_cents']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="total"><span>Paid</span><span><?= $money($totalCents) ?></span></div>
<p class="footer">Bring your order number to the pickup counter.<br>18 Willow Street · Open until 6 PM</p>
</article>
</main>
</body>
</html>
<?php
return (string) ob_get_clean();
That's ordinary PHP templating: output buffering, <?= ?> tags and a foreach. If you have an existing receipt view, it probably already looks something like this.
Money is kept as integer cents and only formatted at the end, which saves you from floating-point surprises in the total. Every piece of text goes through htmlspecialchars(), so Alex & Morgan and Olive & rosemary focaccia come out as text, not broken markup.
Here's the receipt rendered with HCTI:

The pale green border around the receipt comes from .receipt-frame's padding. We'll crop to the frame, so it's included. If you cropped to .receipt instead, it would disappear. Margins don't count, because they sit outside the element's box.
You can download the PHP template and the HTML it generates.
3. Render the Receipt with Chrome PHP
Save this as render-receipt.php in the same directory as the template and vendor/:
<?php
declare(strict_types=1);
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Clip;
use HeadlessChromium\Page;
require __DIR__ . '/vendor/autoload.php';
$html = require __DIR__ . '/order-template.php';
$browser = (new BrowserFactory())->createBrowser([
'windowSize' => [640, 800],
]);
try {
$page = $browser->createPage();
$page->setHtml($html, 10_000, Page::LOAD);
$page->evaluate('document.fonts.ready.then(() => true)')
->getReturnValue(10_000);
$box = $page->evaluate(<<<'JS'
(() => {
const frame = document.querySelector('.receipt-frame');
if (!frame) throw new Error('Receipt frame not found');
const rect = frame.getBoundingClientRect();
return {
x: rect.x + window.scrollX,
y: rect.y + window.scrollY,
width: rect.width,
height: rect.height,
};
})()
JS)->getReturnValue();
$page->screenshot([
'format' => 'png',
'captureBeyondViewport' => true,
'clip' => new Clip($box['x'], $box['y'], $box['width'], $box['height']),
])->saveToFile(__DIR__ . '/pickup-receipt.png');
} finally {
$browser->close();
}
php render-receipt.php
That writes pickup-receipt.png. You can also download render-receipt.php.
Chrome PHP doesn't have a one-line "screenshot this selector" call, so the script measures .receipt-frame in the page and passes that rectangle as the clip. The nice side effect is that a longer order produces a taller image automatically. There's no crop height to keep up to date. The Chrome PHP README covers setHtml(), evaluate() and the screenshot options.
If the selector is missing, the script throws instead of saving a screenshot of the wrong thing, and the finally block closes Chrome either way.
Capturing the Whole Document
To capture the whole page, replace the measurement and the clipped screenshot with:
$page->screenshot([
'format' => 'png',
'captureBeyondViewport' => true,
'clip' => $page->getFullPageClip(),
])->saveToFile(__DIR__ . '/receipt-page.png');
That includes the white space around the receipt. The window size still matters in both cases, because it decides how responsive CSS and text wrapping behave. How to Take a Screenshot of a Specific HTML Element covers padding, shadows and scrollable containers.
The Browsershot Version
If you went the Browsershot route, its image API has a select() method that crops to a selector, so it's much shorter:
<?php
use Spatie\Browsershot\Browsershot;
require __DIR__ . '/vendor/autoload.php';
$html = require __DIR__ . '/order-template.php';
Browsershot::html($html)
->windowSize(640, 800)
->select('.receipt-frame')
->save(__DIR__ . '/pickup-receipt.png');
Swap select() for fullPage() to capture the whole document. The template doesn't change.
4. Wait for Logos, Fonts and QR Codes
The sample receipt has no images and uses a system font, so there's nothing to wait for. A real receipt usually has a logo, maybe a brand font, and often a QR code from another service. Every one of those can still be loading when the screenshot runs.
First, make sure the browser can find them. HTML passed to setHtml() has no base URL, so src="/images/logo.png" won't point at your site. Use absolute HTTPS URLs instead.
Then replace the font wait in render-receipt.php with a check that covers images too:
$assetsReady = $page->evaluate(<<<'JS'
(async () => {
await document.fonts.ready;
const images = document.querySelectorAll('.receipt-frame img');
for (const image of images) {
await image.decode();
}
return true;
})().catch(() => false)
JS)->getReturnValue(10_000);
if ($assetsReady !== true) {
throw new RuntimeException('A required receipt image failed to load');
}
image.decode() waits until each image can be drawn and rejects if one is broken. The script turns that rejection into false so PHP can throw a clear error. If the QR code fails to load, you want the job to fail, not to send a receipt with a blank square on it.
document.fonts.ready waits for fonts to finish loading. It still resolves if a font fails and the browser falls back to another one, so check your output if the brand font matters.
If JavaScript adds the QR code after the page loads, wait for that first. When the check runs, the list of images might still be empty, and an empty list passes immediately. How to Wait for a Page to Finish Rendering covers that case.
5. Render on a Server That Can't Run Chrome
If you're on shared hosting, or you just don't want to look after a browser in production, send the HTML to HTML/CSS to Image instead. Your PHP template still runs in your app, and we only ever see the HTML string it produces.
Install the typed PHP client:
composer require html-css-to-image/client
Set HCTI_API_ID and HCTI_API_KEY as environment variables using the credentials from your dashboard.
Save this as render-receipt-hcti.php:
<?php
declare(strict_types=1);
use HtmlCssToImage\HtmlCssToImageClient;
use HtmlCssToImage\ImageFormat;
use HtmlCssToImage\Request\CreateHtmlCssImageRequest;
use HtmlCssToImage\Response\CreateImageSuccessResponse;
require __DIR__ . '/vendor/autoload.php';
$html = require __DIR__ . '/order-template.php';
$client = HtmlCssToImageClient::fromEnvironment();
$result = $client->createImage(new CreateHtmlCssImageRequest(
html: $html,
selector: '.receipt-frame',
viewportWidth: 640,
viewportHeight: 800,
deviceScale: 1,
format: ImageFormat::PNG,
));
if (!$result instanceof CreateImageSuccessResponse) {
$message = $result->message ?? $result->error;
throw new RuntimeException("HCTI HTTP {$result->statusCode}: {$message}");
}
echo $result->url . PHP_EOL;
Run php render-receipt-hcti.php and it prints the image URL. The script is also available to download.
The client uses PHP named arguments like viewportWidth and maps them to the API's parameter names. selector crops to .receipt-frame, just like the Chrome PHP clip. An API error comes back as a different response type, which is why the script checks for CreateImageSuccessResponse before reading url. Network failures throw exceptions, so make sure your job runner catches those.
Save the URL with the order. Anyone who has the link can open the image, so treat it as carefully as the receipt itself, and don't post it anywhere public.
If your receipt is built by JavaScript, add renderWhenReady: true and call ScreenshotReady() in the page once it's finished. The readiness docs explain both that and the URL-screenshot equivalent.
For more PHP examples, see HTML to PNG in PHP, the PHP API documentation and the parameter reference.
Frequently Asked Questions
Can PHP's GD Library Convert HTML to an Image?
No. GD can draw text, shapes and other images onto a canvas, but it has no idea what HTML or CSS is. It's fine for a simple badge where you control every coordinate. As soon as you want text to wrap or a table to lay itself out, you need a browser.
Can I Use a Laravel Blade Template?
Yes. Render the view to a string with $html = view('receipts.pickup', ['order' => $order])->render();, then pass $html to Chrome PHP, Browsershot or CreateHtmlCssImageRequest. Blade's {{ ... }} output already escapes text. You don't need a public receipt route, because you're rendering the HTML yourself.
Should I Generate the Image During Checkout?
Probably not. Even a fast render adds noticeable time to a request, and a slow logo download would add more. Put it on a queue instead (a Laravel queued job, or whatever your framework uses). Mark the order as placed straight away, and attach the image once the job finishes.
