Render HTML to PDF with the Artisan Chrome renderer
At a glance
Section titled “At a glance”The Artisan bridge renders HTML with a headless Chrome process, then imports
the result into a NextPDF document as a vector Form XObject. Text stays
selectable and searchable instead of being rasterized. You attach a
ChromeRendererConfig, call writeHtmlChrome() on a document, or use
ChromeHtmlRenderer directly, and let Chrome handle layout. This guide covers
the render call, network isolation, page sizing, content height, and the
long-lived renderer lifecycle for a worker.
The prerequisites, up front:
- NextPDF core and
nextpdf/artisanare installed. - A Chrome or Chromium binary is installed, and the worker user can run it
headless. Verify it with
chromium --headless --dump-dom about:blankbefore you start. The Chrome renderer setup page linked under See also covers binary provisioning and the container sandbox decision.
This how-to assumes you can run a Chrome process near the application. For the first runnable example, read the Artisan quickstart.
Install
Section titled “Install”Install the bridge alongside core.
composer require nextpdf/artisanInstall a Chrome or Chromium build that the worker user can run. On Debian or Ubuntu, use the distribution package.
apt-get install -y chromiumConfirm the binary runs headless as the worker user.
chromium --headless --dump-dom about:blankExit code 0 with an empty document object model (DOM) means the binary and its
shared libraries are present. A non-zero exit is the same failure the bridge
surfaces as a ChromeRenderException. Fix it here first.
Conceptual overview
Section titled “Conceptual overview”writeHtmlChrome() is a method on the NextPDF core Document. It validates
input, resolves the Artisan renderer, sends the HTML to Chrome over the Chrome
DevTools Protocol (CDP), parses the returned PDF, and embeds page 0 as a Form
XObject at the current cursor. Chrome runs as a child process of the PHP
worker. The bridge drives Chrome over CDP instead of connecting to a separate
Chrome process over a debugging port, so there is no network endpoint to expose
or authenticate.
The bridge renders with a deny-by-default network posture. Every render uses a
Content-Security-Policy that denies all resource origins (default-src 'none')
and permits only inline images (img-src data:). The bridge also blocks every
subresource URL at the CDP transport layer with
Network.setBlockedURLs(['*']). As a result, a remote image, stylesheet, font,
script, or iframe in your HTML does not load. Inline every asset as a data:
URI. This is how the bridge addresses server-side request forgery (SSRF) risk
when it renders HTML that may be untrusted, and it applies regardless of
configuration.
The page-size model has two modes. When you supply both width and height, in
PDF points, Chrome prints to exactly that paper size. When height is omitted or
null, the bridge measures the rendered content height in Chrome, converts it
to points, and adds a small reflow safety buffer of about 14.4 points. That
keeps printToPDF from spilling onto a second page that the page-0-only
importer would clip.
API surface
Section titled “API surface”// On a NextPDF core Document (the HasTextOutput concern):writeHtmlChrome(string $html, ?float $width = null, ?float $height = null): static
// The standalone renderer:new ChromeHtmlRenderer(ChromeRendererConfig $config, ?LoggerInterface $logger = null)ChromeHtmlRenderer::render(string $html, float $widthPt, float $heightPt = 0.0): ChromeRenderResultChromeHtmlRenderer::close(): void
// The configuration value object (final readonly):new ChromeRendererConfig( ?string $chromeBinaryPath = null, int $renderTimeout = 30, string $defaultCss = '', int $maxHtmlSize = 5_000_000, bool $noSandbox = false,)ChromeRendererConfig::fromArray(array $config): selfChromeRendererConfig is the single configuration surface. It is immutable, so
build a new instance to change a value. ChromeRenderResult::getPdfData() returns the
PDF bytes. The Artisan configuration page linked under See also lists the full
option reference and the fixed Chrome launch flags.
Code sample — Quick start
Section titled “Code sample — Quick start”Attach the config to a document, render trusted HTML, and save.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Artisan\ChromeRendererConfig;use NextPDF\Core\Document;
$config = new ChromeRendererConfig( chromeBinaryPath: '/usr/bin/chromium',);
$document = Document::createStandalone();$document->setChromeRendererConfig($config);$document->addPage();
$document->writeHtmlChrome(' <div style="display: flex; gap: 20px; font-family: sans-serif;"> <div style="flex: 1; background: #f0f0f0; padding: 24px;"> <h2>Revenue</h2> <p style="font-size: 2em; color: #2563eb;">$124,500</p> </div> <div style="flex: 1; background: #f0f0f0; padding: 24px;"> <h2>Orders</h2> <p style="font-size: 2em; color: #16a34a;">1,847</p> </div> </div>');
$document->save('/tmp/report.pdf');Chrome handles the flex layout, and the numbers stay selectable in the output because the page is embedded as a vector Form XObject, not a raster image. To fit a fixed A4 page, pass width and height in points.
$document->writeHtmlChrome($html, width: 595.28, height: 841.89);Code sample — Production
Section titled “Code sample — Production”In production, construct one renderer per worker, inject a PSR-3 logger, catch the two distinct exception types separately, and release the Chrome process deterministically on shutdown.
<?php
declare(strict_types=1);
use NextPDF\Artisan\ChromeHtmlRenderer;use NextPDF\Artisan\ChromeRendererConfig;use NextPDF\Artisan\Exception\ChromeNotAvailableException;use NextPDF\Artisan\Exception\ChromeRenderException;use Psr\Log\LoggerInterface;
final class ReportRenderer{ private ChromeHtmlRenderer $renderer;
public function __construct(LoggerInterface $logger) { $config = ChromeRendererConfig::fromArray([ 'chrome_binary' => getenv('CHROME_BINARY') ?: null, 'render_timeout' => 45, 'max_html_size' => 2_000_000, 'no_sandbox' => (bool) getenv('CHROME_NO_SANDBOX'), ]);
$this->renderer = new ChromeHtmlRenderer($config, $logger); }
public function render(string $html, float $widthPt, float $heightPt = 0.0): string { try { return $this->renderer->render($html, $widthPt, $heightPt)->getPdfData(); } catch (ChromeNotAvailableException $exception) { // Deployment fault: the Chrome runtime is missing. Page on-call. throw $exception; } catch (ChromeRenderException $exception) { // Render-time fault: timeout, crash, or empty output. Retryable once. throw $exception; } }
public function shutdown(): void { $this->renderer->close(); }}Build the renderer once, then reuse it. The underlying browser pool keeps one
Chrome process alive and restarts it every 100 renders to bound memory growth.
The two catch arms separate a deployment fault, such as a missing runtime, from
a render-time fault that you can retry once. Neither catch block is empty. Call
shutdown() when the worker shuts down to release the Chrome process instead
of waiting for the destructor.
Build the config from a framework config array to use snake-case keys, and pin
chromeBinaryPath in production so the binary is deterministic.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Empty HTML is a no-op.
writeHtmlChrome('')returns the document unchanged. - No page yet. If the document has no page,
writeHtmlChrome()adds one before rendering. - Remote assets do not load — by design.
<img src="https://...">renders empty. Inline every asset as adata:URI. This is the network-isolation posture, not a defect. - Only page 0 is imported. Auto-fit height adds the reflow buffer so a single page is produced. With an explicit height, no buffer is added and the output matches the requested paper size exactly, so size the height to fit your content.
- Bridge missing. If
nextpdf/artisanis not installed, core raises a layout exception rather than a fatal error. If thechrome-php/chromelibrary is absent, the bridge raisesChromeNotAvailableExceptionwith the install command. defaultCssand</style>. Any</style>sequence indefaultCssis stripped before injection as a style-breakout defense. Plan around that if you template CSS.
Performance
Section titled “Performance”The first render pays for Chrome startup and layout. Later renders reuse the
live Chrome process, so they rarely pay the startup cost. Construct one
renderer per worker and reuse it. Do not create one per request. Expect a
latency spike on every 100th render, when the bridge restarts the Chrome
process to bound memory. Account for that in your latency objectives instead
of treating it as an incident. Pair renderTimeout with an upstream request
budget on any path reachable by untrusted input.
Security notes
Section titled “Security notes”- Network isolation is the primary control. The bridge permits no outbound
subresource fetch at all: CSP
default-src 'none'plus a CDP transport-level block of every URL. It does not implement a domain allowlist because it needs none. Inline assets asdata:URIs. - Input is bounded before Chrome is contacted. The bridge rejects HTML over
maxHtmlSize(default 5 MB), an oversized base64 data URI (a decompression-bomb guard), and any<meta http-equiv="refresh">tag (which could drive a navigation to an internal endpoint). KeepmaxHtmlSizeat the default unless a known workload needs more. Raising it widens the resource-exhaustion surface. - The Chrome sandbox is a separate control. Setting
noSandbox: truelaunches Chrome with--no-sandbox, which removes Chrome process isolation. That is a real reduction in containment, not a cosmetic flag. Leave itfalseoutside containers. When the container sandbox cannot initialize, run Chrome as a non-root user in a constrained container, and treat the deployment as a higher-trust requirement on the input. - Logs carry metadata only. Inject a PSR-3 logger. The bridge logs byte lengths, dimensions, and lifecycle events, never HTML, PDF bytes, or extracted text.
- Never expose a Chrome remote-debugging port. The bridge does not use one, and an open CDP port is an unauthenticated control channel.
The full threat model, including the SSRF defense, the explicit sandbox boundary, and the failure-mode catalogue, lives on the Artisan security-and-operations page linked under See also. That page pins the relevant OWASP, CWE, and NIST clauses.
Conformance
Section titled “Conformance”This guide makes no normative standards claim of its own. The upstream Artisan security-and-operations page maps the bridge’s network, isolation, and resource-exhaustion controls to OWASP ASVS, the CWE Top 25 (SSRF / uncontrolled resource consumption), and NIST SP 800-53 SC-7. This cookbook page restates the usage and defers those normative citations to that page. The bridge performs no cryptographic operation; signing and encryption are core or commercial-edition concerns and are unaffected by Artisan.
See also
Section titled “See also”- Render at the edge with Cloudflare — render HTML at the edge with local fallback.
- Artisan quickstart — the minimal first render.
- Chrome renderer setup — provision the binary, the container sandbox decision, and a health probe.
- Artisan security and operations — the network-isolation model, the sandbox boundary, and the failure modes.