HTML to Image API for Laravel
The official Laravel package adds the pieces you would otherwise write yourself around the PHP SDK: a service provider, a published config file, a facade, container bindings, an artisan health check, and one-line saving of a render to any filesystem disk.
composer require html2img/html2img-laravel Requires: PHP 8.3 or newer, Laravel 11, 12 or 13. Every account starts with 50 free credits, no card needed.
See also PHP Statamic JavaScript
A Blade view is already a complete HTML document with your fonts, your colours and
your data in it. The Laravel package takes that string, sends it to real Chrome and
hands back a hosted image or PDF, so the design of an Open Graph card or an invoice
lives in resources/views alongside everything else rather than in a canvas
library or a separate design tool.
What you can build
- Open Graph images per model. Render a Blade card on publish, store the URL on the post, and every share looks designed rather than generic.
- Invoices and receipts as PDFs. The same Blade view you show on screen, exported as a vector A4 document you can attach to a Mailable.
- Certificates, tickets and passes rendered when an order completes or a course is finished.
- Dashboard snapshots for email and Slack, because neither will run your charting library but both will show a PNG.
- Screenshots of live URLs for link previews, listing thumbnails or a visual record, through the Screenshot API.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3 or newer |
| Laravel | 11, 12 or 13 |
| API key | Free, from your dashboard |
The package depends on the framework-agnostic PHP SDK and adds the Laravel layer on top: a service provider, a config file, a facade, container bindings, an artisan health check and disk-aware storage helpers.
Installation
composer require html2img/html2img-laravel
The service provider and the Html2img facade register themselves through package
discovery, so there is nothing to add to config/app.php. Add your key to .env:
HTML2IMG_API_KEY=your-api-key
That is the whole setup. Publish the config file only if you want to change something in it:
php artisan vendor:publish --tag=html2img-config
Verify the install
php artisan html2img:test
The command renders a small test image and prints the resulting URL and your
remaining credits, or a clear error if the key is missing or rejected. It performs
a real render, so it costs one credit. To check a key and balance without spending
one, call GET /api/me instead.
Quick start
use Html2img\Laravel\Facades\Html2img;
use Html2img\Request\HtmlRequest;
$response = Html2img::html(new HtmlRequest(
html: view('og.post', ['post' => $post])->render(),
width: 1200,
height: 630,
dpi: 2,
));
return $response->url; // https://i.html2img.com/abc123def456.png
view(...)->render() is the whole trick: Blade produces a string, the package
posts that string, Chrome draws it. Everything below is a variation on that line.
Configuration
The published config/html2img.php reads entirely from the environment:
return [
'api_key' => env('HTML2IMG_API_KEY'),
'base_uri' => env('HTML2IMG_BASE_URI', 'https://app.html2img.com'),
'timeout' => env('HTML2IMG_TIMEOUT', 35),
'storage' => [
'disk' => env('HTML2IMG_DISK'),
],
];
| Variable | Default | Purpose |
|---|---|---|
HTML2IMG_API_KEY | none | Your key, sent as the X-API-Key header |
HTML2IMG_BASE_URI | https://app.html2img.com | API base URI; you rarely need to change this |
HTML2IMG_TIMEOUT | 35 | Request timeout in seconds |
HTML2IMG_DISK | your default disk | Disk used by Html2img::store() |
The 35 second default sits just above the API’s 30 second synchronous render
budget. For captures likely to exceed it, pass a webhookUrl on the request
rather than raising the timeout.
Resolving the client
Three equivalent ways in; pick whichever suits the call site:
use Html2img\Laravel\Facades\Html2img; // the facade
use Html2img\Laravel\Html2img as Manager; // the concrete manager, for injection
// 1. Facade
Html2img::html($request);
// 2. Constructor or method injection: the container binds the manager as a singleton
public function handle(Manager $html2img): void
{
$html2img->html($request);
}
// 3. The underlying SDK client, for anything the manager does not surface
Html2img::client()->html($request);
A custom HTTP client
The package is built on Guzzle. To add retry middleware, request logging or proxy
settings, bind a configured GuzzleHttp\ClientInterface as html2img.http in a
service provider:
use GuzzleHttp\Client;
public function register(): void
{
$this->app->bind('html2img.http', fn () => new Client([
'base_uri' => config('html2img.base_uri'),
'timeout' => config('html2img.timeout'),
// your own handler stack, middleware, proxy settings
]));
}
The package still sends the X-API-Key, Accept and Content-Type headers on
every request.
Blade view to image
POST /api/html takes a complete HTML document. Write it as an ordinary Blade
view with a fixed viewport and inline or linked CSS:
{{-- resources/views/og/post.blade.php --}}
<!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>
* { box-sizing: border-box; }
body {
margin: 0; width: 1200px; height: 630px;
padding: 80px; display: flex; flex-direction: column; justify-content: space-between;
font-family: Inter, system-ui, sans-serif;
background: linear-gradient(160deg, #0e1521, #16233a); color: #fff;
}
h1 { font-size: 64px; line-height: 1.1; margin: 0; font-weight: 800; letter-spacing: -0.02em; }
.meta { display: flex; align-items: center; gap: 16px; font-size: 22px; color: #aeb7c6; }
</style>
</head>
<body>
<h1>{{ $post->title }}</h1>
<div class="meta">
<span>{{ $post->author->name }}</span>
<span>·</span>
<span>{{ $post->published_at->format('j M Y') }}</span>
</div>
</body>
</html>
Then render it:
use Html2img\Laravel\Facades\Html2img;
use Html2img\Request\HtmlRequest;
$response = Html2img::html(new HtmlRequest(
html: view('og.post', ['post' => $post])->render(),
width: 1200,
height: 630,
dpi: 2, // retina: the file comes back 2400x1260
));
Chrome runs on our servers, so asset('logo.png') pointing at
http://my-app.test resolves to nothing and renders blank. Use secure_asset()
against a public domain in production, and in local development either reference
publicly hosted assets, inline them as data URIs, or expose the site with a tunnel
and point APP_URL at it while you iterate. Google Fonts always work, because
they are already public.
Injecting CSS after load
The css parameter is applied on top of the document
after it loads, which lets one view produce two themes without a second Blade
file:
$html = view('og.post', ['post' => $post])->render();
$dark = Html2img::html(new HtmlRequest(html: $html, css: 'body { background: #0f172a; color: #fff }', width: 1200, height: 630));
$light = Html2img::html(new HtmlRequest(html: $html, css: 'body { background: #fff; color: #0f172a }', width: 1200, height: 630));
Website screenshots
POST /api/screenshot captures a live URL in real Chrome.
use Html2img\Laravel\Facades\Html2img;
use Html2img\Request\ScreenshotRequest;
// Viewport capture
$response = Html2img::screenshot(new ScreenshotRequest(
url: $listing->website_url,
width: 1200,
height: 630,
));
// Whole scroll length
$response = Html2img::screenshot(new ScreenshotRequest(
url: route('pricing'),
width: 1400,
fullpage: true,
));
// One element, cropped to its bounding box
$response = Html2img::screenshot(new ScreenshotRequest(
url: route('pricing'),
selector: '#plans',
width: 1400,
));
// Hide the cookie banner and chat widget first
$response = Html2img::screenshot(new ScreenshotRequest(
url: $listing->website_url,
css: '.cookie-banner, .intercom-launcher { display: none !important; }',
width: 1440,
height: 900,
dpi: 2,
));
For pages that finish rendering after load, hold the capture with
waitForSelector (returns as soon as the element exists) or msDelay (always
waits the full duration):
$response = Html2img::screenshot(new ScreenshotRequest(
url: route('reports.show', $report),
waitForSelector: '#chart-rendered',
msDelay: 400,
width: 1440,
height: 900,
));
Parameter behaviour in full:
fullpage,
selector,
dimensions,
dpi,
wait_for_selector,
ms_delay.
A capture is an anonymous request from the public internet, so authenticated
routes come back as your login page. Either render the Blade view directly with
Html2img::html(), which needs no HTTP round trip at all, or expose a signed
route with URL::temporarySignedRoute() and screenshot that.
Blade view to PDF
Set format to Format::Pdf and the same view comes back as an A4 portrait
vector PDF: selectable text, embedded fonts and automatic pagination, for the same
single credit.
use Html2img\Enum\Format;
use Html2img\Laravel\Facades\Html2img;
use Html2img\Request\HtmlRequest;
$response = Html2img::html(new HtmlRequest(
html: view('invoices.show', ['invoice' => $invoice])->render(),
format: Format::Pdf,
));
$path = Html2img::store($response, "invoices/{$invoice->number}.pdf");
Attaching it to a Mailable is then one line:
use Illuminate\Mail\Mailables\Attachment;
public function attachments(): array
{
return [
Attachment::fromStorageDisk('s3', "invoices/{$this->invoice->number}.pdf")
->as("invoice-{$this->invoice->number}.pdf")
->withMime('application/pdf'),
];
}
width, height, dpi, fullpage and selector are ignored in PDF mode, since
the page size is fixed. See the format docs and the
HTML to PDF API overview.
Named templates
When the data is structured and the design does not need to be yours, post JSON to a named template instead of writing a view:
use Html2img\Laravel\Facades\Html2img;
$response = Html2img::template('invoice-image', [
'invoice_number' => $invoice->number,
'business_name' => config('app.name'),
'client_name' => $invoice->client->name,
'items' => $invoice->items->map(fn ($item) => [
'description' => $item->description,
'quantity' => (string) $item->quantity,
'unit_price' => $item->unit_price_formatted,
'amount' => $item->amount_formatted,
])->all(),
'total' => $invoice->total_formatted,
]);
return $response->url;
Templates output PNG only, and their payloads are validated server-side: an
invalid field throws a ValidationException whose details() names it. Inputs
for each template are in the template reference.
Open Graph images
This is the pattern most Laravel apps arrive for. Four parts: a column, a Blade card, a queued job and a dispatch on publish.
1. Store the result on the model.
// database/migrations/xxxx_add_og_image_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
$table->string('og_image_path')->nullable();
});
2. Design the card as resources/views/og/post.blade.php (see
Blade view to image above).
3. Generate it in a job, so publishing never waits on a render.
namespace App\Jobs;
use App\Models\Post;
use Html2img\Exception\Html2imgException;
use Html2img\Laravel\Html2img;
use Html2img\Request\HtmlRequest;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerateOgImage implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public Post $post) {}
public function handle(Html2img $html2img): void
{
$response = $html2img->html(new HtmlRequest(
html: view('og.post', ['post' => $this->post])->render(),
width: 1200,
height: 630,
dpi: 2,
));
$this->post->update([
'og_image_path' => $html2img->store($response, "og/{$this->post->id}.png"),
]);
}
public function failed(Html2imgException $e): void
{
report($e);
// The post keeps whatever image it had; a render failure never blocks publishing.
}
}
4. Dispatch it when the card’s inputs change, not on every save. Rendering an unchanged card spends a credit for an identical file.
namespace App\Observers;
use App\Jobs\GenerateOgImage;
use App\Models\Post;
class PostObserver
{
public function saved(Post $post): void
{
if (! $post->isDirty(['title', 'excerpt', 'author_id', 'published_at'])) {
return;
}
GenerateOgImage::dispatch($post)->afterCommit();
}
}
5. Output the tag in your layout:
@if ($post->og_image_path)
<meta property="og:image" content="{{ Storage::url($post->og_image_path) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
@endif
Hashing the fields the card actually shows, storing the digest alongside the path and skipping the job when it matches turns a busy editorial workflow from one credit per save into one credit per meaningful change. This is exactly what the Statamic addon and Craft plugin do internally.
Queues and background jobs
Renders belong on a queue: a full-page capture can take several seconds, and a web request should not wait for it. The example above is the shape; two details matter.
Dispatch after commit. ->afterCommit() (or after_commit on the queue
connection) stops a worker picking the job up before the transaction that created
the record has landed.
Retry the right failures. A 5xx or a connection failure is worth retrying; a
ValidationException is not, because the same request will fail the same way.
use Html2img\Exception\ServerException;
use Html2img\Exception\ConnectionException;
use Html2img\Exception\ValidationException;
public array $backoff = [10, 60, 300];
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(10);
}
public function handle(Html2img $html2img): void
{
try {
// ... render
} catch (ValidationException $e) {
// Permanent: stop retrying and record why.
$this->fail($e);
} catch (ServerException | ConnectionException $e) {
// Transient: let the queue retry with backoff.
throw $e;
}
}
For captures that will not finish inside the 30 second synchronous budget, use webhook delivery instead of a long-running job:
use Html2img\Laravel\Facades\Html2img;
use Html2img\Request\ScreenshotRequest;
$response = Html2img::screenshot(new ScreenshotRequest(
url: $report->public_url,
fullpage: true,
webhookUrl: route('hooks.html2img'),
));
if ($response->isProcessing()) {
$render->update(['status' => 'pending', 'render_id' => $response->id]);
}
Then handle the callback on an unauthenticated, CSRF-exempt route:
// routes/web.php
Route::post('/hooks/html2img', HandleHtml2imgWebhook::class)
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class])
->name('hooks.html2img');
The payload shape is documented in the
webhook_url reference.
Storage and filesystem disks
The API returns a hosted CDN URL rather than bytes, so the cheapest option is to
store the URL and serve it directly. When you would rather own the file,
store() downloads it and writes it to any
filesystem disk in one line:
use Html2img\Laravel\Facades\Html2img;
$response = Html2img::html(new HtmlRequest(html: $document, width: 1200, height: 630));
// Uses the HTML2IMG_DISK disk, or your application's default disk
$path = Html2img::store($response, "og/{$post->id}.png");
// Or target a disk explicitly
Html2img::store($response, "og/{$post->id}.png", 's3');
// Raw bytes, without storing
$bytes = Html2img::download($response);
// Both helpers accept a URL string as well as a response
Html2img::store('https://i.html2img.com/abc123.png', 'thumbnails/abc123.png');
store() returns the stored path, which is what you keep on the model. Pair it
with Storage::url() when you render the tag.
$response->expiresAt carries an ISO 8601 timestamp on the free tier and is
null on paid plans, where renders are hosted permanently. Storing to a disk
means the free-tier expiry never reaches your pages. Upgrading also makes
everything you rendered earlier permanent.
Error handling
Every failure throws an Html2img\Exception\Html2imgException. No raw Guzzle
exception escapes the package.
use Html2img\Exception\Html2imgException;
use Html2img\Exception\InsufficientCreditsException;
use Html2img\Exception\TimeoutException;
use Html2img\Exception\ValidationException;
use Html2img\Laravel\Facades\Html2img;
try {
$response = Html2img::html(new HtmlRequest(html: $document, width: 1200, height: 630));
} catch (ValidationException $e) {
foreach ($e->details() as $field => $messages) {
logger()->warning("html2img rejected {$field}", $messages);
}
} catch (InsufficientCreditsException $e) {
// 402: keep the existing image and notify someone
logger()->error('html2img out of credits', ['remaining' => $e->creditsRemaining()]);
} catch (TimeoutException $e) {
// 504: re-send with a webhookUrl rather than retrying synchronously
} catch (Html2imgException $e) {
report($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 values are caught before a request leaves your server: a width
above 5000 throws InvalidArgumentException rather than spending a credit on a
render the API would reject. The status-code reference is in the
getting started guide.
Testing
The package resolves its HTTP client from the container, so binding a Guzzle instance with a mock handler keeps your suite off the network and off your credit balance:
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
protected function fakeHtml2img(array $payload = [], int $status = 200): MockHandler
{
$mock = new MockHandler([
new Response($status, [], json_encode($payload + [
'success' => true,
'id' => 'test-render',
'url' => 'https://i.html2img.com/test.png',
'credits_remaining' => 49,
])),
]);
$this->app->bind('html2img.http', fn () => new Client([
'handler' => HandlerStack::create($mock),
]));
return $mock;
}
it('stores an Open Graph image when a post is published', function () {
$this->fakeHtml2img();
Storage::fake('public');
$post = Post::factory()->create();
(new GenerateOgImage($post))->handle(app(\Html2img\Laravel\Html2img::class));
expect($post->fresh()->og_image_path)->toBe("og/{$post->id}.png");
});
Html2img::store() and Html2img::download() use the Http facade internally,
so Http::fake() covers the download leg:
Http::fake(['i.html2img.com/*' => Http::response('fake-png-bytes')]);
To assert on the job itself rather than the render, Queue::fake() and
Queue::assertPushed(GenerateOgImage::class) is usually enough.
Without the package
If you would rather not add a dependency, the Http facade covers the same
ground. This is the shape the package wraps.
use Illuminate\Support\Facades\Http;
// config/services.php
// 'html2img' => ['key' => env('HTML2IMG_API_KEY')],
$response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->timeout(35)
->acceptJson()
->post('https://app.html2img.com/api/html', [
'html' => view('og.post', ['post' => $post])->render(),
'width' => 1200,
'height' => 630,
'dpi' => 2,
])
->throw();
$url = $response->json('url');
Read the key through config() rather than env() at runtime: once
php artisan config:cache has run, env() returns null outside the config files.
Screenshots, PDFs and templates are the same call with a different path or body:
$shot = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->post('https://app.html2img.com/api/screenshot', ['url' => $url, 'fullpage' => true])
->throw()->json('url');
$pdf = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->post('https://app.html2img.com/api/html', [
'html' => view('invoices.show', ['invoice' => $invoice])->render(),
'format' => 'pdf',
])
->throw()->json('url');
$card = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
->post('https://app.html2img.com/api/v1/templates/invoice-image', [
'invoice_number' => $invoice->number,
'total' => $invoice->total_formatted,
])
->throw()->json('url');
Storing the result is then a second request:
Storage::disk('s3')->put("og/{$post->id}.png", Http::get($url)->body());
What you give up: typed request objects, local range checks, one exception
hierarchy instead of RequestException, the store() and download() helpers,
and php artisan html2img:test.
Package reference
Facade Html2img\Laravel\Facades\Html2img, which proxies the
Html2img\Laravel\Html2img manager (bound as a singleton, and as html2img):
| Method | Signature |
|---|---|
html() | html(HtmlRequest $request): RenderResponse |
screenshot() | screenshot(ScreenshotRequest $request): RenderResponse |
template() | template(string $slug, array $data = []): RenderResponse |
download() | download(RenderResponse|string $image): string |
store() | store(RenderResponse|string $image, string $path, ?string $disk = null): string |
client() | client(): Html2imgClient |
Request objects come from the SDK. 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. Null options are omitted, so
the server applies its own default.
Html2img\Enum\Format is Format::Png or Format::Pdf.
Html2img\Response\RenderResponse exposes success, id, url,
expiresAt, creditsRemaining, status, message, template,
isProcessing() and raw(). Property meanings are documented on the
PHP integration page.
Artisan: php artisan html2img:test renders a test image (one credit) and
reports the URL and credits remaining.
Publish tag: php artisan vendor:publish --tag=html2img-config.
Troubleshooting
config('html2img.api_key') is null in production. Almost always
config:cache running before the environment variable existed, or the key being
read through env() outside a config file. Set HTML2IMG_API_KEY, then re-run
php artisan config:cache.
Images and fonts are missing from the render. Chrome fetches them over the
public internet, so anything on .test, .ddev.site, localhost or a private
network is invisible. Point APP_URL at a public host or a tunnel while testing,
or inline small assets as data URIs. Google Fonts always work.
A screenshot of my own app returns the login page. Captures are anonymous.
Render the Blade view directly with Html2img::html(), or expose a
URL::temporarySignedRoute() for the capture.
The job never runs. Check that a worker is running for the queue the job is
pushed to, and that QUEUE_CONNECTION is not still sync in the environment you
are testing. ->afterCommit() also defers dispatch until the surrounding
transaction commits, which looks like “nothing happened” inside a test wrapped in
a transaction.
Every save spends a credit. Guard the dispatch with isDirty() on the fields
the card actually shows, as in the
Open Graph section above.
Html2img::store() writes to the wrong place. With HTML2IMG_DISK unset it
uses your application’s default disk. Set the variable, or pass the disk as the
third argument.
A ConnectionException on a fresh container. Outbound HTTPS to
app.html2img.com needs to be allowed and the image needs CA certificates; a
slim base image with an empty /etc/ssl/certs is a common cause.
TimeoutException on a full-page capture. The render exceeded the 30 second
synchronous budget. Send it again with a webhookUrl rather than raising
HTML2IMG_TIMEOUT.
FAQ
Do I need the PHP SDK as well?
No. html2img/html2img-laravel depends on html2img/html2img-php, so Composer
installs it for you. The PHP page documents the underlying
client if you want to reach past the facade with Html2img::client().
Can I render a Blade component or a Livewire view?
Anything that produces a string works: view()->render(),
Blade::render($string, $data), or Blade::renderComponent(). Livewire’s
runtime JavaScript will not run meaningfully in a one-shot render, so render the
underlying Blade view rather than the Livewire wrapper.
Which Laravel versions are supported? Laravel 11, 12 and 13, on PHP 8.3 or newer.
Does it work on Vapor or Lambda? Yes. There is no browser binary and no local filesystem requirement; it is one outbound HTTPS request. That is the main reason to use a rendering API on serverless in the first place.
Is a PDF more expensive than an image? No. One render is one credit, whether it comes back as a PNG or as a multi-page PDF.
How do I check credits without spending one?
Call GET /api/me. php artisan html2img:test performs a real
render and does cost a credit.
Can I use this from a package or a Nova resource?
Yes. Resolve the manager from the container rather than using the facade
(app(\Html2img\Laravel\Html2img::class)), which keeps it testable and avoids
depending on the alias being registered.
Related integrations
The same API and the same key across every stack. These are the neighbours of the Laravel integration.
Start rendering from Laravel
50 free credits, no card required. One credit renders one image or one PDF.