PHP PDF Guides

How to Generate PDFs in PHP with BladePDF

Build a production-ready HTML-to-PDF workflow with the official BladePDF PHP SDK, including Twig templates, secure local assets, Symfony and PSR-7 responses, asynchronous renders and verified webhooks.

PHP application using Twig and the official BladePDF SDK to generate an invoice PDF
The framework-agnostic bladepdf/php SDK turns HTML from Twig, Symfony, Slim or plain PHP into browser-quality PDFs without adding a browser runtime to the application server.

PHP PDF generation often begins with a short requirement: turn an invoice, report, ticket or certificate into a file the user can download. The real production work is making modern CSS reliable, loading fonts and images safely, choosing a sensible HTTP delivery path, handling slow documents, and deciding who operates the browser that creates the PDF.

This guide builds that complete workflow with BladePDF for PHP and the official open-source bladepdf/php SDK. You will generate a real Twig invoice, securely attach local CSS and SVG files, return PDFs from plain PHP, Symfony and any PSR-7 framework such as Slim, submit long-running renders asynchronously, and verify webhook signatures before trusting a callback.

Framework supportAny PHP application

Use plain PHP, Symfony, Slim, Laminas or another application that can produce an HTML string.

Template enginesBring your own

Render Twig, Latte, Plates or native PHP locally; the SDK receives the completed HTML.

Delivery choicesBytes, file or async

Return a binary string, save to a path, or submit a stored background render with a webhook.

Short answer: install bladepdf/php, read the API key with your application’s configuration system, render your Twig or PHP template to an HTML string, then call BladePdf::create($apiKey)->fromHtml($html)->render(). Use pdf() for an HTTP response, save() for a local file, and storePdf()->webhook(...)->async() when the request should not wait for the PDF.

How PHP HTML-to-PDF generation works#

BladePDF does not replace Twig, Symfony, Slim or your domain layer. Your application still loads the invoice, checks authorization, formats money and renders the template. The PHP SDK packages that final HTML, PDF options and only the local assets it is permitted to read. The BladePDF API returns the generated PDF or accepts an asynchronous job.

This separation is useful, but it is also a real architectural tradeoff:

  • Your PHP process owns the document data and HTML. BladePDF does not query your database or execute your Twig templates.
  • Your PHP server does not install or operate Chrome. Browser dependencies, sandboxing, capacity and upgrades live outside the application deployment.
  • Rendering requires a network request. The final HTML and attached assets are sent to BladePDF. Keep secrets out of documents, use separate API keys per environment, and choose a local renderer if policy requires the document to remain inside an isolated network.

Laravel users should normally use the dedicated bladepdf/laravel package. It builds on the same PHP core and adds Blade views, configuration, dependency injection and Laravel response helpers. This article is for the framework-agnostic SDK.

Install the official PHP SDK with Composer#

The SDK requires PHP 8.2 or newer, ext-json, ext-fileinfo and a PSR-compatible HTTP stack provided by Guzzle. Install the current stable release from Packagist:

Terminal
Shell
composer require bladepdf/php

The package deliberately does not read .env or global framework configuration. Read credentials with the configuration layer you already trust, then pass the value explicitly:

.env.example
Environment
BLADEPDF_API_KEY=your-server-side-api-key
render.php
PHP
<?php

use BladePDF\BladePdf;

$apiKey = $applicationConfig['bladepdf_api_key'];
$bladePdf = BladePdf::create($apiKey);

The key belongs only in a server process, worker or CLI command. Never embed it in HTML, JavaScript, a public repository or a response sent to a browser.

Create one configured BladePDF client#

In a real application, configure the SDK once in your service container or composition root. The factory below sets explicit transport behavior and restricts automatic asset discovery to the project’s public directory:

src/BladePdfFactory.php
<?php

declare(strict_types=1);

namespace BladePdfGuide;

use BladePDF\Assets\AssetResolverOptions;
use BladePDF\BladePdf;
use BladePDF\Client\ClientOptions;
use RuntimeException;

final class BladePdfFactory
{
    public static function create(string $projectRoot): BladePdf
    {
        $apiKey = getenv('BLADEPDF_API_KEY');

        if (! is_string($apiKey) || trim($apiKey) === '') {
            throw new RuntimeException('Missing BLADEPDF_API_KEY.');
        }

        $publicRoot = $projectRoot.'/public';

        return BladePdf::create(
            apiKey: $apiKey,
            clientOptions: new ClientOptions(
                timeout: 60,
                connectTimeout: 10,
                retryTimes: 1,
                retrySleepMilliseconds: 1000,
            ),
            assetOptions: new AssetResolverOptions(
                documentRoot: $publicRoot,
                searchRoots: [$publicRoot],
            ),
        );
    }
}

retryTimes: 1 means one retry after the initial attempt. The SDK retries connection failures and HTTP 429, 502, 503 and 504; it respects Retry-After when present and otherwise uses exponential delay. Authentication, validation, payload and plan-limit errors are intentionally not retried blindly.

If your framework has a dependency injection container, register this configured BladePdf instance as a shared service. Do not build a new client in every controller method.

Generate your first PDF from an HTML string#

For a short document, synchronous rendering is intentionally small:

hello-pdf.php
PHP
<?php

use BladePDF\BladePdf;

$apiKey = getenv('BLADEPDF_API_KEY');

if (! is_string($apiKey) || $apiKey === '') {
    throw new RuntimeException('Missing BLADEPDF_API_KEY.');
}

$bladePdf = BladePdf::create($apiKey);

$result = $bladePdf
    ->fromHtml(<<<'HTML'
        <!doctype html>
        <html lang="en">
          <head><meta charset="utf-8"><title>Order ORD-1042</title></head>
          <body><h1>Order ORD-1042</h1><p>Ready for dispatch.</p></body>
        </html>
        HTML)
    ->format('A4')
    ->showBackground()
    ->render();

$result->save(__DIR__.'/order.pdf');
echo $result->requestId();

render() returns a RenderResult. Its pdf() method returns the raw PDF as a PHP binary string, save($path) writes it to disk and throws if the complete file could not be written, and base64() is available for systems that explicitly require Base64. Avoid Base64 for an ordinary download: it increases payload size and still keeps the complete PDF in memory.

Generate a real PDF invoice with Twig#

Production documents normally combine application data with a template. The SDK is intentionally template-engine agnostic, so render Twig as usual and pass the resulting HTML to fromHtml():

src/InvoicePdf.php
<?php

declare(strict_types=1);

namespace BladePdfGuide;

use BladePDF\BladePdf;
use BladePDF\RenderResult;
use Twig\Environment;

final readonly class InvoicePdf
{
    public function __construct(
        private BladePdf $bladePdf,
        private Environment $twig,
        private string $publicRoot,
    ) {
    }

    /** @param array<string, mixed> $invoice */
    public function render(array $invoice): RenderResult
    {
        $html = $this->twig->render('invoice.html.twig', [
            'invoice' => $invoice,
        ]);

        return $this->bladePdf
            ->fromHtml($html, $this->publicRoot)
            ->format('A4')
            ->margins(0, 0, 0, 0, 'mm')
            ->showBackground()
            ->emulateMedia('print')
            ->waitForFonts()
            ->reference((string) $invoice['number'])
            ->templateName('PHP SDK invoice')
            ->render();
    }
}

The Twig document uses ordinary root-relative URLs for its stylesheet and logo:

templates/invoice.html.twig
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Invoice {{ invoice.number }}</title>
    <link rel="stylesheet" href="/styles/invoice.css">
</head>
<body>
    <main class="invoice">
        <header class="invoice-header">
            <div class="brand">
                <img src="/images/northstar-mark.svg" alt="">
                <strong>NORTHSTAR</strong>
            </div>
            <div class="invoice-title">
                <span>Invoice</span>
                <h1>{{ invoice.number }}</h1>
                <p>Issued {{ invoice.issued_at }}</p>
            </div>
        </header>

        <table>
            {% for item in invoice.items %}
                <tr>
                    <td>{{ item.description }}</td>
                    <td>{{ item.quantity }}</td>
                    <td>${{ (item.quantity * item.unit_price)|number_format(2, '.', ',') }}</td>
                </tr>
            {% endfor %}
        </table>
    </main>
</body>
</html>

reference() attaches your own searchable business identifier to the render, while templateName() supplies a readable label in BladePDF. Neither value is printed into the PDF unless your HTML also displays it.

A real PDF generated by the PHP SDK#

The invoice below was generated from the downloadable Composer project with the public bladepdf/php v1.0.0 package and the BladePDF API. The output is a tagged, single-page A4 PDF produced by Chromium; the example includes Twig, external CSS and a nested local SVG asset.

First page of the invoice PDF generated by the PHP SDK and Twig example
INV-2026-2042.pdfLive A4 render · PHP 8.4 · Twig · local CSS · SVG asset

Run the exact project, not a shortened pseudo-example.

The download contains Composer lock data, the complete Twig template and CSS, plain PHP, Symfony and PSR-7 response examples, async webhook code, and an offline verification suite.

Download the complete PHP exampleComposer project, Twig template, assets and lock file Open the generated PDF

How local CSS, images and fonts reach the renderer#

An HTML string alone cannot carry /styles/invoice.css, a logo on disk or a self-hosted font. The PHP SDK can discover those dependencies, attach their bytes to the multipart request and rewrite references so the renderer loads the uploaded files.

Automatic discovery is off until you grant filesystem roots. This is deliberate. A PDF template must not be able to turn ../../.env or an unexpected symlink into an uploaded asset.

asset-permissions.php
PHP
use BladePDF\Assets\AssetResolverOptions;
use BladePDF\BladePdf;

$bladePdf = BladePdf::create(
    apiKey: $apiKey,
    assetOptions: new AssetResolverOptions(
        documentRoot: __DIR__.'/public',
        searchRoots: [__DIR__.'/public', __DIR__.'/var/pdf-assets'],
        localHosts: ['app.example.test'],
    ),
);

The resolver canonicalizes every located file with realpath() and requires it to remain inside an allowed root. It rejects traversal, absolute paths outside the roots, file:// escapes and symlinks that leave a root. documentRoot maps URL paths such as /images/logo.svg; ordered searchRoots resolve relative references; and localHosts lets URLs for your own application map back to the document root instead of being treated as remote.

For a single file outside automatic roots, grant access intentionally:

tenant-logo.php
PHP
$result = $bladePdf
    ->fromHtml('<img src="asset:///tenant-logo.png" alt="">')
    ->withAsset($approvedLogoPath, 'tenant-logo.png', 'image/png')
    ->render();

The resolver follows HTML and CSS dependencies, including nested CSS imports and font or image URLs. It uploads a file referenced by <script src> or an external SVG URL, but it does not execute or inspect JavaScript imports, fetch() calls, runtime-created URLs or dependencies embedded inside an SVG. Attach those files explicitly or make them reachable through an approved remote URL.

Return a PDF from plain PHP#

For native PHP, send binary-safe headers before writing the result. Make sure no whitespace, warning or debug toolbar is emitted first:

src/Http/NativeInvoiceDownload.php
$result = $invoicePdf->render($invoice);

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="invoice.pdf"');
header('Content-Length: '.strlen($result->pdf()));

echo $result->pdf();
exit;

For larger synchronous PDFs, remember that pdf() is an in-memory string. The network response from BladePDF and your application response are not exposed as a streaming API by the current PHP SDK. If holding the whole file in PHP memory is undesirable, prefer an asynchronous stored render rather than presenting a buffered response as a stream.

Return a PDF from Symfony#

Symfony’s Response accepts the binary string directly. A small controller can return the PDF and optionally expose the BladePDF request ID for operational debugging:

src/Http/SymfonyInvoiceController.php
use Symfony\Component\HttpFoundation\Response;

public function __invoke(): Response
{
    $result = $this->invoicePdf->render(ExampleInvoice::data());

    return new Response($result->pdf(), Response::HTTP_OK, [
        'Content-Type' => 'application/pdf',
        'Content-Disposition' => 'attachment; filename="invoice.pdf"',
        'X-BladePDF-Request-Id' => $result->requestId() ?? '',
    ]);
}

Use Symfony’s existing service container to inject the shared InvoicePdf service. Authorization and invoice lookup should happen before render submission, just as they would for an HTML endpoint.

Use BladePDF with Slim, Mezzio or another PSR-7 framework#

Slim, Mezzio and other PSR-7 stacks differ in routing and dependency injection but agree on the response contract. Write the binary bytes to the response body, then return a new response with PDF headers:

src/Http/Psr7InvoiceAction.php
use Psr\Http\Message\ResponseInterface;

public function __invoke(ResponseInterface $response): ResponseInterface
{
    $result = $this->invoicePdf->render(ExampleInvoice::data());
    $response->getBody()->write($result->pdf());

    return $response
        ->withHeader('Content-Type', 'application/pdf')
        ->withHeader('Content-Disposition', 'attachment; filename="invoice.pdf"')
        ->withHeader('X-BladePDF-Request-Id', $result->requestId() ?? '');
}

This action was tested with slim/psr7, but it depends only on Psr\Http\Message\ResponseInterface; the same pattern works with any writable PSR-7 response body.

Use Twig, Latte, Plates or native PHP templates#

There is no BladePDF-specific template syntax. Render the template to one final HTML string first:

Template source What your application passes to BladePDF Typical use
Twig $twig->render('invoice.html.twig', $data) Symfony and framework-agnostic applications
Latte $latte->renderToString('invoice.latte', $data) Nette and standalone Latte projects
Plates $templates->render('invoice', $data) Native-PHP templates without a compiler
Native PHP output buffering around require $template Small applications and legacy systems
Cloud template fromTemplate($templateId, $context) Templates managed centrally in BladePDF

Keep business rules out of the template where possible. Compute permissions, totals, tax and locale-aware values before rendering; let the template focus on document structure and presentation.

Choose the right PDF delivery method#

The PHP SDK exposes three distinct delivery shapes:

Requirement API Memory and lifecycle
Return bytes in this request render()->pdf() Complete PDF is held as a PHP string
Save a synchronous result render()->save($path) Complete result is received, then safely written
Send PDF to another JSON API render()->base64() Complete result plus Base64 size overhead
Do not wait for rendering storePdf()->webhook(...)->async() Request receives a job ID; stored result arrives later

Use synchronous rendering for interactive invoices, labels or certificates that complete within your normal request budget. Use asynchronous rendering for large reports, batches, scheduled exports or workflows in which the user does not need an immediate download.

PDF options, print CSS and document readiness#

The fluent builder keeps common PDF and browser settings explicit:

pdf-options.php
PHP
$result = $bladePdf
    ->fromHtml($html, __DIR__.'/public')
    ->format('A4')
    ->margins(14, 12, 16, 12, 'mm')
    ->showBackground()
    ->emulateMedia('print')
    ->waitForFonts()
    ->taggedPdf()
    ->outline()
    ->render();
  • format('A4') selects a named paper format; paperSize() accepts custom width, height and units.
  • margins() controls Chromium PDF margins. If your stylesheet owns the full page with @page, use preferCssPageSize() and avoid duplicating margin logic.
  • showBackground() is important for colored invoice headers, cards and charts.
  • emulateMedia('print') activates print media rules. Use screen when the PDF should match the screen presentation instead.
  • waitForFonts() asks the renderer to wait for document.fonts.ready before printing. It exists in both the PHP and Laravel SDKs because the Laravel package uses this PHP core.
  • waitUntil('networkidle0') or waitFunction('window.reportReady === true') can handle documents that finish after JavaScript runs. Prefer a deterministic readiness signal over an arbitrary delay.

If a report needs a repeated header or footer, use withHeaderHtml() and withFooterHtml(). These fragments have their own asset resolution path and should contain self-contained styles appropriate for Chromium’s print header/footer context.

Submit a background PDF render#

Asynchronous rendering requires stored output, because the original PHP request ends before the PDF exists:

src/AsyncInvoice.php
$submission = $bladePdf
    ->fromTemplate('invoice.standard', ['invoice' => $invoice])
    ->reference((string) $invoice['number'])
    ->storePdf()
    ->webhook(
        'https://example.com/webhooks/bladepdf',
        $webhookSecret,
    )
    ->async();

echo $submission->requestId;

Store the request ID with your invoice or export record, return 202 Accepted from your endpoint, and update that record when the webhook arrives. Queueing inside your own application can still be useful for batching business work, but BladePDF’s asynchronous request prevents a PHP worker from waiting on the browser render itself.

Verify BladePDF webhooks before decoding them#

Always verify the exact raw request body. Re-encoding parsed JSON changes its bytes and invalidates the signature by design:

src/BladePdfWebhook.php
use BladePDF\Webhooks\SignatureVerifier;

$rawBody = file_get_contents('php://input');

$valid = SignatureVerifier::isValid(
    rawBody: $rawBody,
    timestamp: $_SERVER['HTTP_BLADEPDF_TIMESTAMP'] ?? null,
    signature: $_SERVER['HTTP_BLADEPDF_SIGNATURE'] ?? null,
    secret: $webhookSecret,
);

if (! $valid) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);

The verifier checks the HMAC and rejects stale timestamps using a five-minute tolerance by default. After verification, make your webhook handler idempotent by request ID and event type; providers may retry delivery after a timeout or non-success response.

Handle render failures without hiding useful context#

Catch the common BladePdfException at your application boundary, but log structured details from RenderFailedException where available:

render-with-errors.php
PHP
use BladePDF\Exceptions\BladePdfException;
use BladePDF\Exceptions\RenderFailedException;

try {
    $result = $invoicePdf->render($invoice);
} catch (RenderFailedException $error) {
    $logger->error('BladePDF render failed', [
        'status' => $error->statusCode(),
        'request_id' => $error->requestId(),
        'response' => $error->responseBody(),
        'invoice' => $invoice['number'],
    ]);

    throw $error;
} catch (BladePdfException $error) {
    $logger->error('BladePDF configuration or asset failure', [
        'exception' => $error::class,
        'invoice' => $invoice['number'],
    ]);

    throw $error;
}

Do not return the upstream response body or API key to the end user. Show a stable application error, retain the BladePDF request ID in logs, and let the SDK retry only transient conditions. A missing file, denied asset path or invalid option needs a code or configuration change, not more retries.

BladePDF versus local PHP PDF libraries#

The best renderer depends on the document and operational constraints:

  • Use DomPDF, mPDF or another PHP-native renderer when documents use a supported subset of HTML/CSS, must render completely in-process, and fidelity to current browser layout is not required.
  • Use wkhtmltopdf when an existing deployment already depends on its rendering behavior and you are comfortable operating the native binary, while recognizing it is not current Chromium.
  • Use local Chrome or Browsershot when you need low-level browser flags, must render offline, or already treat browser processes as a normal production workload.
  • Use BladePDF when you want current browser rendering and a small framework-agnostic PHP API without installing, sandboxing, monitoring and scaling Chromium beside PHP workers.

Our Laravel PDF generation benchmark measures fidelity, latency, throughput and observable memory across five renderers. Although its integration layer is Laravel, the renderer-level tradeoffs remain relevant to a plain PHP architecture.

Production checklist for PHP PDF generation#

Before shipping the endpoint, verify these details:

  1. Keep the API key in server-side secret storage and use separate credentials per environment.
  2. Configure one shared SDK client with bounded connect and total timeouts.
  3. Grant only the asset roots each document actually needs; never point an automatic root at the whole project.
  4. Escape untrusted values in the template and decide whether document JavaScript is necessary.
  5. Use print CSS deliberately, embed or attach required fonts, and test page breaks with realistic data.
  6. Send binary-safe response headers and ensure debugging output cannot corrupt the PDF.
  7. Use asynchronous stored renders for batches and work that exceeds the HTTP request budget.
  8. Verify webhook signatures against the raw body and process events idempotently.
  9. Log your business reference together with the BladePDF request ID.
  10. Test the generated PDF itself—not only the controller status—with representative invoices, images and fonts.

The complete example project includes executable versions of these patterns and an offline suite that checks Twig rendering, local asset discovery, SDK transport, Symfony and PSR-7 responses, and webhook verification.

More from the blog

Continue reading

Keep your existing PHP stack

Generate your first production PDF from PHP.

Keep Twig, Latte or your current framework. BladePDF handles browser rendering, asset transport and PDF delivery while your application keeps ownership of its HTML and data.

Try BladePDF free

Was this article helpful?

Your answer helps shape future technical guides.

What could be better?

Choose the main issue and tell us what would make this guide more useful.

At least 10 characters 0 / 2000
Back to all articles