How to Convert HTML to an Image in Laravel with PHP
Render HTML as PNG or JPEG in Laravel with Browsershot, configure captures, troubleshoot failures, and compare hosted ScreenshotNeo options.
Direct answer: render the HTML in a real headless browser, then save the captured pixels. In Laravel, the most direct implementation uses Spatie Browsershot, which controls Puppeteer and headless Chrome. The basic call is Browsershot::html($html)->save($pathToImage).
This approach supports CSS, web fonts, JavaScript, responsive layouts, and the same browser behaviors your users see. It is different from a pure-PHP image library, which cannot faithfully lay out arbitrary modern HTML and CSS.
1. Install the browser-based renderer
Install Browsershot in your Laravel application with Composer:
composer require spatie/browsershot
Browsershot requires the Node.js and Puppeteer/Chrome toolchain described in its installation documentation. Make sure the PHP process that runs Laravel can execute the configured Node and Chrome binaries. In containers, install those dependencies in the image and run the command as the same user used by PHP-FPM or your queue worker.
2. Convert an HTML string to an image
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use Spatie\Browsershot\Browsershot;
class CardController
{
public function image(): Response
{
$html = '<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; font-family: Arial, sans-serif; }
.card { width: 800px; padding: 48px; background: #111827; color: white; }
h1 { margin: 0 0 12px; font-size: 42px; }
</style>
</head>
<body>
<section class="card">
<h1>Hello from Laravel</h1>
<p>This HTML is rendered by Chrome and saved as an image.</p>
</section>
</body>
</html>';
$path = storage_path('app/public/html-image.png');
Browsershot::html($html)
->windowSize(896, 512)
->save($path);
return response()->file($path, [
'Content-Type' => 'image/png',
]);
}
}
The output extension determines the file type in the normal workflow. Use a writable absolute path such as storage_path('app/public/...'), and expose it through Laravel’s filesystem or a controller response.
3. Render a Blade view
Render the view to a string first, then pass that string to Browsershot. Keep external assets reachable from the browser process.
use Illuminate\Support\Facades\View;
use Spatie\Browsershot\Browsershot;
$html = View::make('cards.invoice', [
'invoice' => $invoice,
])->render();
$path = storage_path('app/public/invoices/' . $invoice->id . '.png');
Browsershot::html($html)
->windowSize(1200, 800)
->save($path);
For CSS, fonts, and images, prefer absolute URLs or inline the required styles. A file URL or a relative asset path that works in a normal HTTP request may not resolve from a temporary HTML document. If assets require authentication, provide suitable headers or cookies and ensure the browser process can reach the host.
4. Capture an existing URL
use Spatie\Browsershot\Browsershot;
Browsershot::url('https://example.com')
->windowSize(1440, 900)
->save(storage_path('app/public/example.png'));
URL capture is useful when the page already contains its layout and data. HTML capture is usually easier for generated invoices, certificates, social cards, and other server-created documents.
5. Choose the capture area and image format
| Goal | Browsershot option | Notes |
|---|---|---|
| Fixed viewport | windowSize(width, height) |
Captures the visible browser viewport. |
| Entire document | fullPage() |
Captures the full scrollable page. |
| Rectangle | clip(x, y, width, height) |
Useful for a known pixel region. |
| One element | select('.card') |
Captures the matching element. |
| JPEG | Use the documented JPEG method and quality argument | Smaller files; lossy compression. |
| PNG | Default output | Lossless and suitable for text or transparency. |
Browsershot::html($html)
->windowSize(1200, 900)
->select('.receipt')
->save(storage_path('app/public/receipt.png'));
Browsershot::html($html)
->fullPage()
->save(storage_path('app/public/page.png'));
Use a fixed viewport when the design is tied to a device size. Use fullPage() for long pages, but remember that very tall documents consume more memory and may create large files.
6. Make dynamic pages deterministic
- Use stable test data and a fixed timezone when dates affect layout.
- Wait for required content before saving. A page that loads data after initial HTML can otherwise produce a partially rendered image.
- Ensure web fonts finish loading; fallback fonts can change line wrapping and element height.
- Disable animations or set a deterministic animation state in custom CSS.
- For lazy-loaded images, scroll or otherwise trigger loading before a full-page capture.
Laravel Screenshot provides a Laravel-oriented facade and driver model, with Browsershot as its default path. Its documentation describes waiting for network idle and also documents a Cloudflare Browser Rendering driver that does not require Node.js or a Chrome binary on the Laravel host. Confirm option parity when switching drivers.
7. Laravel Screenshot as a facade-based alternative
Install the package with:
composer require spatie/laravel-screenshot
The package supplies a Laravel facade and configurable drivers for screenshot workflows. The default driver uses Browsershot; the Cloudflare driver moves browser execution to an external Browser Rendering service. See the introduction and setup guide for current configuration names and requirements. Do not assume every Browsershot option is available through every driver.
8. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It accepts HTML-to-image and URL capture options such as viewport, full-page mode, element selectors, custom CSS and JavaScript, waiting rules, cookies, headers, device presets, retina scale, blocking rules, caching, and bulk capture. See the ScreenshotNeo API documentation for the complete parameter list.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', data);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| “Node not found” or browser launch error | PHP cannot find Node, npm, or Chrome. | Install the browser dependencies and configure absolute executable paths for the PHP or queue user. |
| Blank image | HTML is empty, resources failed, or capture happened before rendering. | Log the rendered HTML, use absolute asset URLs, and wait for required content. |
| Missing CSS or fonts | Relative URLs or blocked network access. | Inline critical CSS or use reachable absolute URLs; verify container DNS and outbound access. |
| Images missing | Lazy loading, authentication, or unsupported local paths. | Use public or authenticated asset URLs, provide cookies/headers where appropriate, and trigger lazy loading. |
| Text wraps differently | Different viewport, font fallback, device scale, or browser version. | Set the viewport explicitly, load the intended font, and keep the rendering environment consistent. |
| Permission denied saving file | Storage directory is not writable by the PHP or worker user. | Choose a writable Laravel storage path and correct ownership and permissions. |
| Long capture times out | Slow third-party resources, scripts, or an oversized full-page document. | Remove unnecessary resources, block irrelevant requests, wait for a specific selector, and increase the job timeout carefully. |
10. Performance, reliability, and cost considerations
- Reuse workers: queue image generation for reports or batches so web requests do not wait on Chrome.
- Control page size: capture an element or fixed viewport when a full document is unnecessary.
- Reduce dependencies: inline critical CSS and avoid analytics, ads, and unrelated third-party scripts in generated HTML.
- Store outputs deliberately: use deterministic names for idempotent jobs and object storage for large or long-lived images.
- Handle retries: retry transient browser or network failures, but avoid duplicate records by making jobs idempotent.
- Measure your own workload: rendering time and memory vary with DOM size, fonts, JavaScript, external assets, and concurrency. The cited package documentation does not establish a universal benchmark.
With local Browsershot, your costs and operational work come from the Laravel host, Node.js, Chrome, and queue capacity. A hosted browser driver shifts execution to an external service and introduces network credentials and service availability dependencies. The research sources do not establish comparative pricing, speed, or reliability.
11. Security checklist
- Do not pass untrusted HTML to a privileged browser context without sanitizing it.
- Keep API keys, cookies, and authorization headers out of rendered HTML and client-side logs.
- Restrict screenshot endpoints so attackers cannot use your server to fetch internal network addresses.
- Use separate storage permissions and short-lived URLs for sensitive documents.
- Review third-party scripts because they execute during browser rendering.
12. FAQ
Can PHP convert HTML without Chrome?
It can draw limited markup with specialized libraries, but faithful modern HTML/CSS rendering requires a browser engine. Browsershot uses Puppeteer-controlled headless Chrome.
Should I use PNG or JPEG?
Use PNG for sharp text, transparency, and lossless output. Use JPEG when photographic content and smaller files matter.
How do I capture only one card?
Give the card a stable selector and use Browsershot’s select() method, or use clip() when you need exact coordinates.
Is the Cloudflare driver automatically better?
No universal winner is established by the documentation. It removes the local Node.js and Chrome requirement but depends on external service connectivity and credentials.
Can I generate images asynchronously?
Yes. Dispatch a Laravel queued job that renders the image, stores it, and records the result. For hosted workflows, ScreenshotNeo also supports asynchronous jobs with signed webhooks.


