PHP HTML to PDF libraries compared
Most PHP projects start with a Composer package that renders HTML inside the PHP process. That is convenient: no extra service, nothing to deploy. The catch is that those engines implement their own subset of CSS, so a layout that looks right in the browser can come out quite differently in the PDF. Here is how the usual choices compare.
| Option | How it works | Worth knowing |
|---|---|---|
| Dompdf | Pure PHP; parses HTML and CSS and lays out the page itself | Easy to install, mostly CSS 2.1 with some CSS3; no flexbox, grid or JavaScript; long tables can be slow and memory hungry |
| mPDF | Pure PHP, grown from the FPDF family, with its own HTML parser | Strong UTF-8 and right-to-left text support; CSS support is partial and floats or positioning can surprise you |
| TCPDF | Draws pages in PHP code, with a writeHTML() method for a small HTML subset | Very complete PDF feature set for code-built layouts; its author points new work to the successor project tc-lib-pdf |
| Snappy (wkhtmltopdf) | A PHP wrapper that shells out to the wkhtmltopdf binary | Better CSS than the pure PHP engines, but the wkhtmltopdf repository was archived in January 2023 and its WebKit is old |
| CastPDF | One HTTPS request; current Chromium plus print features runs on our servers | One network call per document and a monthly plan beyond the free allowance |
If your PDFs are one-page letters with simple styling and Dompdf already handles them, there is no reason to change. The pain usually starts with documents that are long and data driven, such as the shipping manifest used on this page: 80 parcels, a header row that must reappear on every page, totals that must not split from their table, and a designer who styled everything with flexbox. That is the point where an engine with full browser CSS and real pagination saves days.
Your first PDF with Guzzle
Create a test key in the dashboard (it starts with cpdf_test_) and expose it to PHP as the environment variable CASTPDF_API_KEY. Test renders cost nothing and never touch your allowance; they simply carry a watermark. Then run composer require guzzlehttp/guzzle and save the script below as manifest.php.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
$html = <<<'HTML'
<h1>Shipping manifest {{ manifest.number }}</h1>
<p>Carrier: {{ manifest.carrier }}, collected {{ manifest.collected | format_date: "long" }}</p>
<table>
<thead><tr><th>Parcel</th><th>Destination</th><th>Weight (kg)</th></tr></thead>
<tbody>
{% for parcel in parcels %}
<tr><td>{{ parcel.tracking }}</td><td>{{ parcel.city }}</td><td>{{ parcel.kg | number: 1 }}</td></tr>
{% endfor %}
</tbody>
</table>
HTML;
$client = new Client(['timeout' => 75, 'connect_timeout' => 10, 'http_errors' => false]);
$response = $client->post('https://api.castpdf.com/v1/pdf', [
'headers' => ['Authorization' => 'Bearer ' . getenv('CASTPDF_API_KEY')],
'json' => [
'html' => $html,
'data' => [
'manifest' => ['number' => 'MAN-2026-1003', 'carrier' => 'Northline Freight', 'collected' => '2026-10-03'],
'parcels' => [
['tracking' => 'PK-48210', 'city' => 'Lyon', 'kg' => 12.4],
['tracking' => 'PK-48211', 'city' => 'Antwerp', 'kg' => 3.75],
['tracking' => 'PK-48212', 'city' => 'Graz', 'kg' => 27],
],
],
'mode' => 'print',
'filename' => 'manifest-MAN-2026-1003',
],
]);
if ($response->getStatusCode() !== 200) {
$error = json_decode((string) $response->getBody(), true)['error'] ?? [];
throw new RuntimeException(($error['code'] ?? 'unknown') . ': ' . ($error['message'] ?? ''));
}
file_put_contents('manifest.pdf', (string) $response->getBody());
echo 'Saved manifest.pdf with ' . $response->getHeaderLine('x-pages') . ' page(s)' . PHP_EOL;A few details make this work. The nowdoc (<<<'HTML', with quotes) stops PHP from reading anything in the markup as a variable. The json option makes Guzzle encode the array and set Content-Type: application/json. Setting http_errors to false lets you read the JSON error body yourself instead of catching a ClientException. Because the request includes data, CastPDF treats the HTML as a Liquid template and expands the {% for %} loop into one row per parcel. "mode": "print" turns on the print engine, so the thead repeats when the manifest runs onto page two.
Blade and Twig use the same braces
Liquid shares the {{ }} syntax with Blade and Twig. If the HTML lives in a Blade view, write @{{ parcel.city }} so Blade leaves it alone; in Twig, wrap the Liquid parts in {% verbatim %}. Or render the view to finished HTML in PHP and send it without data, so CastPDF uses it exactly as it is.
Fill a saved template with plain cURL
Not every PHP host lets you add Composer packages, and the cURL extension ships with almost every PHP build. The next script uses nothing but ext-curl and ext-json. It also stops carrying HTML in code: the manifest design lives in a saved template in the dashboard, where you edit HTML and CSS with a live preview and operations staff can adjust the sample values in Simple mode. Your PHP sends the template ID plus the day's data and asks for a signed link instead of the file.
<?php
declare(strict_types=1);
$manifestNumber = 'MAN-2026-1004';
$payload = [
'template_id' => getenv('MANIFEST_TEMPLATE_ID'),
'data' => [
'manifest' => ['number' => $manifestNumber, 'carrier' => 'Northline Freight', 'collected' => '2026-10-04'],
'depot' => ['name' => 'Duisburg Hub', 'dock' => 'D7'],
'parcels' => [
['tracking' => 'PK-48302', 'city' => 'Rotterdam', 'kg' => 8.2],
['tracking' => 'PK-48303', 'city' => 'Basel', 'kg' => 15.6],
],
],
'filename' => 'manifest-' . $manifestNumber,
'response' => 'url',
];
$ch = curl_init('https://api.castpdf.com/v1/pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 75,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('CASTPDF_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: manifest-' . $manifestNumber,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status !== 200) {
throw new RuntimeException("{$body['error']['code']}: {$body['error']['message']}");
}
printf("%s (%d pages), valid until %s\n", $body['url'], $body['pages'], $body['expires_at']);The Idempotency-Key header names the document by its manifest number. If a cron run dies halfway and starts again, the repeated request returns the PDF that was already made, and you are not charged twice. The data array replaces the template's sample data in full, so send every field the template reads, including nested ones such as depot.dock.
The JSON reply holds id, pages, bytes, url and expires_at. Anyone with the link can download the file until it expires, without your key, so it is safe to put in an email or a redirect. Store the id in your database: GET /v1/pdf/:id hands out a fresh link later. The API reference describes both calls field by field.
Error handling and retries in PHP
A failed request always answers with a JSON body shaped like {"error": {"code", "message", "docs_url"}}. Build your logic on code, which never changes, and show message to people. Three situations are worth retrying: 429 rate_limited (wait the seconds in the Retry-After header), 503 service_busy (back off exponentially) and network failures, which Guzzle reports as a ConnectException (that includes timeouts). A 409 idempotency_conflict means the first request with the same key is still rendering, so a later retry is safe as well. Any other 4xx describes the request itself and will fail the same way again.
<?php
declare(strict_types=1);
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
final class CastPdfException extends RuntimeException
{
public function __construct(public readonly int $status, public readonly string $errorCode, string $message)
{
parent::__construct("{$status} {$errorCode}: {$message}");
}
}
function renderPdf(Client $client, array $payload, string $idempotencyKey, int $attempts = 5): string
{
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
$response = $client->post('https://api.castpdf.com/v1/pdf', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('CASTPDF_API_KEY'),
'Idempotency-Key' => $idempotencyKey,
],
'json' => $payload,
'http_errors' => false,
'timeout' => 75,
'connect_timeout' => 10,
]);
} catch (ConnectException $e) {
if ($attempt === $attempts) {
throw $e;
}
sleep(2 ** $attempt);
continue;
}
$status = $response->getStatusCode();
if ($status === 200) {
return (string) $response->getBody();
}
if (in_array($status, [409, 429, 503], true) && $attempt < $attempts) {
$retryAfter = $response->getHeaderLine('Retry-After');
sleep(ctype_digit($retryAfter) ? (int) $retryAfter : 2 ** $attempt);
continue;
}
$error = json_decode((string) $response->getBody(), true)['error'] ?? [];
throw new CastPdfException($status, $error['code'] ?? 'unknown', $error['message'] ?? 'no message');
}
throw new LogicException('No attempts were made');
}The exception keeps the API code in $errorCode rather than $code, because PHP's base Exception already owns an integer $code property. Catch CastPdfException in your controller or job and branch on $e->errorCode: show a friendly message for template_render_error, alert someone for spending_cap_reached, and let the queue retry anything that escaped the loop.
Production checklist for PHP
- Set both Guzzle timeouts. Guzzle's default
timeoutis0, which means wait forever, so one stuck connection can pin a PHP-FPM worker. Use a connect timeout around 10 seconds and a total timeout above 60. - Mind the server limits too. A render can take up to 30 seconds, and PHP-FPM's
request_terminate_timeout, your web server's proxy timeout ormax_execution_timemay cut the request first. Big manifests belong in a queue worker (Laravel queues, Symfony Messenger or a plain CLI script), where those limits do not apply. - Stream large files to disk. Pass Guzzle's
sinkoption with a file path, orCURLOPT_FILEwith plain cURL, instead of holding a multi-megabyte string in memory under a tightmemory_limit. - Read the key with
getenv()or from your framework's config, never from a file inside the web root, and never print it into a page. Move fromcpdf_test_to acpdf_live_key at launch; the code does not change. - Derive the
Idempotency-Keyfrom your own data, such as the manifest number or order ID, so a retried cron or queue job never produces a second PDF. - On Windows and some minimal containers, cURL may lack a CA bundle. Point
curl.cainfoinphp.iniat a current bundle instead of switching off certificate checks. - Keep each document under 50 pages and 40 MB. Scale product photos down before you embed them.
Common errors from PHP code
| Symptom or code | Likely cause | Fix |
|---|---|---|
invalid_api_key (401) | getenv() returned false because the variable exists in your shell but not in PHP-FPM's environment | Set it in the pool config (env[CASTPDF_API_KEY]) or your framework's .env, then reload FPM |
invalid_request (400) | An unknown field, or json_encode turned an empty data array into [] instead of an object | Check field names against the API reference; send (object) [] or new stdClass() for empty data |
template_render_error (422) | A Liquid tag failed, often Blade or Twig syntax like {{ $parcel->city }} left in the template | Read details.line and details.column and write Liquid such as {{ parcel.city }} |
cURL error 28 | The client timed out before the render finished | Raise the total timeout above 60 seconds and retry with the same key |
cURL error 60 | PHP cannot verify the TLS certificate because no CA bundle is configured | Set curl.cainfo to a current CA bundle |
rate_limited (429) | Too many requests per minute (a test key allows 20) | Sleep for Retry-After seconds, then send again |
The quickstart covers the dashboard side: keys, starter templates and your first saved design. On Laravel, the Laravel page wraps these calls in a controller and a queued job. Running Ruby services next to PHP? The Ruby page shows the same requests with Net::HTTP.