Official package

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

RequirementVersion
PHP8.3 or newer
Laravel11, 12 or 13
API keyFree, 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'),
    ],
];
VariableDefaultPurpose
HTML2IMG_API_KEYnoneYour key, sent as the X-API-Key header
HTML2IMG_BASE_URIhttps://app.html2img.comAPI base URI; you rarely need to change this
HTML2IMG_TIMEOUT35Request timeout in seconds
HTML2IMG_DISKyour default diskDisk 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>&middot;</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
));
Asset URLs must be publicly reachable

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.

Screenshotting your own app

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
Guard the render with a fingerprint

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.

Free-tier renders expire after seven days

$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(),
    ]);
}
ExceptionRaised on
AuthenticationException401, missing or invalid API key
InsufficientCreditsException402, no credits remaining
NotSubscribedException403, no active subscription
NotFoundException404, for example an unknown template slug
ValidationException400 or 422, with details() per field
TimeoutException408 or 504, the render budget was exceeded
ServerException5xx, an unexpected renderer error
ConnectionExceptionThe request never reached a response
Html2imgExceptionBase 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):

MethodSignature
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.

Start rendering from Laravel

50 free credits, no card required. One credit renders one image or one PDF.