HTML to PDF in PHP: Blade Invoices From a Laravel Controller
Blog / HTML to PDF
HTML to PDF

HTML to PDF in PHP: Blade Invoices From a Laravel Controller

Demetra Theodorou · Oct 21, 2026

Render Blade views and plain HTML to PDF from PHP and serve them from a Laravel controller. Page setup, headers, escaped templates and error handling, with every sample run before publishing.

PHP applications generate more invoices and reports than almost any other kind, and the usual answer, Dompdf, starts to creak as soon as the layout uses flexbox or a web font. This tutorial takes the API route instead. It moves from one URL conversion through page setup, running headers, templates with escaped data, pages that need JavaScript, and error handling, to a Laravel controller that serves invoices.

The HTML to PDF API renders with Chrome on PDFGate's infrastructure, so PHP only sends requests, through the SDK or the cURL extension. Every listing ran against the API before this page was published, and the Laravel routes were served with artisan serve and called with curl.

Key takeaways

  • Two calls do the work: generatePdf renders, getFile returns a stream. No Dompdf CSS limits, no wkhtmltopdf binary, no Chrome beside PHP.
  • Options are associative arrays with the API's own camelCase keys. Set pageSizeType explicitly, the default is Ledger.
  • Escape every user value with htmlspecialchars before it goes into a template, or render a Blade view, which escapes by default.
  • In Laravel, bind one PdfGateClient as a singleton, put the routes in routes/api.php, and return the bytes or a pre-signed link.

The options in PHP

PHP has a few well-known HTML to PDF libraries, and each one trades something away:

  • Dompdf, mPDF and TCPDF. Pure PHP, no server setup, and still the default in a lot of Laravel projects through wrapper packages. They implement their own CSS engines, so flexbox, grid, web fonts loaded with @font-face and anything that needs JavaScript are partly or wholly unsupported. They also render slowly on long documents.
  • Snappy and other wkhtmltopdf wrappers. Better CSS, but the engine was archived in 2023 and needs a binary installed on every server.
  • Browsershot. Real Chrome output through Puppeteer, which means Node.js and a browser of a few hundred megabytes on each PHP server, plus the memory to run them.
  • A conversion API.One Composer package, no binaries on the server, no Node.js beside PHP, and the PDF is Chrome's own print output. You trade a network request per document and a plan for that.

Below, the API is used. To compare that with running Chrome next to PHP, see the Puppeteer comparison.

Install the PHP SDK

The Composer package pdfgate/pdfgate-sdk-php requires PHP 7.4 or newer plus the cURL and JSON extensions, both present in nearly every PHP build, and nothing else.

1composer require pdfgate/pdfgate-sdk-php

Create a key in the dashboard. Its prefix chooses the environment. test_ goes to the sandbox, which returns watermarked PDFs at no cost, and live_ goes to production. The listings read the key with getenv().

Convert a URL to PDF

A conversion is generatePdf followed by getFile. The first returns a document object with an ID, the second returns a stream containing the file.

1<?php23require __DIR__ . '/vendor/autoload.php';45use PdfGate\PdfGateClient;67$client = new PdfGateClient(getenv('PDFGATE_API_KEY'));89$page = $client->generatePdf([10    'url' => 'https://en.wikipedia.org/wiki/Portable_Document_Format',11    'pageSizeType' => 'a4',12]);1314$stream = $client->getFile($page->getId());15file_put_contents('wikipedia.pdf', stream_get_contents($stream));16fclose($stream);1718echo "URL -> {$page->getId()} ({$page->getSize()} bytes)\n";
  • Every method takes an associative array whose keys are the API's camelCase parameter names, which makes the API reference the SDK reference as well.
  • The renderer fetches the URL itself, so it must be public. A localhost address is refused with a 400.
  • Without pageSizeType the page is Ledger. Valid values are the lowercase strings a0 to a6, letter, legal, ledger and tabloid. Pixel sizes go in width and height.
  • getFile hands back a php://temp resource that already contains the full download. Read it with stream_get_contents or copy it with stream_copy_to_stream, then fclose it. The Wikipedia article is 23 pages and about 1.4 MB.
  • A new PdfGateClient per request is normal in PHP's process model. Under Laravel, bind it once as a singleton, as shown later.

Convert an HTML string

When PHP builds the document, pass html and omit url. A nowdoc keeps a whole page readable and leaves dollar signs untouched, and public stylesheets, images and fonts referenced from the markup are loaded during the render. The listings from this point use the small saveFile helper defined first.

1function saveFile(PdfGateClient $client, string $documentId, string $path): void2{3    $stream = $client->getFile($documentId);4    file_put_contents($path, stream_get_contents($stream));5    fclose($stream);6}78$invoiceHtml = <<<'HTML'9<!DOCTYPE html>10<html>11<head>12  <meta charset="utf-8">13  <style>14    body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }15    h1 { color: #0055d4; }16    table { width: 100%; border-collapse: collapse; margin-top: 24px; }17    th { background: #0055d4; color: white; text-align: left; padding: 8px; }18    td { padding: 8px; border-bottom: 1px solid #e4e7eb; }19    .total { text-align: right; font-size: 18px; margin-top: 16px; }20  </style>21</head>22<body>23  <h1>Invoice #2026-0142</h1>24  <p>Billed to Acme GmbH, 21 October 2026</p>25  <table>26    <tr><th>Item</th><th>Qty</th><th>Price</th></tr>27    <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>28    <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>29  </table>30  <p class="total">Total EUR 99.00</p>31</body>32</html>33HTML;3435$invoice = $client->generatePdf([36    'html' => $invoiceHtml,37    'pageSizeType' => 'a4',38    'margin' => ['top' => '20mm', 'bottom' => '20mm', 'left' => '15mm', 'right' => '15mm'],39    'printBackground' => true,40]);4142saveFile($client, $invoice->getId(), 'invoice.pdf');43echo "HTML -> {$invoice->getId()} ({$invoice->getSize()} bytes)\n";
  • margin is an array keyed top, bottom, left and right with CSS lengths as values. Leave a key out to keep that side's default.
  • Print mode discards background colours unless printBackground is true, which is why table headers sometimes print white.
  • 'orientation' => 'landscape' rotates the page, and 'emulateMediaType' => 'screen' applies the screen stylesheet instead of the print one.
  • A nowdoc, <<<'HTML', is the right heredoc flavour for markup that contains JavaScript or prices, because nothing inside it is interpolated.

The full set of layout parameters is in page size, margins and orientation.

Headers, footers and page numbers

Page headers and footers are HTML fragments in header and footer, drawn on each page. The renderer populates elements with the classes date, title, url, pageNumber and totalPages. Reserve the space with the top and bottom margins, or the API rejects the request.

1$report = $client->generatePdf([2    'url' => 'https://en.wikipedia.org/wiki/Portable_Document_Format',3    'pageSizeType' => 'a4',4    'header' => <<<'HTML'5        <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">6          Portable Document Format, exported <span class="date"></span>7        </div>8        HTML,9    'footer' => <<<'HTML'10        <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">11          Page <span class="pageNumber"></span> of <span class="totalPages"></span>12        </div>13        HTML,14    'margin' => ['top' => '25mm', 'bottom' => '20mm'],15]);1617saveFile($client, $report->getId(), 'header-footer.pdf');18echo "Header/footer -> {$report->getId()}\n";
  • Put a font-size in the fragment. Header templates default to a size you can hardly read.
  • The fragment is rendered on its own, so the page stylesheet does not apply. Everything has to be inline.
  • Indented closing markers like HTML, inside an array need PHP 7.3 or later, which the SDK already requires.

Logos, background colours and custom fonts behave differently inside a header, which headers and footers goes through case by case.

Templates with your own data

An invoice, a contract or a report starts as data in your code. For a small document, sprintf with a nowdoc and array_map for the rows is enough and needs no dependency. The escaping is the part that matters.

1$order = [2    'number' => '2026-0142',3    'customer' => 'Acme GmbH',4    'lines' => [5        ['description' => 'API plan, Business', 'quantity' => 1, 'unitPrice' => 79.00],6        ['description' => 'Extra documents', 'quantity' => 2000, 'unitPrice' => 0.01],7    ],8];910$total = array_sum(array_map(fn ($line) => $line['quantity'] * $line['unitPrice'], $order['lines']));1112$rows = implode("\n", array_map(fn ($line) => sprintf(13    '<tr><td>%s</td><td>%s</td><td>EUR %s</td></tr>',14    htmlspecialchars($line['description']),15    number_format($line['quantity']),16    number_format($line['quantity'] * $line['unitPrice'], 2)17), $order['lines']));1819$html = sprintf(<<<'HTML'20<h1>Invoice #%s</h1>21<p>Billed to %s</p>22<table>23  <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>24  %s25</table>26<p>Total EUR %s</p>27HTML, htmlspecialchars($order['number']), htmlspecialchars($order['customer']), $rows, number_format($total, 2));2829$templated = $client->generatePdf(['html' => $html, 'pageSizeType' => 'a4']);3031saveFile($client, $templated->getId(), 'template.pdf');32echo "Template -> {$templated->getId()}\n";
  • Every value that came from a user goes through htmlspecialchars, as the sample does for the customer and the line descriptions. A name containing <script> is otherwise rendered as markup.
  • In Laravel, render a Blade view to a string with view('invoice', $data)->render() and send that as html. Blade escapes {{ }} output by default. Twig does the same in Symfony.
  • number_formatuses a dot and a comma unless you pass the separators. Pass them explicitly when the reader's locale differs from the server's.

In Laravel the template is a Blade view, and the one that renders an invoice page can render the PDF too. Blade escapes everything inside { }, so the htmlspecialchars calls from the previous listing disappear.

1{{-- resources/views/invoice.blade.php --}}2<h1>Invoice #{{ $order['number'] }}</h1>3<p>Billed to {{ $order['customer'] }}</p>4<table>5  <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>6  @foreach ($order['lines'] as $line)7    <tr>8      <td>{{ $line['description'] }}</td>9      <td>{{ number_format($line['quantity']) }}</td>10      <td>EUR {{ number_format($line['quantity'] * $line['unitPrice'], 2) }}</td>11    </tr>12  @endforeach13</table>14<p>Total EUR {{ number_format($total, 2) }}</p>

view() normally returns a response. Calling render() on it returns the HTML as a string instead, which is what the API needs.

1// In a controller that receives PdfGateClient through the constructor2public function pdf(string $number): Response3{4    $order = [5        'number' => $number,6        'customer' => 'Acme GmbH',7        'lines' => [8            ['description' => 'API plan, Business', 'quantity' => 1, 'unitPrice' => 79.00],9            ['description' => 'Extra documents', 'quantity' => 2000, 'unitPrice' => 0.01],10        ],11    ];12    $total = array_sum(array_map(fn ($line) => $line['quantity'] * $line['unitPrice'], $order['lines']));1314    // resources/views/invoice.blade.php, rendered to a string instead of a response15    $html = view('invoice', ['order' => $order, 'total' => $total])->render();1617    $document = $this->client->generatePdf(['html' => $html, 'pageSizeType' => 'a4']);1819    $stream = $this->client->getFile($document->getId());20    $pdf = stream_get_contents($stream);21    fclose($stream);2223    return response($pdf, 200, ['Content-Type' => 'application/pdf']);24}

This action was served with artisan serve and fetched with curl like the other Laravel listings below, and its PDF was identical to the one built with sprintf above. Twig in Symfony works the same way through $twig->render().

Pages that render with JavaScript

Content that appears after the load event, charts or fetched data, is left out of the PDF unless the renderer is told to wait. Have the page insert a marker element when it has finished and name it in waitForSelector. The listing fakes a slow source with setTimeout.

1$chartHtml = <<<'HTML'2<h1>Sales report</h1>3<div id="chart">Loading…</div>4<script>5  // Stands in for a chart library or a fetch() that finishes later6  setTimeout(() => {7    document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8    document.body.insertAdjacentHTML("beforeend", '<div id="chart-ready"></div>');9  }, 800);10</script>11HTML;1213$chart = $client->generatePdf(['html' => $chartHtml, 'waitForSelector' => '#chart-ready']);1415saveFile($client, $chart->getId(), 'wait.pdf');16echo "Wait -> {$chart->getId()}\n";
  • A selector that never matches ends the request with a 422 after 30 seconds, rather than a half-finished PDF.
  • waitForNetworkIdle is for pages you cannot edit, and delay is a fixed number of milliseconds for the cases nothing else fits.
  • When the page should be adjusted before capture, say to disable animations, send the code in javascript.

How to pick between them is explained in waiting for dynamic content.

Error handling

A non-2xx answer throws PdfGate\Exception\ApiException, with getStatusCode(), getResponseBody() and the API's explanation in the message. Connection problems throw TransportException. Both extend PdfGateException, so one catch can cover the SDK. The listing provokes a failure with a loopback URL.

1use PdfGate\Exception\ApiException;23try {4    $client->generatePdf(['url' => 'http://localhost:3000/report']);5} catch (ApiException $e) {6    echo "API error {$e->getStatusCode()}: {$e->getMessage()}\n";7}

The codes you will actually see:

  • 400: a parameter the API rejected, named in the message, such as the local URL or a malformed pageRanges.
  • 401: the key is missing or wrong.
  • 422: a page that was fetched but never rendered, nearly always because waitForSelector waited the full 30 seconds.
  • 429: more renders running at once than the plan allows. Pause and retry.

In Laravel

Bind a single PdfGateClient in the container and let Laravel inject it into the controller. The first action returns the bytes with the PDF content type and a download header. The second asks for a temporary link with preSignedUrlExpiresIn and returns JSON, keeping large files off the web server.

1<?php23namespace App\Http\Controllers;45use Illuminate\Http\JsonResponse;6use Illuminate\Http\Response;7use PdfGate\PdfGateClient;89class InvoiceController extends Controller10{11    public function __construct(private PdfGateClient $client)12    {13    }1415    // Returns the PDF directly16    public function pdf(string $number): Response17    {18        $html = "<h1>Invoice #{$number}</h1><p>Generated on " . now()->toDateString() . '</p>';1920        $document = $this->client->generatePdf(['html' => $html, 'pageSizeType' => 'a4']);2122        $stream = $this->client->getFile($document->getId());23        $pdf = stream_get_contents($stream);24        fclose($stream);2526        return response($pdf, 200, [27            'Content-Type' => 'application/pdf',28            'Content-Disposition' => "attachment; filename=\"invoice-{$number}.pdf\"",29        ]);30    }3132    // Returns a temporary download link instead of the bytes33    public function pdfLink(string $number): JsonResponse34    {35        $document = $this->client->generatePdf([36            'html' => "<h1>Invoice #{$number}</h1>",37            'preSignedUrlExpiresIn' => 3600,38        ]);3940        return response()->json(['id' => $document->getId(), 'fileUrl' => $document->getFileUrl()]);41    }42}

The routes belong in routes/api.php, which skips CSRF verification, and the singleton binding takes the key from config/services.php, which in turn reads PDFGATE_API_KEY from .env.

1// routes/api.php2use App\Http\Controllers\InvoiceController;3use Illuminate\Support\Facades\Route;45Route::get('/invoices/{number}/pdf', [InvoiceController::class, 'pdf']);6Route::post('/invoices/{number}/pdf-link', [InvoiceController::class, 'pdfLink']);78// app/Providers/AppServiceProvider.php9public function register(): void10{11    $this->app->singleton(PdfGateClient::class, fn () => new PdfGateClient(config('services.pdfgate.key')));12}
  • Since Laravel 11, routes/api.php exists only after php artisan install:api, or once api: is added to withRouting in bootstrap/app.php.
  • Asking for a link on an account without file storage gets a 422, so switch storage on first. Links expire after anything from a minute to a day, whatever you request.
  • Each render blocks for a few seconds, which is acceptable for a user waiting on a download. For bulk generation, dispatch one queued job per document and let the worker count enforce the plan's concurrency limit.
  • In Symfony the same two actions work unchanged, with the client registered as a service and a Response carrying the PDF content type.

Without the SDK (cURL)

The SDK is a thin client for POST /generate/pdf, which accepts JSON and returns the PDF. The cURL extension can make that request on its own.

1<?php23$ch = curl_init('https://api.pdfgate.com/generate/pdf');4curl_setopt_array($ch, [5    CURLOPT_POST => true,6    CURLOPT_RETURNTRANSFER => true,7    CURLOPT_HTTPHEADER => [8        'Authorization: Bearer ' . getenv('PDFGATE_API_KEY'),9        'Content-Type: application/json',10    ],11    CURLOPT_POSTFIELDS => json_encode([12        'html' => '<h1>Hello from cURL</h1>',13        'pageSizeType' => 'a4',14        'margin' => ['top' => '20mm', 'bottom' => '20mm'],15    ]),16]);1718$body = curl_exec($ch);19$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);2021if ($status !== 201) {22    echo $body, "\n";23    exit(1);24}2526file_put_contents('http.pdf', $body);27echo "cURL -> http.pdf\n";
  • The success code is 201. Add jsonResponse set to true to get the document record instead of the bytes.
  • A test_ key must be sent to api-sandbox.pdfgate.com. The SDK selects the host from the prefix, a raw request does not.
  • Inside Laravel, Http::withToken($key)->post($url, $payload)->body() does the same in one line, and Guzzle works just as well.

Where to go next

  • Forms. enableFormFields makes the inputs in your HTML real PDF fields. The fillable fields guide lists the supported inputs, and flattenPdf locks them afterwards.
  • Pages behind a login. Hand the renderer a session cookie or token via httpHeaders, or basic auth via authentication, see authenticated pages.
  • GDPR banners. clickSelectorChainSetup lists the clicks that close them before capture, see removing cookie banners.
  • Fonts. Liberation and colour emoji are the only installed faces. A brand font is loaded by the page as a web font, see custom fonts and images.
  • Working in a different language? The C#, Java, Python and Node.js editions follow the same path.
  • Still missing something? Every request key is documented in the HTML to PDF API reference, and the client's other methods in the PHP SDK documentation.

Frequently asked questions

Which PHP versions does the SDK support?

PHP 7.4 and later, with the cURL and JSON extensions. There are no other dependencies, so there is nothing to install on the server.

How do I add page numbers to a PDF generated from PHP?

Pass header and footer HTML with the pageNumber and totalPages CSS classes, and set the top and bottom margins so there is room to draw them. The renderer fills the classes in on every page.

Can I use a Blade view as the template?

Yes. Render it to a string with view('invoice', $data)->render() and send the string as html. Blade escapes {{ }} output by default, and Twig does the same in Symfony.

What happens when a request fails?

A non-2xx response throws PdfGate\Exception\ApiException with getStatusCode and getResponseBody, and a network failure throws TransportException. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.

Whatever this page did not touch is on the HTML to PDF API page.

This website uses cookies to offer a better browsing experience. By clicking on the "Accept" button you accept our Cookie policy.