HTML to Image API for PHP
The official PHP SDK wraps the HTML to Image API in a small typed client built on Guzzle: render HTML you control, screenshot a live URL, or fill a named template, each returning one response object and throwing one exception hierarchy.
composer require html2img/html2img-php Requires: PHP 8.3 or newer. Every account starts with 50 free credits, no card needed.
The API is a plain REST service, so any PHP application can call it with cURL. The
official SDK exists because the parts that are tedious to hand-roll (building the
request body, mapping eleven status codes onto something you can catch, and
keeping the option ranges honest) are the same in every project. Both routes are
covered below: the SDK first, raw cURL and Guzzle at the end.
What you can build
- Open Graph and social images for every page, post or product, rendered from a PHP template and cached as a URL on the record.
- Invoices, receipts, credit notes and statements as PNGs for the account screen, or as vector PDFs for the emailed copy.
- Certificates, tickets and passes generated on completion or purchase.
- Product and listing thumbnails rendered once at import and re-served from your own CDN.
- Website screenshots through the Screenshot API, for link previews, directory listings or a visual record of a page.
If your data already fits one of the named templates you can skip writing markup entirely and post JSON instead.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3 or newer |
| ext-curl | Required by Guzzle, which the SDK is built on |
| Guzzle | ^7.0, installed for you by Composer |
| API key | Free, from your dashboard |
The SDK is framework-agnostic. On Laravel, install the Laravel package instead: it wraps this client and adds a facade, config file and storage helpers. Statamic, Craft CMS and WordPress each have their own integration built on the same foundation.
Installation
composer require html2img/html2img-php
Keep the key in the environment, never in the repository:
HTML2IMG_API_KEY=your-api-key
The key travels as an X-API-Key header on every request. Issuing and rotating
keys is covered in the authentication docs.
Quick start
Three lines to a rendered image:
<?php
require __DIR__ . '/vendor/autoload.php';
use Html2img\Html2imgClient;
use Html2img\Request\HtmlRequest;
$client = new Html2imgClient(getenv('HTML2IMG_API_KEY'));
$response = $client->html(new HtmlRequest(
html: '<!doctype html><html><body><h1>Hello from PHP</h1></body></html>',
width: 1200,
height: 630,
));
echo $response->url; // https://i.html2img.com/abc123def456.png
The API returns a JSON envelope containing the CDN URL of the render, not the image bytes. Store the URL against your record and serve it directly, or download a copy (see downloading and storing).
There is no Chrome binary, no shared memory tuning and no Puppeteer version to keep in step with your PHP version. The rendering happens on our infrastructure; your application makes one HTTP request.
HTML to image
POST /api/html. Send a complete HTML document and get back an image of the
rendered result. Inline your CSS in a <style> block, or pull in remote
stylesheets and web fonts with <link> tags in the document head.
use Html2img\Request\HtmlRequest;
$document = <<<HTML
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;800&display=swap">
<style>
body { margin: 0; width: 1200px; height: 630px; display: flex;
align-items: center; padding: 80px; box-sizing: border-box;
font-family: Inter, system-ui, sans-serif; background: #0f172a; color: #fff; }
h1 { font-size: 68px; line-height: 1.1; margin: 0; font-weight: 800; }
</style>
</head>
<body><h1>{$post->title}</h1></body>
</html>
HTML;
$response = $client->html(new HtmlRequest(
html: $document,
width: 1200,
height: 630,
dpi: 2, // retina: the file comes back 2400x1260
));
Rendering from a PHP template
Most applications already have a templating layer. Capture its output and hand the string to the SDK. With plain PHP includes:
function renderToString(string $template, array $data = []): string
{
extract($data, EXTR_SKIP);
ob_start();
require $template;
return (string) ob_get_clean();
}
$response = $client->html(new HtmlRequest(
html: renderToString(__DIR__ . '/templates/og-card.php', ['post' => $post]),
width: 1200,
height: 630,
dpi: 2,
));
Twig, Latte, Plates and Blade all expose the same shape: a method that returns the rendered string. Anything a browser can draw works, because a browser is what draws it.
The renderer fetches your fonts, images and stylesheets from its own servers, so
http://localhost/logo.png resolves to nothing and comes out blank. Reference
absolute public URLs, inline small images as data URIs, or expose your
development site through a tunnel while you iterate.
Injecting CSS after load
The css parameter is applied after the document loads,
on top of whatever the markup already carries. On the HTML endpoint it is mostly
useful for theming one document two ways:
$dark = $client->html(new HtmlRequest(
html: $document,
css: 'body { background: #0f172a; color: #fff; }',
width: 1200,
height: 630,
));
$light = $client->html(new HtmlRequest(
html: $document,
css: 'body { background: #fff; color: #0f172a; }',
width: 1200,
height: 630,
));
Website screenshots
POST /api/screenshot captures a live, publicly reachable URL. Use
selector to crop to a single element and
css to hide anything you do not want in the frame.
use Html2img\Request\ScreenshotRequest;
// Viewport capture at a fixed size
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://example.com',
width: 1200,
height: 630,
));
// The whole scroll length of the page
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://example.com/pricing',
width: 1400,
fullpage: true,
));
// One element, cropped to its own bounding box
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://example.com/pricing',
selector: '#plans',
width: 1400,
));
// Hide a cookie banner and a chat widget before capturing
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://example.com',
css: '.cookie-banner, .intercom-launcher { display: none !important; }',
width: 1440,
height: 900,
dpi: 2,
));
Page styles usually win on specificity, so injected rules generally need
!important. Two parameters control timing: waitForSelector returns as soon as
an element exists, msDelay always waits the full duration. Prefer the first
wherever you control the markup.
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://your-app.example.com/reports/42',
waitForSelector: '#chart-rendered',
msDelay: 400,
width: 1440,
height: 900,
));
Full behaviour for each option lives in the parameter reference:
fullpage,
selector,
dimensions,
dpi,
wait_for_selector and
ms_delay.
HTML to PDF
Set format to Format::Pdf on either request object and the same markup comes
back as an A4 portrait vector PDF: text stays selectable and searchable, web fonts
are embedded, and long content paginates automatically. One credit, the same as an
image.
use Html2img\Enum\Format;
use Html2img\Request\HtmlRequest;
$response = $client->html(new HtmlRequest(
html: renderToString(__DIR__ . '/templates/invoice.php', ['invoice' => $invoice]),
format: Format::Pdf,
));
echo $response->url; // https://i.html2img.com/....pdf
width, height, dpi, fullpage and selector are ignored in PDF mode,
because the page size is fixed. The
format parameter docs cover the rest, and the
HTML to PDF API page has the overview.
The API’s scale_to_fit parameter, which shrinks
a layout wider than the page rather than cropping it, is not exposed on the PHP
SDK’s request objects. If you need it, send that one request with
raw cURL or Guzzle.
Named templates
POST /api/v1/templates/{slug} renders one of the named templates
from a JSON payload, with no markup of your own. Data is validated server-side per
template, and templates output PNG only.
$response = $client->template('invoice-image', [
'invoice_number' => 'INV-2026-0042',
'business_name' => 'Coastline Coffee Co',
'client_name' => 'Riverside Bakery',
'items' => [
['description' => 'Wholesale beans', 'quantity' => '50', 'unit_price' => '£15.00', 'amount' => '£750.00'],
],
'total' => '£750.00',
]);
echo $response->template; // invoice-image
echo $response->url;
PHP applications most often reach for invoice, receipt, certificate, business card, coupon and event ticket templates. Each one’s inputs are documented in the template reference.
Downloading and storing
The response carries a hosted URL rather than bytes, which is usually what you want: store the URL on the record and let the CDN serve it. When you would rather keep your own copy, download it like any other file.
// Simplest: straight to disk
file_put_contents(
__DIR__ . "/storage/og/{$post->id}.png",
file_get_contents($response->url),
);
With Guzzle, which the SDK already brings in, and streaming so a long PDF never sits in memory:
use GuzzleHttp\Client;
$http = new Client();
$http->get($response->url, ['sink' => __DIR__ . "/storage/og/{$post->id}.png"]);
Or hand the bytes to an object store:
$s3->putObject([
'Bucket' => 'my-bucket',
'Key' => "og/{$post->id}.png",
'Body' => file_get_contents($response->url),
'ContentType' => 'image/png',
]);
$response->expiresAt is an ISO 8601 timestamp on the free tier and null on
paid plans, where renders are hosted permanently. If you are on the free tier and
the image needs to outlive that window, download it. Upgrading makes everything
you rendered earlier permanent too.
Configuration
Everything beyond the API key is optional:
use Html2img\Html2imgClient;
$client = new Html2imgClient(
apiKey: getenv('HTML2IMG_API_KEY'),
baseUri: 'https://app.html2img.com', // default
timeout: 35.0, // seconds, default
);
The 35 second default sits just above the API’s 30 second synchronous render
budget. For captures likely to exceed it, pass a webhookUrl rather than raising
the timeout (see background rendering).
Reusing your own Guzzle client
Pass a pre-configured GuzzleHttp\ClientInterface to reuse your own middleware,
retry strategy, proxy settings or logging. The SDK still sets the X-API-Key,
Accept and Content-Type headers on every request:
use GuzzleHttp\Client;
use Html2img\Html2imgClient;
$guzzle = new Client([
'base_uri' => 'https://app.html2img.com',
'timeout' => 60,
// your own handler stack, middleware, proxy settings
]);
$client = new Html2imgClient(getenv('HTML2IMG_API_KEY'), httpClient: $guzzle);
A service class
In a framework-free application, wrapping the client in a small service keeps the API key resolution and your house defaults in one place:
namespace App\Imaging;
use Html2img\Html2imgClient;
use Html2img\Request\HtmlRequest;
use Html2img\Response\RenderResponse;
final class SocialImages
{
public function __construct(private readonly Html2imgClient $client) {}
public static function fromEnvironment(): self
{
$key = getenv('HTML2IMG_API_KEY');
if ($key === false || $key === '') {
throw new \RuntimeException('HTML2IMG_API_KEY is not set.');
}
return new self(new Html2imgClient($key));
}
/** Every social card in the application is 1200x630 at 2x. */
public function card(string $document): RenderResponse
{
return $this->client->html(new HtmlRequest(
html: $document,
width: 1200,
height: 630,
dpi: 2,
));
}
}
Background rendering
A synchronous request holds the connection open until the render finishes, within a 30 second budget. Two ways to keep that off a web request:
Render in a worker. Whatever queue you already run (Symfony Messenger, a database-backed job table, a cron-driven command) can call the client and write the resulting URL back to the record. Nothing in the SDK is queue-specific.
Let the API call you back. Pass a webhookUrl and the API responds
immediately with status: "processing" and a null url, then POSTs the finished
URL to your endpoint:
$response = $client->screenshot(new ScreenshotRequest(
url: 'https://example.com/very-long-report',
fullpage: true,
webhookUrl: 'https://your-app.example.com/hooks/html2img',
));
if ($response->isProcessing()) {
// The final URL arrives at your webhook, not on this response.
$render->update(['status' => 'pending', 'render_id' => $response->id]);
}
The payload shape and delivery guarantees are in the
webhook_url docs.
Error handling
Every failure throws an Html2img\Exception\Html2imgException. Catch that one
type to handle anything, or catch a subclass to react per case. No raw Guzzle
exception escapes the client.
use Html2img\Exception\Html2imgException;
use Html2img\Exception\InsufficientCreditsException;
use Html2img\Exception\TimeoutException;
use Html2img\Exception\ValidationException;
try {
$response = $client->html(new HtmlRequest(html: $document, width: 1200, height: 630));
} catch (ValidationException $e) {
// 400 or 422: the request was malformed. Retrying will not help.
foreach ($e->details() as $field => $messages) {
$logger->warning("html2img rejected {$field}: " . implode(', ', $messages));
}
} catch (InsufficientCreditsException $e) {
// 402: out of credits. Keep the old image and tell someone.
$logger->error('html2img out of credits', ['remaining' => $e->creditsRemaining()]);
} catch (TimeoutException $e) {
// 504: the render exceeded the sync budget. Re-send with a webhookUrl.
} catch (Html2imgException $e) {
$logger->error('html2img failed', [
'status' => $e->statusCode(),
'code' => $e->errorCode(),
'body' => $e->payload(),
]);
}
| Exception | Raised on |
|---|---|
AuthenticationException | 401, missing or invalid API key |
InsufficientCreditsException | 402, no credits remaining |
NotSubscribedException | 403, no active subscription |
NotFoundException | 404, for example an unknown template slug |
ValidationException | 400 or 422, with details() per field |
TimeoutException | 408 or 504, the render budget was exceeded |
ServerException | 5xx, an unexpected renderer error |
ConnectionException | The request never reached a response |
Html2imgException | Base type for all of the above |
Out-of-range option values are caught locally, before a request leaves your
server: a width above 5000 or a dpi above 4 throws
InvalidArgumentException rather than spending a credit on a render the API would
reject. Retry 5xx and connection failures; do not retry a 4xx, since the same
request will fail the same way. The full status-code reference is in the
getting started guide.
Testing
The client’s only seam is the Guzzle instance, so a mocked handler keeps your test suite off the network and off your credit balance:
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Html2img\Html2imgClient;
use Html2img\Request\HtmlRequest;
$mock = new MockHandler([
new Response(200, [], json_encode([
'success' => true,
'id' => 'test-render',
'url' => 'https://i.html2img.com/test.png',
'credits_remaining' => 49,
])),
]);
$client = new Html2imgClient(
'test-key',
httpClient: new Client(['handler' => HandlerStack::create($mock)]),
);
$response = $client->html(new HtmlRequest(html: '<h1>Hi</h1>'));
assert($response->url === 'https://i.html2img.com/test.png');
Queue a 402 response the same way to exercise your out-of-credits path. To check a
real key and balance without spending a credit, call
GET /api/me; the testing guide covers
reproducing each error condition against the live API.
Without the SDK
If you would rather not add a dependency, the API is four fields and a header.
Raw cURL
function html2img(string $endpoint, array $payload): array
{
$ch = curl_init("https://app.html2img.com/api/{$endpoint}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 35,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . getenv('HTML2IMG_API_KEY'),
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException("html2img request failed: {$error}");
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
if ($status !== 200 || empty($result['success'])) {
throw new RuntimeException(sprintf(
'html2img returned %d: %s',
$status,
$result['message'] ?? $result['error'] ?? 'unknown error',
));
}
return $result;
}
// HTML to image
$render = html2img('html', [
'html' => $document,
'width' => 1200,
'height' => 630,
'dpi' => 2,
]);
// Screenshot
$shot = html2img('screenshot', [
'url' => 'https://example.com',
'fullpage' => true,
]);
// PDF, with the scale_to_fit option the SDK does not expose
$pdf = html2img('html', [
'html' => $document,
'format' => 'pdf',
'scale_to_fit' => true,
]);
echo $render['url'];
Named templates use the same helper with a different path:
$ch = curl_init('https://app.html2img.com/api/v1/templates/invoice-image');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . getenv('HTML2IMG_API_KEY'),
],
CURLOPT_POSTFIELDS => json_encode([
'invoice_number' => 'INV-2026-0042',
'business_name' => 'Coastline Coffee Co',
'client_name' => 'Riverside Bakery',
'total' => '£750.00',
]),
]);
$url = json_decode(curl_exec($ch), true)['url'];
Guzzle
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use RuntimeException;
final class Html2imgClient
{
private Client $http;
public function __construct(string $apiKey)
{
$this->http = new Client([
'base_uri' => 'https://app.html2img.com/api/',
'timeout' => 35,
'headers' => [
'X-API-Key' => $apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]);
}
public function renderHtml(string $html, array $options = []): string
{
return $this->post('html', ['html' => $html] + $options);
}
public function screenshot(string $url, array $options = []): string
{
return $this->post('screenshot', ['url' => $url] + $options);
}
public function template(string $slug, array $data): string
{
return $this->post("v1/templates/{$slug}", $data);
}
private function post(string $path, array $payload): string
{
try {
$response = $this->http->post($path, ['json' => $payload]);
} catch (BadResponseException $e) {
$body = json_decode((string) $e->getResponse()->getBody(), true) ?: [];
throw new RuntimeException(sprintf(
'html2img returned %d: %s',
$e->getResponse()->getStatusCode(),
$body['message'] ?? $body['error'] ?? 'unknown error',
), 0, $e);
}
return json_decode((string) $response->getBody(), true)['url'];
}
}
This is the shape the SDK gives you for free, plus typed request objects, typed exceptions and local range checks.
Package reference
Html2img\Html2imgClient
| Member | Signature |
|---|---|
| Constructor | __construct(string $apiKey, string $baseUri = 'https://app.html2img.com', float $timeout = 35.0, ?ClientInterface $httpClient = null) |
html() | html(HtmlRequest $request): RenderResponse |
screenshot() | screenshot(ScreenshotRequest $request): RenderResponse |
template() | template(string $slug, array $data = []): RenderResponse |
Request objects. Html2img\Request\HtmlRequest takes html plus css,
width, height, fullpage, dpi, webhookUrl, msDelay, waitForSelector
and format. Html2img\Request\ScreenshotRequest takes url plus the same
options and additionally selector. Every option except the first is nullable and
omitted from the request when null, so the server applies its own default. Full
ranges and behaviour are in the parameter reference.
Html2img\Enum\Format is Format::Png or Format::Pdf.
Html2img\Response\RenderResponse is readonly:
| Property | Type | Meaning |
|---|---|---|
success | bool | Whether the API reported success |
id | string|null | The render id |
url | string|null | CDN URL of the render, null while an async job is pending |
expiresAt | string|null | ISO 8601 expiry on the free tier, null on paid plans |
creditsRemaining | int|null | Credits left after this call |
status | string|null | "processing" for accepted async jobs |
message | string|null | Human-readable message, when provided |
template | string|null | Template slug, on template renders |
isProcessing() | bool | Whether the job is still rendering |
raw() | array | The full decoded JSON payload |
Troubleshooting
HTML2IMG_API_KEY is empty at runtime. getenv() reads the process
environment, which is not the same as a .env file. If you use vlucas/phpdotenv
or similar, make sure it is loaded before the client is constructed, and remember
that PHP-FPM does not inherit your shell’s environment.
Images and fonts are missing from the render. The renderer fetches them from
the public internet. localhost, .test, .ddev.site and private network
addresses are invisible to it. Use absolute public URLs, inline small assets as
data URIs, or tunnel your development site while you iterate.
The output is blank or half-drawn. The capture happened before the content
did. Add waitForSelector pointing at something the finished page contains, or
msDelay as a fallback for anything inside an iframe, which selectors cannot see.
ValidationException on the templates endpoint. Each template validates its
own payload. details() names the field and the reason; check the template’s
inputs in the template reference.
TimeoutException on full-page captures. A very long page can exceed the 30
second synchronous budget. Send the same request with a webhookUrl and handle
the callback rather than raising the client timeout.
Injected CSS has no effect. Page rules usually win on specificity. Add
!important, and confirm the selector matches on the live page rather than in
your local copy.
A ConnectionException in a container. Outbound HTTPS to
app.html2img.com needs to be allowed, and the container needs CA certificates.
An empty /etc/ssl/certs in a slim base image is a common cause.
FAQ
Should I use the SDK or plain cURL? Use the SDK unless you have a policy against adding dependencies. It is one small package on top of Guzzle, and the parts it replaces (status-code mapping, request building, range checks) are the parts most often got wrong by hand. Both routes hit the same endpoints and cost the same.
Does this work without Composer? The SDK requires Composer, because it requires Guzzle. The raw cURL helper above has no dependencies at all and works in any PHP 8 codebase.
Can I render a Blade, Twig or Latte view?
Yes. The SDK takes a string, so anything that renders to a string works. On
Laravel, use the Laravel package, where
view(...)->render() drops straight into the request object.
Do I get the image bytes back? No, you get a hosted URL. That is deliberate: most applications want to store a URL rather than proxy bytes, and downloads from the CDN are free and unmetered. See downloading and storing if you want a local copy.
Is one PDF page one credit? No. One render is one credit, however many pages the PDF turns out to be, and a PDF costs exactly the same as an image.
How do I check my balance without spending a credit?
Call GET /api/me. It reports your plan and remaining credits
and never renders anything.
Is there a synchronous size or time limit?
A synchronous render has a 30 second budget. Anything likely to exceed it should
pass a webhookUrl and be handled asynchronously.
Related integrations
The same API and the same key across every stack. These are the neighbours of the PHP integration.
Start rendering from PHP
50 free credits, no card required. One credit renders one image or one PDF.