Skip to content

Render HTML to PDF with the Artisan Chrome renderer

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/artisan are installed.
  • A Chrome or Chromium binary is installed, and the worker user can run it headless. Verify it with chromium --headless --dump-dom about:blank before 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 the bridge alongside core.

Terminal window
composer require nextpdf/artisan

Install a Chrome or Chromium build that the worker user can run. On Debian or Ubuntu, use the distribution package.

Terminal window
apt-get install -y chromium

Confirm the binary runs headless as the worker user.

Terminal window
chromium --headless --dump-dom about:blank

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

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.

// 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): ChromeRenderResult
ChromeHtmlRenderer::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): self

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

Attach the config to a document, render trusted HTML, and save.

render-quickstart.php
<?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.

explicit A4 page size
$document->writeHtmlChrome($html, width: 595.28, height: 841.89);

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.

ReportRenderer.php
<?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.

  • 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 a data: 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/artisan is not installed, core raises a layout exception rather than a fatal error. If the chrome-php/chrome library is absent, the bridge raises ChromeNotAvailableException with the install command.
  • defaultCss and </style>. Any </style> sequence in defaultCss is stripped before injection as a style-breakout defense. Plan around that if you template CSS.

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.

  • 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 as data: 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). Keep maxHtmlSize at the default unless a known workload needs more. Raising it widens the resource-exhaustion surface.
  • The Chrome sandbox is a separate control. Setting noSandbox: true launches Chrome with --no-sandbox, which removes Chrome process isolation. That is a real reduction in containment, not a cosmetic flag. Leave it false outside 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.

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.