Skip to content
CastPDF

PHP HTML to PDF with modern CSS and no binaries

To convert HTML to PDF in PHP, post the markup (or a saved template ID and an array of data) to CastPDF with Guzzle or the built-in cURL extension, then save the response body as a file. Flexbox, grid and web fonts work. It is the same PDF generation API every language calls, from PHP 8 onwards.

  • Updated October 2026
  • 100 free PDFs a month

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.

Ways to make a PDF from PHP
OptionHow it worksWorth knowing
DompdfPure PHP; parses HTML and CSS and lays out the page itselfEasy to install, mostly CSS 2.1 with some CSS3; no flexbox, grid or JavaScript; long tables can be slow and memory hungry
mPDFPure PHP, grown from the FPDF family, with its own HTML parserStrong UTF-8 and right-to-left text support; CSS support is partial and floats or positioning can surprise you
TCPDFDraws pages in PHP code, with a writeHTML() method for a small HTML subsetVery 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 binaryBetter CSS than the pure PHP engines, but the wkhtmltopdf repository was archived in January 2023 and its WebKit is old
CastPDFOne HTTPS request; current Chromium plus print features runs on our serversOne 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.

manifest.php (PHP 8.1+, Guzzle 7)
<?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.

manifest_link.php (ext-curl only)
<?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.

CastPdf.php (retry helper, PHP 8.1+)
<?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 timeout is 0, 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 or max_execution_time may 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 sink option with a file path, or CURLOPT_FILE with plain cURL, instead of holding a multi-megabyte string in memory under a tight memory_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 from cpdf_test_ to a cpdf_live_ key at launch; the code does not change.
  • Derive the Idempotency-Key from 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.cainfo in php.ini at 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

Errors PHP developers meet first, and how to fix them
Symptom or codeLikely causeFix
invalid_api_key (401)getenv() returned false because the variable exists in your shell but not in PHP-FPM's environmentSet 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 objectCheck 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 templateRead details.line and details.column and write Liquid such as {{ parcel.city }}
cURL error 28The client timed out before the render finishedRaise the total timeout above 60 seconds and retry with the same key
cURL error 60PHP cannot verify the TLS certificate because no CA bundle is configuredSet 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.

FAQ

Common questions

Should I replace Dompdf with an API?

Only if Dompdf is holding you back. It is a good fit for simple, short documents styled with basic CSS. Teams usually switch when they need flexbox or grid, repeating table headers on long reports, or tables long enough that rendering in PHP becomes slow.

Is there a Composer package for CastPDF?

No, and you do not need one. The API is a single POST request with a JSON body, so Guzzle, Symfony HttpClient or the cURL extension built into PHP all work. The scripts on this page are complete.

Can I generate PDFs on shared hosting with PHP?

Usually yes. Shared hosts rarely allow binaries such as wkhtmltopdf or Chromium, but almost all of them include the cURL extension. Outgoing HTTPS is all CastPDF needs, so the plain cURL example runs there unchanged.

How do I send the PDF to the browser as a download in PHP?

Call the API on the server, then send the header Content-Type: application/pdf, a Content-Disposition header with the file name, and echo the body. In a framework, return a response object with those headers. The API key stays on your server the whole time.

Does it handle Arabic, Chinese or accented characters sent from PHP?

Yes. Send UTF-8 strings, which json_encode requires anyway, and the renderer has Noto fonts installed for many scripts, including Arabic and the CJK families. Set dir="rtl" on the html element for right-to-left documents.

How is PHP PDF generation priced?

The same as any other language. Test renders are free and unlimited, and each live PDF counts once no matter how many pages it has. The Free plan includes 100 live PDFs a month, and paid plans start at $19 a month.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.