h4cc/wkhtmltoimage-amd64
Static compiled wkhtmltoimage binary for 64-bit Linux/amd64, packaged for Composer. Install h4cc/wkhtmltoimage-amd64 at version 0.12.4 to get the wkhtmltoimage executable in vendor/bin, ready for HTML-to-image conversion.
1. Install the binary package (pin the version — there is nothing newer than 0.12.4):
composer require h4cc/wkhtmltoimage-amd64:"0.12.4"
The tag is the binary version, so use an exact constraint. ^0.12 or * buys you nothing since the last release was 2018-01-15.
2. Know where the binary lives after install:
vendor/h4cc/wkhtmltoimage-amd64/bin/wkhtmltoimage-amd64
vendor/bin/wkhtmltoimage-amd64 # composer-generated symlink
3. Look first at: the binary's own --help, then the wkhtmltopdf.org docs. This package ships no PHP code, no service provider, no config — it is a Composer-delivered executable. Everything you configure is either (a) an env var pointing at the path, or (b) options passed on the command line.
4. First use case — render a Blade view to a PNG for an OG/social card:
vendor/bin/wkhtmltoimage-amd64 --format png --width 1200 --height 630 \
--quality 90 file:///tmp/card.html /tmp/card.png
Then wire it into Laravel. If you want a PHP API instead of shelling out, pair it with barryvdh/laravel-snappy and point Snappy's image driver at the binary:
// config/snappy.php
'image' => [
'enabled' => true,
'binary' => env('WKHTMLTOIMAGE_BINARY', base_path('vendor/h4cc/wkhtmltoimage-amd64/bin/wkhtmltoimage-amd64')),
'timeout' => 60,
'options' => [],
],
// app code
$path = storage_path('app/cards/'.$order->id.'.png');
SnappyImage::loadView('cards.order', ['order' => $order])
->setOption('width', 1200)
->setOption('height', 630)
->save($path);
Run chmod +x vendor/bin/wkhtmltoimage-amd64 once and confirm vendor/bin/wkhtmltoimage-amd64 --version prints 0.12.4 before writing any application code — if the binary can't run, nothing else matters.
Wrap it behind an interface, not a call site. The binary is 8 years stale; you will want to swap it for Browsershot/Puppeteer or the official wkhtmltopdf build later without touching controllers.
interface HtmlImageRenderer
{
public function render(string $html, array $options = []): string; // returns temp path
}
Pattern A — view → image via Snappy (best for internal templates you control):
public function render(string $html, array $options = []): string
{
$tmp = tempnam(sys_get_temp_dir(), 'wkhtml').'.png';
SnappyImage::loadHTML($html)
->setOptions(array_merge([
'format' => 'png',
'width' => 1200,
'height' => 630,
'quality' => 90,
'disable-smart-width' => true, // otherwise --width is a hint, not a rule
'quiet' => true,
], $options))
->save($tmp);
return $tmp;
}
Pattern B — direct process (no extra dependency, full control over exit codes and stderr):
use Illuminate\Support\Facades\Process;
$htmlPath = tempnam(sys_get_temp_dir(), 'wkhtml').'.html';
file_put_contents($htmlPath, $html);
$result = Process::timeout(45)->run([
config('services.wkhtmltoimage.binary'),
'--format', 'png',
'--width', '1200',
'--quality', '90',
'--enable-local-file-access', // only when the HTML references local assets
'file://'.$htmlPath,
$outPath,
]);
if ($result->failed()) {
throw new ImageRenderException($result->errorOutput());
}
Pass arguments as an array so Laravel/Symfony escapes them — never concatenate user-influenced strings into a shell command.
Always queue it. wkhtmltoimage is CPU- and memory-heavy (a 1200×630 render of a JS chart page is a few hundred ms; a page with slow remote assets can take tens of seconds). Keep it out of the request cycle:
final class GenerateCardImage implements ShouldQueue
{
public int $timeout = 120; // must exceed the Process/Snappy timeout
public int $tries = 3;
public array $backoff = [10, 60];
public function __construct(public int $orderId) {}
public function handle(HtmlImageRenderer $renderer, FilesystemManager $fs): void
{
$order = Order::findOrFail($this->orderId);
$tmp = $renderer->render(view('cards.order', compact('order'))->render());
$fs->disk('s3')->put("cards/{$order->id}.png", fopen($tmp, 'r'), 'private');
@unlink($tmp);
}
}
Serving the result: stream from the private disk through a controller rather than putting images at guessable public URLs; or store publicly if the asset is genuinely public (OG cards).
Rendering authenticated pages: wkhtmltoimage has no session/cookie sharing with your app. Don't try to render route('order.show', $order) — it'll capture your login page. Instead, create a dedicated render route that takes a short-lived signed URL and renders a stripped-down layout:
URL::temporarySignedRoute('render.order', now()->addMinutes(5), ['order' => $order->id]);
Asset handling: the renderer runs from /tmp, not your app root, so relative asset() paths resolve against nothing. Two reliable options:
<style> and images as data: URIs (also removes network flakiness).file:// paths to public/ and pass --enable-local-file-access.Docker/multi-stage deploys: copy the binary out of a build stage or install via Composer at build time, then verify the +x bit survives. A common shortcut is a config env var so you can point at a system-installed binary in prod:
'binary' => env('WKHTMLTOIMAGE_BINARY', base_path('vendor/h4cc/wkhtmltoimage-amd64/bin/wkhtmltoimage-amd64')),
Pair with the PDF sibling. Most apps that need images also need PDFs — install h4cc/wkhtmltopdf-amd64 alongside and let Snappy's pdf and image configs share the option arrays.
Architecture lock-in. This is amd64 only. On Apple Silicon laptops, ARM CI runners, or Graviton EC2 the package installs fine and then fails at runtime with "Exec format error". Guard your tests:
if (php_uname('m') !== 'x86_64') {
$this->markTestSkipped('wkhtmltoimage-amd64 binary requires x86_64');
}
For local dev on ARM, prefer running the renderer in a linux/amd64 container or switch drivers.
Missing shared libraries. The build is "static compiled" in the sense that it's a self-contained binary, but it still needs system libs for fonts and X. On a slim Debian/Ubuntu image you will typically need:
RUN apt-get update && apt-get install -y --no-install-recommends \
libxrender1 libxext6 libfontconfig1 libjpeg62-turbo libx11-6 \
fonts-dejavu-core fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
Symptom of missing libs: exit code 127 or a silent empty/0-byte output file. Symptom of missing fonts: correct layout with blank boxes for CJK or emoji. Install the fonts your templates actually use.
Exit code 0 does not mean the image is good. wkhtmltoimage can write a 0-byte file while returning success when the page errors. Always validate:
if (!is_file($outPath) || filesize($outPath) < 1024) {
throw new ImageRenderException('Empty or truncated render');
}
Also pass --quiet to stop it spamming stderr, but capture errorOutput() anyway for logs.
Exact pixel dimensions. --width alone lets the webkit engine grow the output. Combine with --disable-smart-width (and --height if you need a fixed crop) when dimensions must match an OG spec or a thumbnail grid.
Timeouts and hangs. A template pointing at a slow third-party font or analytics script will hang the render. Use all three: Process::timeout(), the queue job's $timeout set higher than the process timeout, and --javascript-delay 250 (or --window-status ready) so it waits for charts to paint instead of racing them.
Transparent PNGs. --transparent gives you an alpha background for PNG. There is no per-pixel alpha for JPEG, so don't offer it there.
Security — this is the big one. wkhtmltoimage renders untrusted HTML with local file access and outbound network access. If a user can influence the HTML you feed it, they can:
file:///etc/passwd or your .env and see it rendered into the image.http://169.254.169.254/... (cloud metadata) or internal services.Mitigations: never render raw user HTML — render your Blade template with escaped user data. If you must render user HTML, run the binary in a container with no network, no bind mounts, and a read-only rootfs, and drop --enable-local-file-access.
Version rot. 0.12.4 (2018) predates most modern CSS and JS. No flexbox gap, no CSS grid in practice, no modern JS by default. Templates that look fine in Chrome will look wrong here. Keep render templates deliberately old-school (tables, floats, inline styles) and pin them with a golden-image test. If you find yourself fighting it, that's the signal to move to Browsershot/Chromium — keep the HtmlImageRenderer interface so the migration is one class.
Permissions after deploy. git archive, some CI artifacts, and COPY --from in Docker can strip the executable bit. Add a post-deploy chmod +x vendor/bin/wkhtmltoimage-amd64 (or a composer post-install script) and health-check it.
Debugging recipe. When a render looks wrong, reproduce outside Laravel with the exact same flags, then bisect the option list. --debug-javascript shows script errors; --log-level debug shows resource load failures. Nine times out of ten it's a missing absolute asset path, a font not installed, or a JS timeout.
Testing. Don't assert on image pixels in CI. Assert that a file exists, is non-trivial in size, and has the expected dimensions via getimagesize(). Snapshot/golden-image comparisons across machines will flake on font rendering differences.
Extension points. There is nothing to extend inside the package — the extension points are all yours: the binary path (env), the option defaults (config), the renderer interface (swap drivers), and the queue job (retries/memory limits). Set --enable-local-file-access per-call rather than globally so you can keep the safe default for user-derived content.
How can I help you explore Laravel packages today?