Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 080 / 100

Generating PDFs in Laravel: patterns, pitfalls and fixes

Invoices and reports.

Expona Admin9 min read

PDF generation is one of those features that feels straightforward until you're debugging garbled layouts at 2am or explaining why your invoice took 45 seconds to render. We've built enough e-commerce and SaaS platforms to know the difference between "it works locally" and "it works at scale"—and generating PDFs in Laravel sits right at that intersection.

In this post, we'll walk through the patterns we use for reliable, performant PDF generation: when to reach for Dompdf, when Browsershot earns its place, how to queue the work properly, and how to keep those files accessible without tanking your storage or security. These aren't theoretical best practices—they're patterns that handle invoices, reports, and certificates in production.

Dompdf for simple layouts

Dompdf is the workhorse of Laravel PDF generation. It's bundled into Barryvdh's Laravel-Dompdf package, it's well-maintained, and it doesn't require external dependencies like Chrome or Puppeteer. For straightforward documents—invoices with a few line items, receipts, simple reports—Dompdf is fast and reliable.

The basic flow is simple: render a Blade view, pass it to Dompdf, get back a PDF stream or file. Here's how we structure it:

php
// In your controller or service
use Barryvdh\DomPDF\Facade\Pdf;

public function downloadInvoice(Invoice $invoice)
{
    $pdf = Pdf::loadView('invoices.template', [
        'invoice' => $invoice,
        'items' => $invoice->items,
        'total' => $invoice->total(),
    ]);
    
    return $pdf->download("invoice-{$invoice->id}.pdf");
}

The real work happens in the Blade template. Dompdf parses a subset of CSS—not everything your browser understands. Table layouts work well. Flexbox and Grid have limited support. Absolute positioning works but can be fragile. We've learned to keep Dompdf templates simple: tables for tabular data, inline styles for critical formatting, and media print CSS sparingly.

A practical invoice template looks like this:

php
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        table { width: 100%; border-collapse: collapse; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #f5f5f5; }
        .total { font-weight: bold; font-size: 16px; }
    </style>
</head>
<body>
    <h1>Invoice #{{ $invoice->id }}</h1>
    
    <table>
        <thead>
            <tr>
                <th>Item</th>
                <th>Qty</th>
                <th>Price</th>
                <th>Total</th>
            </tr>
        </thead>
        <tbody>
            @foreach ($items as $item)
            <tr>
                <td>{{ $item->description }}</td>
                <td>{{ $item->quantity }}</td>
                <td>${{ number_format($item->price, 2) }}</td>
                <td>${{ number_format($item->total, 2) }}</td>
            </tr>
            @endforeach
        </tbody>
    </table>
    
    <div class="total">Total: ${{ number_format($total, 2) }}</div>
</body>
</html>

Dompdf is fast because it's pure PHP. No subprocess overhead. On our platforms, a typical invoice renders in 200–400ms, even with hundreds of line items. The trade-off: pixel-perfect layouts are harder. Margins can shift. Font rendering varies. Images sometimes fail to embed. But for 95% of invoices and simple reports, Dompdf works and it's maintainable.

Browsershot for pixel-perfect HTML

When a client needs their branded report to look exactly like the HTML mockup, or when your design has complex CSS that Dompdf can't handle, you need Browsershot. It wraps Puppeteer or a headless Chrome instance and renders the page in a real browser, then exports to PDF. The result is pixel-perfect because it's actually a screenshot.

The downside: Browsershot is slower (typically 1–3 seconds per page) and requires Chrome or a headless browser to be installed. On Lambda, it adds significant cold-start time. But for on-demand reports that get cached, or for a fixed number of PDFs generated per day, it's worth it.

Browsershot setup requires the package and either a local Chrome installation or a Lambda layer. Laravel-Browsershot makes it dead simple:

php
use Spatie\Browsershot\Browsershot;

public function generateReport(Report $report)
{
    $html = view('reports.template', [
        'report' => $report,
        'data' => $report->getData(),
    ])->render();
    
    Browsershot::html($html)
        ->setChromePath('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
        ->paperSize(210, 297) // A4
        ->margins(10, 10, 10, 10)
        ->savePdf(storage_path("reports/report-{$report->id}.pdf"));
    
    return storage_path("reports/report-{$report->id}.pdf");
}

When you use Browsershot, your Blade template can be a full-featured modern page: CSS Grid, Flexbox, custom fonts, complex images, even charts rendered by JavaScript. The browser renders it all, then Puppeteer screenshots it as PDF. No layout surprises.

Real talk: on shared hosting or smaller servers, running Chromium is overkill. On AWS Lambda, you'll want a pre-built layer or container image. In a Kubernetes cluster with headless browsers as a sidecar, it integrates cleanly. The choice between Dompdf and Browsershot should be: "Does our template need browser CSS?" If yes, Browsershot. If it's tabular data and simple formatting, Dompdf is faster and lighter.

Generate in a queued job

Never generate a PDF in the HTTP request path. Your user hits "download invoice," you queue a job, you redirect them immediately. The job runs in the background, stores the file, and when the PDF is ready, you email a link or provide a download endpoint.

Why? PDF generation is I/O-bound and can fail. If you generate inline and Chrome crashes, your user sees a 500 error. If it takes 3 seconds, they wait. If ten users request PDFs simultaneously, your app becomes unresponsive. Queueing decouples generation from the request.

Here's the pattern we use:

php
// In your controller
public function requestInvoice(Invoice $invoice)
{
    // Immediately return; don't generate yet
    GeneratePdfJob::dispatch($invoice);
    
    return response()->json([
        'message' => 'Your invoice is being generated. Check your email shortly.',
        'id' => $invoice->id,
    ]);
}

// In app/Jobs/GeneratePdfJob.php
namespace App\Jobs;

use App\Models\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;

class GeneratePdfJob implements ShouldQueue
{
    use Queueable, SerializesModels;
    
    protected Invoice $invoice;
    
    public function __construct(Invoice $invoice)
    {
        $this->invoice = $invoice;
        $this->onQueue('pdfs'); // Separate queue for PDF work
    }
    
    public function handle()
    {
        try {
            $pdf = Pdf::loadView('invoices.template', [
                'invoice' => $this->invoice,
                'items' => $this->invoice->items,
            ]);
            
            // Store locally first for testing, but we'll move to S3
            $path = storage_path("pdfs/invoice-{$this->invoice->id}.pdf");
            $pdf->save($path);
            
            // Update the invoice record with the file path
            $this->invoice->update([
                'pdf_path' => $path,
                'pdf_generated_at' => now(),
            ]);
            
            // Notify the user
            \App\Notifications\InvoicePdfReady::dispatch($this->invoice);
        } catch (\Exception $e) {
            \Log::error("PDF generation failed for invoice {$this->invoice->id}", [
                'error' => $e->getMessage(),
            ]);
            throw $e; // Queue retries on exception
        }
    }
}

For heavy workloads, create a dedicated queue supervisor just for PDFs. On Laravel Forge, configure a separate daemon. In Kubernetes, scale the PDF worker independently. When PDF generation is isolated, a spike in invoices doesn't lock up your web requests.

Once the PDF is generated, store it in S3 (or another cloud storage). Don't keep PDFs in local storage—they're ephemeral, take up disk space, and aren't easily shared. S3 gives you durability, scalability, and integrates cleanly with Laravel's Storage facade.

The flow: generate PDF to temporary storage, upload to S3, delete local file, update the database with the S3 path, email the user a signed link.

php
// Updated GeneratePdfJob
public function handle()
{
    try {
        $pdf = Pdf::loadView('invoices.template', [
            'invoice' => $this->invoice,
            'items' => $this->invoice->items,
        ]);
        
        // Generate to temp file
        $tempPath = storage_path("temp/invoice-{$this->invoice->id}.pdf");
        $pdf->save($tempPath);
        
        // Upload to S3
        $s3Path = "invoices/{$this->invoice->id}.pdf";
        \Storage::disk('s3')->put(
            $s3Path,
            file_get_contents($tempPath),
            ['visibility' => 'private']
        );
        
        // Clean up temp file
        unlink($tempPath);
        
        // Update invoice with S3 path
        $this->invoice->update([
            'pdf_path' => $s3Path,
            'pdf_generated_at' => now(),
        ]);
        
        // Generate a signed URL valid for 7 days
        $downloadUrl = \Storage::disk('s3')->temporaryUrl(
            $s3Path,
            now()->addDays(7)
        );
        
        // Email the link
        \Mail::send(new InvoicePdfReady($this->invoice, $downloadUrl));
        
    } catch (\Exception $e) {
        \Log::error("PDF generation failed for invoice {$this->invoice->id}", [
            'error' => $e->getMessage(),
        ]);
        throw $e;
    }
}

In your mailable:

php
namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class InvoicePdfReady extends Mailable
{
    public function __construct(
        public $invoice,
        public $downloadUrl,
    ) {}
    
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Your invoice #{$this->invoice->id} is ready",
        );
    }
    
    public function content(): Content
    {
        return new Content(
            view: 'emails.invoice-ready',
            with: [
                'invoice' => $this->invoice,
                'url' => $this->downloadUrl,
            ],
        );
    }
}

Benefits: Files are durable and versioned. Signed URLs expire, so old links eventually stop working. Users never hit your app to download—traffic goes directly to S3. Storage is cheap. And if a file gets corrupted, you regenerate it without local storage overhead.

Common mistakes

Generating PDFs in the request. Every time a user refreshes the download, you generate it again. Under load, your app hangs. Queue it instead.

Storing PDFs locally and forgetting to clean up. After a month, your /storage disk is full. Use S3 or set up a cron to archive old PDFs.

Hardcoding Dompdf CSS instead of testing media print rules. Your template works in development but fonts look wrong in production. Use consistent font families (system fonts or embedded) and test with real Dompdf, not just in your browser.

Not setting queue timeouts for Browsershot. Chrome can hang or take unexpectedly long. Set timeout => 30 on the job and let failed jobs retry or deadletter.

Sending unencrypted S3 URLs in emails. Use signed URLs with expiration. Regenerate them if someone needs a new link; don't keep permanent public URLs.

Forgetting to set visibility to private on S3. If your PDFs contain sensitive data, ensure the S3 object is marked private and only accessible via signed URLs.

Key takeaways

  • Use Dompdf for simple, tabular documents; it's fast, zero-dependency, and reliable for invoices and receipts.
  • Reach for Browsershot when you need pixel-perfect layouts or complex CSS; trade speed for visual fidelity.
  • Always generate PDFs in a queued job. Never block the HTTP request. Route PDF work to a separate queue if traffic is high.
  • Store generated PDFs in S3 with private visibility. Use signed URLs to grant temporary access and avoid local disk bloat.
  • Email users a signed link instead of attaching the PDF. It scales better, users get the latest version, and you control expiration.
  • Test your templates with actual Dompdf or Browsershot output, not just in the browser. Layout surprises happen.
  • Set queue timeouts, monitor failed jobs, and log errors. PDF generation is a background process—visibility is key to reliability.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles