Guides / Laravel PDFs

Generate PDFs from Laravel

PHP applications bill people. Invoices, receipts, quotes, statements, delivery notes. The PDF is part of the product, and it has to look right on the first attempt, because a customer keeps it.

First the honest state of the PHP options, then a complete integration for the hosted route: a service class, failures you can catch, queued generation, mail attachments, and tests that don't need a browser. Everything below assumes Laravel 12 or 13 (both current in mid-2026, and the code is the same on either) and PHP 8.3+.

The four options, honestly

barryvdh/laravel-dompdf (dompdf). Pure PHP, no binaries, installs in one command. By far the smallest operational footprint, and the right answer for a plain document. What its own README states about the rendering, though: it "Handles most CSS 2.1 and a few CSS3 properties", "Does not support CSS flexbox", "Does not support CSS Grid", and the one that bites invoices specifically: "Table cells are not pageable, meaning a table row must fit on a single page". Your designer built that invoice with flexbox, and a long line-item table has to break across pages.

barryvdh/laravel-snappy (wkhtmltopdf). Renders through a real engine, so more CSS works. But wkhtmltopdf's repository was archived by its owner on 2 January 2023 and is read-only: its Qt WebKit is frozen years behind any current browser, and no CSS feature it lacks is ever arriving. Choosing it now is choosing a dead renderer.

spatie/laravel-pdf / Browsershot. Drives real headless Chromium, so modern CSS just works. This is the correct self-hosted answer. The cost is that your PHP application now depends on Node, Puppeteer and a Chromium binary on every machine that renders: local, CI, the Docker image, each queue worker. Plus fonts, memory headroom, and a Chromium upgrade cadence somebody owns. Running Puppeteer on Vercel and Lambda is the full itemisation of that bill. It applies the same in PHP.

A rendering API (this page). Chromium-grade CSS with no binary in your stack. One HTTP call from Http::, a template versioned outside your deploy, and the browser is someone else's problem. What you give up: your documents render off-site, you're bounded by plan limits, and you can't script a browser. This renders a template with data, nothing more.

If your PDF is a plain report and dompdf already renders it correctly, keep dompdf. The comparison above only matters once the layout your designer produced stops surviving the renderer.

1. Get a key and a template

Create an account, then an API key in the dashboard (shown once, copy it). For the template, open Modern Invoice in the gallery, click Use this template, and publish it. The editor's API tab has its tpl_ code. A template must be published before the API will render it.

Prove the pair works before writing any PHP. It's the same call your Laravel code will make:

import { writeFile } from "node:fs/promises";
import { PdfGlyph } from "pdfglyph";

const client = new PdfGlyph({
  apiKey: "YOUR_API_KEY",
  baseUrl: "https://api.pdfglyph.dev",
});

const { pdf } = await client.generate({
  templateId: "tpl_invoice_8x2k",
  data: {
    "invoiceNumber": "INV-2026-0042",
    "issueDate": "June 5, 2026",
    "total": "$5,859.00"
  },
});

// pdf is a Uint8Array — write it to a file, return it, or stream it.
await writeFile("document.pdf", pdf);

The cURL tab above, run against the live API, answers HTTP/2 201 with content-type: application/pdf, x-render-duration-ms: 73 and 67,084 bytes on disk. A PDF on disk means the key, the template id, and the published version are all good, and anything that fails later is your PHP.

2. Configure it

No package to install: Laravel's HTTP client is Guzzle already. Add the credentials to config/services.php:

// config/services.php
'pdfglyph' => [
    'url' => env('PDFGLYPH_URL'),
    'key' => env('PDFGLYPH_API_KEY'),
    'template' => env('PDFGLYPH_TEMPLATE_ID'),
],

And in .env, the key, the template code, and the API base URL, PDFGLYPH_URL=https://api.pdfglyph.dev:

PDFGLYPH_API_KEY=sb_your_key_here
PDFGLYPH_TEMPLATE_ID=tpl_invoice_8x2k

Never read env() outside config/: php artisan config:cache in production makes those calls return null.

3. A service class

One place that knows the wire format, so nothing else in your app does. It returns raw PDF bytes and throws a typed exception:

<?php
// app/Services/PdfGlyph.php

namespace App\Services;

use App\Exceptions\PdfGlyphException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

class PdfGlyph
{
    /** Codes where a retry cannot help: fix the cause instead. */
    private const PERMANENT = [
        'invalid_data', 'invalid_json', 'template_not_found',
        'template_not_published', 'unauthorized', 'payment_required',
        'account_suspended', 'limit_exceeded', 'idempotency_conflict',
        'render_failed',
    ];

    public function __construct(
        private readonly string $baseUrl,
        private readonly string $apiKey,
    ) {}

    /**
     * Render a published template with $data and return the PDF bytes.
     *
     * $idempotencyKey makes the call replayable: a repeat with the same key comes
     * back as the same generation, billed once, instead of billing twice. Derive it
     * from the business event (invoice id + revision), never from a random value.
     */
    public function generate(string $templateId, array $data, ?string $idempotencyKey = null): string
    {
        $attempts = 0;

        while (true) {
            $attempts++;

            try {
                $response = Http::withToken($this->apiKey)
                    ->timeout(60)        // the render itself is bounded server-side
                    ->connectTimeout(10)
                    ->withHeaders($idempotencyKey ? ['Idempotency-Key' => $idempotencyKey] : [])
                    ->post("{$this->baseUrl}/v1/generate", [
                        'templateId' => $templateId,
                        'data' => $data,
                    ]);
            } catch (ConnectionException $e) {
                // The response was lost, so the request may have completed and been
                // billed. Only replay it when a key guarantees the API replays too.
                if ($idempotencyKey && $attempts < 3) {
                    sleep($attempts);
                    continue;
                }
                throw new PdfGlyphException('network_error', $e->getMessage(), retryable: (bool) $idempotencyKey);
            }

            if ($response->successful()) {
                return $response->body();   // raw PDF bytes
            }

            $code = $response->json('error.code') ?? 'unknown';
            $message = $response->json('error.message') ?? $response->body();
            $requestId = $response->header('X-Request-Id');

            // 429 rate_limited and 503 are pacing, not rejection: wait as told and retry.
            if (in_array($code, ['rate_limited', 'service_unavailable'], true) && $attempts < 3) {
                sleep(max(1, (int) $response->header('Retry-After')));
                continue;
            }

            throw new PdfGlyphException(
                $code,
                $message,
                retryable: ! in_array($code, self::PERMANENT, true),
                requestId: $requestId,
            );
        }
    }
}

Two details carry most of the reliability:

  • Retry-After is honoured rather than replaced by a guess. When the API knows how long to wait, it says so.
  • A lost connection is only retried under an idempotency key. Without one, a retry can bill a second document for a request that actually succeeded. With one, the API replays the first outcome. The same rule the SDK follows.

Laravel's Http::retry() can express the retry loop too. It's written out here because the two rules above (the server's own wait, and "only replay what is replayable") are the parts that matter, and they're easier to read than to configure.

Register it in a service provider so the credentials are resolved once:

// app/Providers/AppServiceProvider.php  (register method)
$this->app->singleton(PdfGlyph::class, fn () => new PdfGlyph(
    rtrim(config('services.pdfglyph.url'), '/'),
    config('services.pdfglyph.key'),
));

The exception carries the code, so callers branch on the reason instead of the status:

<?php
// app/Exceptions/PdfGlyphException.php

namespace App\Exceptions;

use RuntimeException;

class PdfGlyphException extends RuntimeException
{
    public function __construct(
        // NOT `$code`: PHP's Exception already declares a non-readonly int $code, and
        // redeclaring it readonly is a fatal error — "Cannot redeclare non-readonly
        // property Exception::$code as readonly".
        public readonly string $errorCode,
        string $message,
        public readonly bool $retryable = false,
        public readonly ?string $requestId = null,
    ) {
        parent::__construct($message);
    }
}

The codes are a closed set: invalid_data, template_not_found, template_not_published, rate_limited, limit_exceeded, payment_required, service_unavailable and a few more, each documented with what to do about it in Errors. Catch it and branch on $e->errorCode. Log requestId whenever you log a failure, since it's what support traces a single request by.

4. Map your models to the template

The gallery invoice expects invoiceNumber, issueDate, dueDate, company, billTo, items, subtotal, taxLabel, taxAmount, total, payment and an optional notes. Templates render values exactly as given (there are no formatting helpers), so money, dates and locale are your code's decisions, made once:

<?php
// app/Support/InvoicePdfData.php

namespace App\Support;

use App\Models\Invoice;

class InvoicePdfData
{
    public static function from(Invoice $invoice): array
    {
        return [
            'invoiceNumber' => $invoice->number,
            'issueDate' => $invoice->issued_at->translatedFormat('F j, Y'),
            'dueDate' => $invoice->due_at->translatedFormat('F j, Y'),
            'company' => [
                'name' => config('app.name'),
                'addressLine1' => '128 Maple Avenue, Suite 5',
                'addressLine2' => 'Portland, OR 97204',
                'email' => 'billing@northwind.studio',
            ],
            'billTo' => [
                'name' => $invoice->customer->name,
                'addressLine1' => $invoice->customer->address_line1,
                'addressLine2' => $invoice->customer->address_line2,
            ],
            'items' => $invoice->lines->map(fn ($line) => [
                'description' => $line->description,
                'quantity' => (string) $line->quantity,
                'rate' => self::money($line->unit_price_cents),
                'amount' => self::money($line->quantity * $line->unit_price_cents),
            ])->all(),
            'subtotal' => self::money($invoice->subtotal_cents),
            'taxLabel' => "VAT ({$invoice->tax_rate}%)",
            'taxAmount' => self::money($invoice->tax_cents),
            'total' => self::money($invoice->total_cents),
            'payment' => [
                'bankName' => 'First Cascade Bank',
                'accountNumber' => '0041 2298 7745',
                'swift' => 'FCSBUS6P',
            ],
            'notes' => $invoice->notes,
        ];
    }

    private static function money(int $cents): string
    {
        return '$' . number_format($cents / 100, 2);
    }
}

Money stays in integer cents until this one method. items uses ->all() rather than the collection, so the JSON body is an array and not an object with numeric keys. A {"0": {...}} payload is the classic way a Laravel {{#each}} renders empty.

5. Stream it from a controller

<?php
// app/Http/Controllers/InvoicePdfController.php

namespace App\Http\Controllers;

use App\Models\Invoice;
use App\Services\PdfGlyph;
use App\Support\InvoicePdfData;
use Illuminate\Support\Facades\Gate;

class InvoicePdfController extends Controller
{
    public function __invoke(Invoice $invoice, PdfGlyph $pdfglyph)
    {
        // It is a customer's financial document. `Gate::authorize` rather than
        // `$this->authorize`, which needs a trait the slim skeleton no longer adds.
        Gate::authorize('view', $invoice);

        $pdf = $pdfglyph->generate(
            config('services.pdfglyph.template'),
            InvoicePdfData::from($invoice),
            "invoice-pdf:{$invoice->id}:{$invoice->updated_at->timestamp}",
        );

        return response($pdf, 200, [
            'Content-Type' => 'application/pdf',
            'Content-Disposition' => "inline; filename=\"invoice-{$invoice->number}.pdf\"",
            'Content-Length' => strlen($pdf),
        ]);
    }
}

The idempotency key includes updated_at, so a double-click replays the same document for free while an edited invoice renders again instead of serving a stale replay.

Nothing here catches PdfGlyphException, so a failure becomes a 500 through Laravel's handler. That's fine as a default, since the message and requestId land in your log. When you want a friendlier response, map it once in bootstrap/app.php rather than in every controller:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(fn (PdfGlyphException $e) => response()->json([
        'message' => $e->retryable ? 'Please try again in a moment.' : 'This document cannot be generated.',
    ], $e->retryable ? 503 : 422));
})

6. Queue it, and attach it to mail

Generation is a network call, so keep it off the request that a human is waiting on whenever the document is being sent rather than viewed:

<?php
// app/Jobs/SendInvoiceEmail.php

namespace App\Jobs;

use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Services\PdfGlyph;
use App\Support\InvoicePdfData;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;

class SendInvoiceEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(public Invoice $invoice) {}

    public function handle(PdfGlyph $pdfglyph): void
    {
        // A job retry reuses this key, so the retry replays instead of billing twice.
        $pdf = $pdfglyph->generate(
            config('services.pdfglyph.template'),
            InvoicePdfData::from($this->invoice),
            "invoice-email:{$this->invoice->id}:{$this->invoice->updated_at->timestamp}",
        );

        Mail::to($this->invoice->customer->email)->send(new InvoiceMail($this->invoice, $pdf));
    }
}

The mailable attaches the bytes directly. Nothing is written to disk:

// app/Mail/InvoiceMail.php  (in the Mailable)
use Illuminate\Mail\Mailables\Attachment;

public function attachments(): array
{
    return [
        Attachment::fromData(fn () => $this->pdf, "invoice-{$this->invoice->number}.pdf")
            ->withMime('application/pdf'),
    ];
}

Generate before sending and let a failure fail the job. A retryable code (rate_limited, service_unavailable, a network blip under a key) is exactly what $tries is for. A permanent one (invalid_data) should land in failed_jobs where you'll see it, because every invoice will hit it until the mapping is fixed.

7. Tests that don't need a browser

This is where the hosted route pays back a second time: the renderer is an HTTP call, so it fakes. No Chromium in CI, no binary to install, no snapshot of a rendered page.

// tests/Feature/InvoicePdfTest.php (Pest)
use App\Models\Invoice;
use App\Models\InvoiceLine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;

uses(RefreshDatabase::class);

it('streams a PDF for an invoice', function () {
    Http::fake([
        '*/v1/generate' => Http::response('%PDF-1.4 fake bytes', 201, [
            'Content-Type' => 'application/pdf',
        ]),
    ]);

    $invoice = Invoice::factory()->has(InvoiceLine::factory()->count(3), 'lines')->create();

    $this->actingAs($invoice->user)
        ->get(route('invoices.pdf', $invoice))
        ->assertOk()
        ->assertHeader('Content-Type', 'application/pdf');

    // The assertion that actually protects you: what you SENT satisfies the template.
    Http::assertSent(function ($request) use ($invoice) {
        expect($request['data']['invoiceNumber'])->toBe($invoice->number)
            ->and($request['data']['items'])->toHaveCount(3)
            ->and($request['data']['total'])->toMatch('/^\$[\d,]+\.\d{2}$/');

        return $request->hasHeader('Idempotency-Key');
    });
});

Then test the failure path the same way: a faked 422 with ['error' => ['code' => 'invalid_data', 'message' => '...']] proves your controller turns it into something a user can live with, without waiting for the real thing to happen in production.

Verify the whole path

php artisan tinker
>>> $pdf = app(App\Services\PdfGlyph::class)->generate(config('services.pdfglyph.template'), App\Support\InvoicePdfData::from(App\Models\Invoice::first()));
>>> file_put_contents('invoice.pdf', $pdf);
>>> strlen($pdf);
>>> substr($pdf, 0, 8);

Run against the live API on Laravel 13.23 / PHP 8.4, that wrote 64,534 bytes beginning %PDF-1.4, a one-page A4 invoice.

Open the file. If the numbers disagree with your database the bug is in InvoicePdfData, which is a unit test. If the layout is wrong it's the template, which you fix in the editor with a preview that renders under the same page conditions as production. The dashboard's History view shows the generation with its template version, duration and status.

Next steps