---
title: "HTML to Image API for Laravel | Blade to Image, Screenshots, PDFs"
description: "Render Blade views to PNG or PDF and capture screenshots in Laravel. Official package with a facade, config, disk storage, queued jobs and an artisan check."
url: "https://html2img.com/integrations/laravel/"
---

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

- **Install:** `composer require html2img/html2img-laravel`
- **Registry:** [Packagist](https://packagist.org/packages/html2img/html2img-laravel)
- **Source:** https://github.com/html2img/html2img-laravel
- **Licence:** MIT
- **Requires:** PHP 8.3 or newer, Laravel 11, 12 or 13

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](https://html2img.com/screenshot-api/).

## Requirements

| Requirement | Version |
| --- | --- |
| PHP | 8.3 or newer |
| Laravel | 11, 12 or 13 |
| API key | Free, from your [dashboard](https://app.html2img.com/register) |

The package depends on the framework-agnostic
[PHP SDK](https://html2img.com/integrations/php/) 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

```bash
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`:

```dotenv
HTML2IMG_API_KEY=your-api-key
```

That is the whole setup. Publish the config file only if you want to change
something in it:

```bash
php artisan vendor:publish --tag=html2img-config
```

### Verify the install

```bash
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`](https://html2img.com/docs/account/) instead.

## Quick start

```php
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:

```php
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:

```php
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:

```php
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:

```blade
{{-- 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:

```php
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
));
```

> **Warning: 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](https://html2img.com/docs/parameters/css/) is applied on top of the document
after it loads, which lets one view produce two themes without a second Blade
file:

```php
$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.

```php
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):

```php
$response = Html2img::screenshot(new ScreenshotRequest(
    url: route('reports.show', $report),
    waitForSelector: '#chart-rendered',
    msDelay: 400,
    width: 1440,
    height: 900,
));
```

Parameter behaviour in full:
[`fullpage`](https://html2img.com/docs/parameters/fullpage/),
[`selector`](https://html2img.com/docs/parameters/selector/),
[`dimensions`](https://html2img.com/docs/parameters/dimensions/),
[`dpi`](https://html2img.com/docs/parameters/dpi/),
[`wait_for_selector`](https://html2img.com/docs/parameters/wait_for_selector/),
[`ms_delay`](https://html2img.com/docs/parameters/ms_delay/).

> **Note: 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.

```php
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:

```php
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](https://html2img.com/docs/parameters/format/) and the
[HTML to PDF API](https://html2img.com/html-to-pdf/) overview.

## Named templates

When the data is structured and the design does not need to be yours, post JSON to
a [named template](https://html2img.com/templates/) instead of writing a view:

```php
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](https://html2img.com/docs/templates/).

## 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.**

```php
// 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](#blade-view-to-image) above).

**3. Generate it in a job**, so publishing never waits on a render.

```php
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.

```php
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:

```blade
@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
```

> **Tip: 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](https://html2img.com/integrations/statamic/) and
> [Craft plugin](https://html2img.com/integrations/craft/) 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.

```php
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:

```php
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:

```php
// 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](https://html2img.com/docs/parameters/webhook-url/).

## 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](https://laravel.com/docs/filesystem) in one line:

```php
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.

> **Tip: 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.

```php
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](https://html2img.com/docs/getting-started/).

## 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:

```php
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;
}
```

```php
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:

```php
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.

```php
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:

```php
$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:

```php
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](https://html2img.com/integrations/php/#package-reference).

**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](#open-graph-images) 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](https://html2img.com/integrations/php/) 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`](https://html2img.com/docs/account/). `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.
